Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions cms/scripts/migrate-blog-to-components.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import './sync-mdx/videoEmbedHandler'
import './sync-mdx/blockquoteHandler'
import './sync-mdx/calloutTextHandler'
import './sync-mdx/pdfEmbedHandler'
import './sync-mdx/codeBlockHandler'
import './sync-mdx/ambassadorHandler'

import { parseMdxToBlocks, type ParserContext } from './sync-mdx/mdxBlockParser'
Expand Down
42 changes: 42 additions & 0 deletions cms/scripts/sync-mdx/codeBlock-roundtrip.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { describe, it, expect } from 'vitest'
import { parseMdxToBlocks, type ParserContext } from './mdxBlockParser'
import { serialize } from '../../src/serializers/blocks/code-block.serializer'

// Side-effect import: registers CodeBlock handler
import './codeBlockHandler'

const ctx: ParserContext = { locale: 'en' }

async function roundTrip(code: string, language = 'javascript') {
const mdx = serialize({ code, language })
const blocks = await parseMdxToBlocks(mdx, ctx)
if (blocks instanceof Error) throw blocks
return (blocks[0] as { code: string }).code
}

describe('CodeBlock round-trip (serialize → parse)', () => {
it('preserves plain code', async () => {
const code = 'const x = 1'
expect(await roundTrip(code)).toBe(code)
})

it('preserves literal template-literal interpolation', async () => {
const code = 'const greeting = `hello ${name}`'
expect(await roundTrip(code)).toBe(code)
})

it('preserves a bare ${...} sequence', async () => {
const code = 'echo "${HOME}/bin"'
expect(await roundTrip(code, 'bash')).toBe(code)
})

it('preserves backslashes', async () => {
const code = 'const path = "C:\\Users\\dev"'
expect(await roundTrip(code)).toBe(code)
})

it('preserves a backslash directly before an interpolation', async () => {
const code = 'const s = "\\${not interpolated}"'
expect(await roundTrip(code)).toBe(code)
})
})
135 changes: 135 additions & 0 deletions cms/scripts/sync-mdx/codeBlockHandler.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import { describe, it, expect } from 'vitest'
import { parseMdxToBlocks } from './mdxBlockParser'
import { MdxParserError, ParserErrorCode } from './parserErrors'

// Side-effect import: registers CodeBlock handler
import './codeBlockHandler'

const ctx = { locale: 'en' }

describe('CodeBlock handler', () => {
it('parses language, title, and template literal code', async () => {
const blocks = await parseMdxToBlocks(
'<CodeBlock language="html" title="test" code={`qwe`} />',
ctx
)

expect(blocks).toEqual([
{
__component: 'blocks.code-block',
code: 'qwe',
language: 'html',
title: 'test'
}
])
})

it('parses multiline code from a template literal', async () => {
const blocks = await parseMdxToBlocks(
`<CodeBlock language="python" title="hello.py" code={\`def hello():
print("world")\`} />`,
ctx
)

expect(blocks[0]).toMatchObject({
__component: 'blocks.code-block',
language: 'python',
title: 'hello.py',
code: 'def hello():\n print("world")'
})
})

it('preserves source indentation as-is', async () => {
const blocks = await parseMdxToBlocks(
`<CodeBlock language="javascript" code={\`function run() {
if (true) {
return 1
}
}\`} />`,
ctx
)

expect((blocks[0] as { code: string }).code).toBe(
'function run() {\n if (true) {\n return 1\n }\n}'
)
})

it('omits title when not provided', async () => {
const blocks = await parseMdxToBlocks(
'<CodeBlock language="javascript" code={`const x = 1`} />',
ctx
)

expect(blocks[0]).toEqual({
__component: 'blocks.code-block',
code: 'const x = 1',
language: 'javascript'
})
})

it('preserves markdown order around CodeBlock', async () => {
const blocks = await parseMdxToBlocks(
`Intro paragraph.

<CodeBlock language="bash" code={\`echo hi\`} />

Outro paragraph.`,
ctx
)

expect(blocks).toHaveLength(3)
expect(blocks[0]).toMatchObject({ __component: 'blocks.paragraph' })
expect(blocks[1]).toMatchObject({
__component: 'blocks.code-block',
code: 'echo hi',
language: 'bash'
})
expect(blocks[2]).toMatchObject({ __component: 'blocks.paragraph' })
})
})

