From e44936dfc81175da457da643a8182870433c7a9f Mon Sep 17 00:00:00 2001 From: Jonathan Matthey Date: Thu, 4 Jun 2026 03:47:42 +0200 Subject: [PATCH 01/16] feat(blog): add code block with syntax highlighting and copy button [INTORG-780] --- .../foundation-blog-post/schema.json | 3 +- cms/src/components/blocks/code-block.json | 50 +++++++++++ cms/src/index.ts | 19 +++- .../blocks/code-block.serializer.test.ts | 79 +++++++++++++++++ .../blocks/code-block.serializer.ts | 24 ++++++ cms/src/serializers/blocks/index.ts | 4 +- cms/src/utils/contentPopulate.ts | 3 +- cms/types/generated/components.d.ts | 45 ++++++++++ cms/types/generated/contentTypes.d.ts | 7 +- src/components/blocks/CodeBlock.astro | 86 +++++++++++++++++++ src/components/blocks/DynamicZone.astro | 4 +- .../pages/DevelopersBlogPostPage.astro | 3 +- .../pages/FoundationBlogPostPage.astro | 4 +- src/data/ui.ts | 8 +- 14 files changed, 329 insertions(+), 10 deletions(-) create mode 100644 cms/src/components/blocks/code-block.json create mode 100644 cms/src/serializers/blocks/code-block.serializer.test.ts create mode 100644 cms/src/serializers/blocks/code-block.serializer.ts create mode 100644 src/components/blocks/CodeBlock.astro diff --git a/cms/src/api/foundation-blog-post/content-types/foundation-blog-post/schema.json b/cms/src/api/foundation-blog-post/content-types/foundation-blog-post/schema.json index c13fda53..46ffd283 100644 --- a/cms/src/api/foundation-blog-post/content-types/foundation-blog-post/schema.json +++ b/cms/src/api/foundation-blog-post/content-types/foundation-blog-post/schema.json @@ -123,7 +123,8 @@ "components": [ "blocks.paragraph", "blocks.video-embed", - "blocks.image-block" + "blocks.image-block", + "blocks.code-block" ], "pluginOptions": { "i18n": { "localized": true } } }, diff --git a/cms/src/components/blocks/code-block.json b/cms/src/components/blocks/code-block.json new file mode 100644 index 00000000..14d50cd5 --- /dev/null +++ b/cms/src/components/blocks/code-block.json @@ -0,0 +1,50 @@ +{ + "collectionName": "components_blocks_code_blocks", + "info": { + "displayName": "Code Block", + "icon": "code", + "description": "Syntax-highlighted code snippet with optional title and copy button" + }, + "options": {}, + "attributes": { + "code": { + "type": "text", + "required": true, + "pluginOptions": { + "i18n": { + "localized": true + } + } + }, + "language": { + "type": "enumeration", + "required": true, + "default": "javascript", + "enum": [ + "javascript", + "typescript", + "jsx", + "tsx", + "html", + "css", + "bash", + "json", + "yaml", + "python", + "rust", + "go", + "sql", + "markdown" + ] + }, + "title": { + "type": "string", + "required": false, + "pluginOptions": { + "i18n": { + "localized": true + } + } + } + } +} diff --git a/cms/src/index.ts b/cms/src/index.ts index d7466c10..e3307d0f 100644 --- a/cms/src/index.ts +++ b/cms/src/index.ts @@ -686,6 +686,11 @@ async function configureFieldLabels(strapi: StrapiInstance) { needsFullView: 'Needs full view', needsOutline: 'Needs outline' }, + 'blocks.code-block': { + code: 'Code', + language: 'Language', + title: 'Title (optional)' + }, 'shared.category': { categoryValue: 'Category' }, @@ -721,6 +726,10 @@ async function configureFieldLabels(strapi: StrapiInstance) { 'Enable for complex images, diagrams, or anything where fine detail matters.', needsOutline: 'Enable if the image has a white or light background and needs a boundary to separate it from blending into the page.' + }, + 'blocks.code-block': { + title: 'Displayed as the filename label above the code. Leave blank to show the language name.', + code: 'Paste or type your code here.' } } @@ -984,7 +993,15 @@ async function configureLayouts(strapi: StrapiInstance) { ], [{ name: 'hero_call_to_action', size: 12 }] ], - 'shared.seo': [[{ name: 'metaDescription', size: 12 }]] + 'shared.seo': [[{ name: 'metaDescription', size: 12 }]], + 'blocks.table-block': [[{ name: 'content', size: 12 }]], + 'blocks.code-block': [ + [ + { name: 'language', size: 4 }, + { name: 'title', size: 8 } + ], + [{ name: 'code', size: 12 }] + ] } const contentTypeService = plugin.service('content-types') as diff --git a/cms/src/serializers/blocks/code-block.serializer.test.ts b/cms/src/serializers/blocks/code-block.serializer.test.ts new file mode 100644 index 00000000..8addfcd5 --- /dev/null +++ b/cms/src/serializers/blocks/code-block.serializer.test.ts @@ -0,0 +1,79 @@ +import { describe, it, expect } from 'vitest' +import { serialize } from './code-block.serializer' + +describe('code-block serializer', () => { + it('wraps code in a CodeBlock tag with language and code props', () => { + const result = serialize({ code: 'const x = 1', language: 'javascript' }) + + expect(result).toContain(' { + const result = serialize({ + code: 'const x = 1', + language: 'typescript', + title: 'Example' + }) + + expect(result).toContain('title="Example"') + }) + + it('omits title attribute when not provided', () => { + const result = serialize({ code: 'const x = 1', language: 'javascript' }) + + expect(result).not.toContain('title=') + }) + + it('escapes backticks in code', () => { + const result = serialize({ + code: 'const x = `hello`', + language: 'javascript' + }) + + expect(result).toContain('\\`hello\\`') + }) + + it('escapes template literal expressions in code', () => { + const result = serialize({ + code: 'const x = `hello ${name}`', + language: 'javascript' + }) + + expect(result).toContain('\\${name}') + }) + + it('escapes backslashes in code', () => { + const result = serialize({ + code: 'const path = "C:\\\\Users"', + language: 'javascript' + }) + + expect(result).toContain('C:\\\\\\\\Users') + }) + + it('throws when code is missing', () => { + expect(() => + serialize({ code: '', language: 'javascript' }) + ).toThrow('CodeBlock block is missing code') + }) + + it('throws when language is missing', () => { + expect(() => serialize({ code: 'x = 1', language: '' })).toThrow( + 'CodeBlock block is missing language' + ) + }) + + it('serialises Python code correctly', () => { + const result = serialize({ + code: 'def hello():\n print("world")', + language: 'python', + title: 'hello.py' + }) + + expect(result).toContain('language="python"') + expect(result).toContain('title="hello.py"') + expect(result).toContain('def hello()') + }) +}) diff --git a/cms/src/serializers/blocks/code-block.serializer.ts b/cms/src/serializers/blocks/code-block.serializer.ts new file mode 100644 index 00000000..a0c6ade5 --- /dev/null +++ b/cms/src/serializers/blocks/code-block.serializer.ts @@ -0,0 +1,24 @@ +import { escDouble as esc } from '../shared' + +interface CodeBlockBlock { + code: string + language: string + title?: string +} + +export function serialize(block: CodeBlockBlock): string { + if (!block.code) throw new Error('CodeBlock block is missing code') + if (!block.language) throw new Error('CodeBlock block is missing language') + + // Escape for use inside a JS template literal: backslashes first, + // then backticks, then template-expression openers. + const safeCode = block.code + .replace(/\\/g, '\\\\') + .replace(/`/g, '\\`') + .replace(/\$\{/g, '\\${') + + const attrs = [`language="${esc(block.language)}"`] + if (block.title) attrs.push(`title="${esc(block.title)}"`) + + return `` +} diff --git a/cms/src/serializers/blocks/index.ts b/cms/src/serializers/blocks/index.ts index 494a48eb..ae11d7ea 100644 --- a/cms/src/serializers/blocks/index.ts +++ b/cms/src/serializers/blocks/index.ts @@ -17,6 +17,7 @@ import { serialize as ctaStrip } from './cta-strip.serializer' import { serialize as pdfEmbed } from './pdf-embed.serializer' import { serialize as videoEmbed } from './video-embed.serializer' import { serialize as imageBlock } from './image-block.serializer' +import { serialize as codeBlock } from './code-block.serializer' const SERIALIZERS: Record string> = { 'blocks.cards-grid': cardsGrid, @@ -32,7 +33,8 @@ const SERIALIZERS: Record string> = { 'blocks.cta-strip': ctaStrip, 'blocks.pdf-embed': pdfEmbed, 'blocks.video-embed': videoEmbed, - 'blocks.image-block': imageBlock + 'blocks.image-block': imageBlock, + 'blocks.code-block': codeBlock } export function serializeContent( diff --git a/cms/src/utils/contentPopulate.ts b/cms/src/utils/contentPopulate.ts index d876da60..cd11b700 100644 --- a/cms/src/utils/contentPopulate.ts +++ b/cms/src/utils/contentPopulate.ts @@ -33,7 +33,8 @@ const FOUNDATION_BLOG_BLOCKS = { 'blocks.video-embed': {}, 'blocks.image-block': { populate: { image: true, tabletImage: true, mobileImage: true } - } + }, + 'blocks.code-block': {} } as const /** Populate config for foundation-page and summit-page content fields. */ diff --git a/cms/types/generated/components.d.ts b/cms/types/generated/components.d.ts index ab78203e..2421e063 100644 --- a/cms/types/generated/components.d.ts +++ b/cms/types/generated/components.d.ts @@ -308,6 +308,50 @@ export interface BlocksCarouselItem extends Struct.ComponentSchema { } } +export interface BlocksCodeBlock extends Struct.ComponentSchema { + collectionName: 'components_blocks_code_blocks' + info: { + description: 'Syntax-highlighted code snippet with optional title and copy button' + displayName: 'Code Block' + icon: 'code' + } + attributes: { + code: Schema.Attribute.Text & + Schema.Attribute.Required & + Schema.Attribute.SetPluginOptions<{ + i18n: { + localized: true + } + }> + language: Schema.Attribute.Enumeration< + [ + 'javascript', + 'typescript', + 'jsx', + 'tsx', + 'html', + 'css', + 'bash', + 'json', + 'yaml', + 'python', + 'rust', + 'go', + 'sql', + 'markdown' + ] + > & + Schema.Attribute.Required & + Schema.Attribute.DefaultTo<'javascript'> + title: Schema.Attribute.String & + Schema.Attribute.SetPluginOptions<{ + i18n: { + localized: true + } + }> + } +} + export interface BlocksCtaBanner extends Struct.ComponentSchema { collectionName: 'components_blocks_cta_banners' info: { @@ -855,6 +899,7 @@ declare module '@strapi/strapi' { 'blocks.cards-grid': BlocksCardsGrid 'blocks.carousel': BlocksCarousel 'blocks.carousel-item': BlocksCarouselItem + 'blocks.code-block': BlocksCodeBlock 'blocks.cta-banner': BlocksCtaBanner 'blocks.cta-strip': BlocksCtaStrip 'blocks.image-block': BlocksImageBlock diff --git a/cms/types/generated/contentTypes.d.ts b/cms/types/generated/contentTypes.d.ts index 47302d92..230be5eb 100644 --- a/cms/types/generated/contentTypes.d.ts +++ b/cms/types/generated/contentTypes.d.ts @@ -547,7 +547,12 @@ export interface ApiFoundationBlogPostFoundationBlogPost } }> content: Schema.Attribute.DynamicZone< - ['blocks.paragraph', 'blocks.video-embed', 'blocks.image-block'] + [ + 'blocks.paragraph', + 'blocks.video-embed', + 'blocks.image-block', + 'blocks.code-block' + ] > & Schema.Attribute.SetPluginOptions<{ i18n: { diff --git a/src/components/blocks/CodeBlock.astro b/src/components/blocks/CodeBlock.astro new file mode 100644 index 00000000..09a7c8af --- /dev/null +++ b/src/components/blocks/CodeBlock.astro @@ -0,0 +1,86 @@ +--- +import { codeToHtml } from 'shiki' +import { useTranslations } from '@/utils' + +interface Props { + code: string + language: string + title?: string +} + +const { code, language, title } = Astro.props +const { routeLocale } = Astro.locals +const t = useTranslations(routeLocale) + +const highlighted = await codeToHtml(code, { + lang: language, + theme: 'github-dark-dimmed' +}) +--- + +
+ {/* Title bar */} +
+ {/* Decorative window dots */} + + + {/* Filename / title */} + + {title ?? language} + + + {/* Copy button */} + +
+ + {/* Highlighted code */} +
+
+ + diff --git a/src/components/blocks/DynamicZone.astro b/src/components/blocks/DynamicZone.astro index 3d8ab732..8cddf7b7 100644 --- a/src/components/blocks/DynamicZone.astro +++ b/src/components/blocks/DynamicZone.astro @@ -20,6 +20,7 @@ import CalloutTextBlock from './CalloutTextBlock.astro' import PdfEmbedBlock from './PdfEmbedBlock.astro' import VideoEmbedBlock from './VideoEmbedBlock.astro' import ImageBlock from './ImageBlock.astro' +import CodeBlock from './CodeBlock.astro' interface Block { __component: string @@ -46,7 +47,8 @@ const componentMap: Record = { 'blocks.callout-text': CalloutTextBlock, 'blocks.pdf-embed': PdfEmbedBlock, 'blocks.video-embed': VideoEmbedBlock, - 'blocks.image-block': ImageBlock + 'blocks.image-block': ImageBlock, + 'blocks.code-block': CodeBlock } const resolvedBlocks = blocks diff --git a/src/components/pages/DevelopersBlogPostPage.astro b/src/components/pages/DevelopersBlogPostPage.astro index 22c4c253..8f1d1ea3 100644 --- a/src/components/pages/DevelopersBlogPostPage.astro +++ b/src/components/pages/DevelopersBlogPostPage.astro @@ -6,6 +6,7 @@ import DevelopersBlogLayout from '@/layouts/DevelopersBlogLayout.astro' import TranslationDisclaimer from '@/components/shared/TranslationDisclaimer.astro' import OptimizedImage from '@/components/shared/OptimizedImage.astro' import ImageBlock from '@/components/blocks/ImageBlock.astro' +import CodeBlock from '@/components/blocks/CodeBlock.astro' interface Props { slug: string @@ -45,6 +46,6 @@ const blogPath = translatePath( - + diff --git a/src/components/pages/FoundationBlogPostPage.astro b/src/components/pages/FoundationBlogPostPage.astro index 39d635ea..812ae8b8 100644 --- a/src/components/pages/FoundationBlogPostPage.astro +++ b/src/components/pages/FoundationBlogPostPage.astro @@ -17,6 +17,7 @@ import VideoEmbed from '@/components/shared/VideoEmbed.astro' import TranslationDisclaimer from '@/components/shared/TranslationDisclaimer.astro' import OptimizedImage from '@/components/shared/OptimizedImage.astro' import ImageBlock from '@/components/blocks/ImageBlock.astro' +import CodeBlock from '@/components/blocks/CodeBlock.astro' interface Props { slug: string @@ -65,7 +66,8 @@ const relatedPosts = (mdxData.relatedArticles ?? []) Blockquote, Paragraph, VideoEmbed, - ImageBlock + ImageBlock, + CodeBlock }} /> diff --git a/src/data/ui.ts b/src/data/ui.ts index 162aab87..0d3d481f 100644 --- a/src/data/ui.ts +++ b/src/data/ui.ts @@ -220,7 +220,9 @@ export const ui = { 'home.stats.countries_label': 'Countries with active tech', 'home.stats.countries_aria': '45 plus countries with active tech', 'home.stats.students_label': 'Students skilled', - 'home.stats.students_aria': '1,000 plus students skilled' + 'home.stats.students_aria': '1,000 plus students skilled', + 'codeBlock.copy': 'Copy', + 'codeBlock.copied': 'Copied!' }, es: { 'site.title': '', @@ -434,6 +436,8 @@ export const ui = { 'home.stats.countries_label': 'Países con tecnología activa', 'home.stats.countries_aria': 'Más de 45 países con tecnología activa', 'home.stats.students_label': 'Estudiantes capacitados', - 'home.stats.students_aria': 'Más de 1,000 estudiantes capacitados' + 'home.stats.students_aria': 'Más de 1,000 estudiantes capacitados', + 'codeBlock.copy': '', + 'codeBlock.copied': '' } } as const From c1a75dfb4f5e4cc160ad880c3db65bf16ec42766 Mon Sep 17 00:00:00 2001 From: Jonathan Matthey Date: Thu, 4 Jun 2026 03:57:14 +0200 Subject: [PATCH 02/16] style codeblock --- src/components/blocks/CodeBlock.astro | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/src/components/blocks/CodeBlock.astro b/src/components/blocks/CodeBlock.astro index 09a7c8af..97e52d90 100644 --- a/src/components/blocks/CodeBlock.astro +++ b/src/components/blocks/CodeBlock.astro @@ -14,17 +14,17 @@ const t = useTranslations(routeLocale) const highlighted = await codeToHtml(code, { lang: language, - theme: 'github-dark-dimmed' + theme: 'github-light' }) ---
{/* Title bar */}
{/* Decorative window dots */} {/* Filename / title */} - + {title ?? language} {/* Copy button */}
{/* Highlighted code */}
Date: Thu, 4 Jun 2026 23:29:21 +0200 Subject: [PATCH 03/16] pnpm format --- cms/src/index.ts | 3 ++- .../blocks/code-block.serializer.test.ts | 6 ++--- src/components/blocks/CodeBlock.astro | 22 ++++++++++++++++--- 3 files changed, 24 insertions(+), 7 deletions(-) diff --git a/cms/src/index.ts b/cms/src/index.ts index e3307d0f..414c164e 100644 --- a/cms/src/index.ts +++ b/cms/src/index.ts @@ -728,7 +728,8 @@ async function configureFieldLabels(strapi: StrapiInstance) { 'Enable if the image has a white or light background and needs a boundary to separate it from blending into the page.' }, 'blocks.code-block': { - title: 'Displayed as the filename label above the code. Leave blank to show the language name.', + title: + 'Displayed as the filename label above the code. Leave blank to show the language name.', code: 'Paste or type your code here.' } } diff --git a/cms/src/serializers/blocks/code-block.serializer.test.ts b/cms/src/serializers/blocks/code-block.serializer.test.ts index 8addfcd5..c098141b 100644 --- a/cms/src/serializers/blocks/code-block.serializer.test.ts +++ b/cms/src/serializers/blocks/code-block.serializer.test.ts @@ -54,9 +54,9 @@ describe('code-block serializer', () => { }) it('throws when code is missing', () => { - expect(() => - serialize({ code: '', language: 'javascript' }) - ).toThrow('CodeBlock block is missing code') + expect(() => serialize({ code: '', language: 'javascript' })).toThrow( + 'CodeBlock block is missing code' + ) }) it('throws when language is missing', () => { diff --git a/src/components/blocks/CodeBlock.astro b/src/components/blocks/CodeBlock.astro index 97e52d90..86445dac 100644 --- a/src/components/blocks/CodeBlock.astro +++ b/src/components/blocks/CodeBlock.astro @@ -52,9 +52,25 @@ const highlighted = await codeToHtml(code, { aria-label={t('codeBlock.copy')} > {t('codeBlock.copy')} -
From 6726c4c9299d1e4ad06dcd2c9aa2df89dd61ba92 Mon Sep 17 00:00:00 2001 From: Jonathan Matthey Date: Thu, 4 Jun 2026 23:57:11 +0200 Subject: [PATCH 04/16] fix(code-block): mobile horizontal scroll and line spacing [INTORG-767] --- cms/src/components/blocks/code-block.json | 1 + src/components/blocks/CodeBlock.astro | 64 ++++++++++++++++++----- src/components/blocks/DynamicZone.astro | 8 +-- src/layouts/BlogPostLayout.astro | 2 +- 4 files changed, 54 insertions(+), 21 deletions(-) diff --git a/cms/src/components/blocks/code-block.json b/cms/src/components/blocks/code-block.json index 14d50cd5..7737285d 100644 --- a/cms/src/components/blocks/code-block.json +++ b/cms/src/components/blocks/code-block.json @@ -10,6 +10,7 @@ "code": { "type": "text", "required": true, + "description": "4-space indentation is normalized to 2 spaces.", "pluginOptions": { "i18n": { "localized": true diff --git a/src/components/blocks/CodeBlock.astro b/src/components/blocks/CodeBlock.astro index 86445dac..368dbfce 100644 --- a/src/components/blocks/CodeBlock.astro +++ b/src/components/blocks/CodeBlock.astro @@ -19,26 +19,22 @@ const highlighted = await codeToHtml(code, { ---
- {/* Title bar */}
- {/* Decorative window dots */} - {/* Filename / title */} - + {title ?? language} - {/* Copy button */}
+ +