describe('CodeBlock handler — errors', () => {
it('returns MISSING_REQUIRED_PROP when language is missing', async () => {
const result = await parseMdxToBlocks(
'<CodeBlock code={`const x = 1`} />',
ctx
)
expect(result).toBeInstanceOf(MdxParserError)
expect(result).toMatchObject({
code: ParserErrorCode.MISSING_REQUIRED_PROP
})
})

it('returns MISSING_REQUIRED_PROP when code is missing', async () => {
const result = await parseMdxToBlocks(
'<CodeBlock language="javascript" />',
ctx
)
expect(result).toBeInstanceOf(MdxParserError)
expect(result).toMatchObject({
code: ParserErrorCode.MISSING_REQUIRED_PROP
})
})

it('returns INVALID_PROP_VALUE for unsupported language', async () => {
const result = await parseMdxToBlocks(
'<CodeBlock language="cobol" code={`x`} />',
ctx
)
expect(result).toBeInstanceOf(MdxParserError)
expect(result).toMatchObject({
code: ParserErrorCode.INVALID_PROP_VALUE
})
})

it('returns DYNAMIC_EXPRESSION for template interpolation', async () => {
const result = await parseMdxToBlocks(
'<CodeBlock language="javascript" code={`hello ${name}`} />',
ctx
)
expect(result).toBeInstanceOf(MdxParserError)
expect(result).toMatchObject({
code: ParserErrorCode.DYNAMIC_EXPRESSION
})
})
})
70 changes: 70 additions & 0 deletions cms/scripts/sync-mdx/codeBlockHandler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import type { CodeBlockBlock, ParsedBlock } from './types.blocks'
import { getStaticExpressionAttr, getStringAttr } from './jsxExtract'
import {
registerComponentHandler,
type JsxBlockNode,
type ParserContext
} from './mdxBlockParser'
import {
MdxParserError,
ParserErrorCode,
tryCatchParserError
} from './parserErrors'

const CODE_BLOCK_LANGUAGES = [
'javascript',
'typescript',
'jsx',
'tsx',
'html',
'css',
'bash',
'json',
'yaml',
'python',
'rust',
'go',
'sql',
'markdown'
] as const

type CodeBlockLanguage = (typeof CODE_BLOCK_LANGUAGES)[number]

async function handleCodeBlock(
node: JsxBlockNode,
ctx: ParserContext
): Promise<ParsedBlock[] | MdxParserError> {
return tryCatchParserError(() => {
const language = getStringAttr(node, 'language', { required: true })
const title = getStringAttr(node, 'title')
const code = getStaticExpressionAttr(node, 'code', {
required: true,
sourceText: ctx.sourceText
})

if (!CODE_BLOCK_LANGUAGES.includes(language as CodeBlockLanguage)) {
throw new MdxParserError({
code: ParserErrorCode.INVALID_PROP_VALUE,
message: `Prop "language" must be one of: ${CODE_BLOCK_LANGUAGES.join(', ')}.`,
component: node.name ?? undefined,
prop: 'language',
line: node.position?.start.line,
column: node.position?.start.column
})
}

const block: CodeBlockBlock = {
__component: 'blocks.code-block',
code,
language
}

if (title !== undefined) {
block.title = title
}

return [block]
})
}

registerComponentHandler('CodeBlock', handleCodeBlock)
1 change: 1 addition & 0 deletions cms/scripts/sync-mdx/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import './ctaStripHandler'
import './paragraphHandler'
import './pdfEmbedHandler'
import './videoEmbedHandler'
import './codeBlockHandler'
import { createRelationResolver } from './ambassadorHandler'
import { type ParserContext } from './mdxBlockParser'
import { MdxParserError, ParserErrorCode } from './parserErrors'
Expand Down
Loading
Loading