From a0e68643b83bb2fc222d15461ab1096f0c52cae8 Mon Sep 17 00:00:00 2001 From: Vishal Rana Date: Wed, 4 Nov 2026 07:35:49 -0800 Subject: [PATCH 1/3] site: accept indented and titled Pro source fences The Echo Pro exporter now nests source includes under numbered list steps (1 to 3 spaces) and allows one literal title from a small alphabet. Mirror both rules in the publication checker; every other rejection stays. --- site/plugins/remark-source-code.test.mjs | 18 +++++++++++++++ site/scripts/pro-publication.mjs | 16 +++++++++---- site/scripts/pro-publication.test.mjs | 23 +++++++++++++++++++ site/scripts/testdata/publication-corpus.json | 4 ++-- 4 files changed, 54 insertions(+), 7 deletions(-) diff --git a/site/plugins/remark-source-code.test.mjs b/site/plugins/remark-source-code.test.mjs index 553e92de..c5fa0bad 100644 --- a/site/plugins/remark-source-code.test.mjs +++ b/site/plugins/remark-source-code.test.mjs @@ -53,6 +53,24 @@ test('includes a named region inside a nested fence', (t) => { assert.equal(quote.children[0].meta, null); }); +test('MDX ordered-list includes keep their list item and display title', (t) => { + const { root } = fixture(t); + const markdown = '1. Configure the file:\n\n ```go title="~/.netrc" file=server.go#handler\n ```'; + const [list] = render(markdown, root, 'install.mdx').children; + assert.equal(list.type, 'list'); + assert.equal(list.ordered, true); + const [item] = list.children; + assert.equal(item.type, 'listItem'); + const code = item.children.find((node) => node.type === 'code'); + assert.equal(code.value, 'func handler() {}'); + assert.equal(code.meta, 'title="~/.netrc"'); + const loader = withSourceCode({ name: 'test', load: (ctx) => ctx.generateDigest(markdown) }, { root }); + const context = { generateDigest: JSON.stringify }; + const before = loader.load(context); + writeFileSync(join(root, 'server.go'), '// docs:start handler\nfunc changed() {}\n// docs:end handler\n'); + assert.notEqual(loader.load(context), before); +}); + test('includes hash-comment regions from shell and workflow sources', (t) => { const { root } = fixture(t); writeFileSync(join(root, 'commands.sh'), '# docs:start install\n go mod verify\n# docs:end install\n'); diff --git a/site/scripts/pro-publication.mjs b/site/scripts/pro-publication.mjs index 071abd75..5765e8fd 100644 --- a/site/scripts/pro-publication.mjs +++ b/site/scripts/pro-publication.mjs @@ -150,11 +150,17 @@ function checkPublicationSyntax(name, body, files) { const end = trimmed.indexOf(']'); if (end < 0 || /[\[\\]/.test(trimmed.slice(1, end))) fail('unsupported reference label'); } - if (trimmed.startsWith('~~~') || trimmed.startsWith('```') && !line.startsWith('```')) fail('unsupported source fence'); - if (line.startsWith('```')) { - if (line.slice(3).includes('`')) fail('backtick in fence info'); - if (lines[++i] !== '```') fail('source fences must be empty'); - const attrs = line.slice(3).trim().split(/\s+/); + const fence = line.replace(/^ +/, ''); + const indent = line.slice(0, line.length - fence.length); + if (trimmed.startsWith('~~~') || trimmed.startsWith('```') && (!fence.startsWith('```') || indent.length > 3)) fail('unsupported source fence'); + if (trimmed.startsWith('```')) { + if (fence.slice(3).includes('`')) fail('backtick in fence info'); + if (lines[++i] !== `${indent}\x60\x60\x60`) fail('source fences must be empty'); + // Match the exporter's optional literal title and retain every other + // attribute for the existing language/source allowlist below. + const titles = [...fence.matchAll(/ title="[a-zA-Z0-9_./~ -]+"/g)]; + const info = titles.length === 1 ? fence.slice(0, titles[0].index) + fence.slice(titles[0].index + titles[0][0].length) : fence; + const attrs = info.slice(3).trim().split(/\s+/); if (attrs.length !== 2 || !/^[a-zA-Z][a-zA-Z0-9_-]*$/.test(attrs[0]) || !attrs[1].startsWith('file=site/pro-source/')) fail('every code fence must be an empty source include'); const [path, region, extra] = attrs[1].slice('file=site/pro-source/'.length).split('#'); if (!path.startsWith('examples/') || !files.has(path)) fail('unexported snippet'); diff --git a/site/scripts/pro-publication.test.mjs b/site/scripts/pro-publication.test.mjs index 26fe14cb..446dcdf5 100644 --- a/site/scripts/pro-publication.test.mjs +++ b/site/scripts/pro-publication.test.mjs @@ -12,6 +12,29 @@ const reviewedImport = "import ConfigReference from '../../../../components/Conf const api = { configs: [{ name: 'auth.Config' }] }; const files = new Map(); +test('source fences accept up to three spaces and one restricted literal title', () => { + const source = new Map([['examples/demo.sh', '# docs:start install\ngo mod verify\n# docs:end install\n']]); + const file = 'file=site/pro-source/examples/demo.sh#install'; + const check = (content) => checkMDX('pages/install.mdx', content, source, api); + for (const indent of ['', ' ', ' ', ' ']) { + for (const attrs of [file, `title="~/.netrc" ${file}`, `${file} title="A-Z_a-z/0.9 ~"`]) { + assert.doesNotThrow(() => check(`${indent}\x60\x60\x60sh ${attrs}\n${indent}\x60\x60\x60`)); + } + } + assert.doesNotThrow(() => check(`1. Install:\n\n \x60\x60\x60sh title="~/.netrc" ${file}\n \x60\x60\x60`)); + for (const indent of [' ', ' ', '\t', ' \t']) { + assert.throws(() => check(`${indent}\x60\x60\x60sh ${file}\n${indent}\x60\x60\x60`), /unsupported source fence/); + } + for (const close of ['```', ' ```', ' ```', ' ``` ', ' ````']) { + assert.throws(() => check(` \x60\x60\x60sh ${file}\n${close}`), /source fences must be empty/); + } + for (const attrs of [ + `title="" ${file}`, `title='demo' ${file}`, `title={demo} ${file}`, + `title="a" title="b" ${file}`, `title="a" extra ${file}`, + ...['{x}', '', 'a:b', 'a#b', 'a=b', 'a\\b', 'a\tb', 'café', 'a"b', 'a`b'].map((title) => `title="${title}" ${file}`), + ]) assert.throws(() => check(`\x60\x60\x60sh ${attrs}\n\x60\x60\x60`), undefined, attrs); +}); + test('only reviewed MDX imports, components and literal attributes are accepted', () => { for (const content of [ "import fs from 'node:fs';", "> import fs from 'node:fs';", "- import fs from 'node:fs';", "import secret from '/private/source.go?raw';", diff --git a/site/scripts/testdata/publication-corpus.json b/site/scripts/testdata/publication-corpus.json index 36aa34ef..d515e128 100644 --- a/site/scripts/testdata/publication-corpus.json +++ b/site/scripts/testdata/publication-corpus.json @@ -302,8 +302,8 @@ "id": "nested-fence- ", "name": "pages/reference/auth.mdx", "content": " ```go file=site/pro-source/examples/getting-started/main.go\n ```", - "allowed": false, - "reason": "unsupported source fence" + "allowed": true, + "reason": "" }, { "id": "source-fence", From d97f909193e452fddc31e798b744ecf79001ca26 Mon Sep 17 00:00:00 2001 From: Vishal Rana Date: Wed, 4 Nov 2026 09:03:55 -0800 Subject: [PATCH 2/3] site: match Pro source fences as one anchored line Mirror the exporter's sourceFence rule: indent, language, an optional title as its own token, then the file. Removing the title before splitting could join tokens and check a different language or file than the site renders. The publication corpus is the exporter's copy. --- site/scripts/pro-publication.mjs | 12 +- site/scripts/pro-publication.test.mjs | 10 +- site/scripts/testdata/publication-corpus.json | 138 +++++++++++++++++- 3 files changed, 148 insertions(+), 12 deletions(-) diff --git a/site/scripts/pro-publication.mjs b/site/scripts/pro-publication.mjs index 5765e8fd..802cce91 100644 --- a/site/scripts/pro-publication.mjs +++ b/site/scripts/pro-publication.mjs @@ -9,6 +9,8 @@ const reviewedImport = "import ConfigReference from '../../../../components/Conf const comments = /^\/\* Diataxis: (tutorial|how-to|reference|explanation)(; generated by sitedocs)? \*\/$/; const declarationPackages = new Set(['admin', 'audit', 'audit/auditpg', 'audit/auditadmin', 'auth', 'auth/authbind', 'execution', 'execution/pgstore', 'execution/executionadmin', 'quota', 'quota/redisstore', 'quota/quotaadmin', 'quota/quotaotel', 'quota/quotaprom', 'quota/quotamcp', 'quota/quotastripe', 'quota/quotastripe/stripeadmin', 'tenant', 'webhook', 'webhook/webhookmem', 'webhook/webhookpg', 'webhook/webhookadmin']); const markdownNodes = new Set(['root', 'yaml', 'paragraph', 'text', 'heading', 'thematicBreak', 'blockquote', 'list', 'listItem', 'emphasis', 'strong', 'delete', 'break', 'inlineCode', 'code', 'link', 'image', 'definition', 'linkReference', 'imageReference']); +// Match the complete line, exactly as the Go exporter's sourceFence does. +const sourceFence = /^ {0,3}```[a-zA-Z][a-zA-Z0-9_-]*(?: title="[a-zA-Z0-9_./~ -]+")? file=site\/pro-source\/([^\s`]+)$/; export function checkDestination(name, input, files) { let destination = input.replace(/&(#x[0-9a-f]+|#\d+|[a-z][a-z0-9]+);/gi, (whole, entity) => { @@ -156,13 +158,9 @@ function checkPublicationSyntax(name, body, files) { if (trimmed.startsWith('```')) { if (fence.slice(3).includes('`')) fail('backtick in fence info'); if (lines[++i] !== `${indent}\x60\x60\x60`) fail('source fences must be empty'); - // Match the exporter's optional literal title and retain every other - // attribute for the existing language/source allowlist below. - const titles = [...fence.matchAll(/ title="[a-zA-Z0-9_./~ -]+"/g)]; - const info = titles.length === 1 ? fence.slice(0, titles[0].index) + fence.slice(titles[0].index + titles[0][0].length) : fence; - const attrs = info.slice(3).trim().split(/\s+/); - if (attrs.length !== 2 || !/^[a-zA-Z][a-zA-Z0-9_-]*$/.test(attrs[0]) || !attrs[1].startsWith('file=site/pro-source/')) fail('every code fence must be an empty source include'); - const [path, region, extra] = attrs[1].slice('file=site/pro-source/'.length).split('#'); + const match = sourceFence.exec(line); + if (!match) fail('every code fence must be an empty source include'); + const [path, region, extra] = match[1].split('#'); if (!path.startsWith('examples/') || !files.has(path)) fail('unexported snippet'); if (region !== undefined) { const escaped = region.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); diff --git a/site/scripts/pro-publication.test.mjs b/site/scripts/pro-publication.test.mjs index 446dcdf5..2e47c911 100644 --- a/site/scripts/pro-publication.test.mjs +++ b/site/scripts/pro-publication.test.mjs @@ -17,7 +17,7 @@ test('source fences accept up to three spaces and one restricted literal title', const file = 'file=site/pro-source/examples/demo.sh#install'; const check = (content) => checkMDX('pages/install.mdx', content, source, api); for (const indent of ['', ' ', ' ', ' ']) { - for (const attrs of [file, `title="~/.netrc" ${file}`, `${file} title="A-Z_a-z/0.9 ~"`]) { + for (const attrs of [file, `title="~/.netrc" ${file}`, `title="A-Z_a-z/0.9 ~" ${file}`]) { assert.doesNotThrow(() => check(`${indent}\x60\x60\x60sh ${attrs}\n${indent}\x60\x60\x60`)); } } @@ -31,6 +31,7 @@ test('source fences accept up to three spaces and one restricted literal title', for (const attrs of [ `title="" ${file}`, `title='demo' ${file}`, `title={demo} ${file}`, `title="a" title="b" ${file}`, `title="a" extra ${file}`, + `${file} title="A-Z_a-z/0.9 ~"`, ...['{x}', '', 'a:b', 'a#b', 'a=b', 'a\\b', 'a\tb', 'café', 'a"b', 'a`b'].map((title) => `title="${title}" ${file}`), ]) assert.throws(() => check(`\x60\x60\x60sh ${attrs}\n\x60\x60\x60`), undefined, attrs); }); @@ -128,12 +129,15 @@ test('synthetic corpus traverses readPro and its source include validation', asy for (const c of corpus.cases.filter((c) => !c.currentPage)) await t.test(c.id, (t) => { const root = mkdtempSync(join(tmpdir(), 'pro-publication-corpus-')); t.after(() => rmSync(root, { recursive: true, force: true })); + // The shared grammar fixture uses a generic filename; materialize it at + // an already reviewed bundle path without widening the export allowlist. + const bundlePath = (value) => value.replaceAll('examples/demo.sh', 'examples/site-docs/commands.sh'); const payloads = { - ...corpus.files, + ...Object.fromEntries(Object.entries(corpus.files).map(([name, content]) => [bundlePath(name), content])), 'go.mod': 'module echo.labstack.com/pro-site-snapshot\n\ngo 1.26.0\n', 'config-fields.json': JSON.stringify({ ...api, module: 'echo.labstack.com/pro', revision: 'v0.1.0' }), 'problems.json': '[]', - [c.name]: c.content, + [c.name]: bundlePath(c.content), }; const provenance = { module: 'echo.labstack.com/pro', version: 'v0.1.0', commit: 'a'.repeat(40), hashes: {} }; for (const [name, content] of Object.entries(payloads)) { diff --git a/site/scripts/testdata/publication-corpus.json b/site/scripts/testdata/publication-corpus.json index d515e128..cce8867b 100644 --- a/site/scripts/testdata/publication-corpus.json +++ b/site/scripts/testdata/publication-corpus.json @@ -1,7 +1,8 @@ { "files": { "pages/auth.mdx": "", - "examples/getting-started/main.go": "// docs:start demo\npackage main\n// docs:end demo\n" + "examples/getting-started/main.go": "// docs:start demo\npackage main\n// docs:end demo\n", + "examples/demo.sh": "echo demo\n" }, "cases": [ { @@ -299,12 +300,145 @@ "reason": "unsupported source fence" }, { - "id": "nested-fence- ", + "id": "indented-source-fence", "name": "pages/reference/auth.mdx", "content": " ```go file=site/pro-source/examples/getting-started/main.go\n ```", "allowed": true, "reason": "" }, + { + "id": "source-fence-joined-language", + "name": "pages/reference/auth.mdx", + "content": "```g title=\"x\"o file=site/pro-source/examples/demo.sh\n```", + "allowed": false, + "reason": "every code fence must be an empty source include" + }, + { + "id": "source-fence-joined-path", + "name": "pages/reference/auth.mdx", + "content": "```sh file=site/pro-source/examples/de title=\"x\"mo.sh\n```", + "allowed": false, + "reason": "every code fence must be an empty source include" + }, + { + "id": "source-fence-title-before-language", + "name": "pages/reference/auth.mdx", + "content": "``` title=\"x\" sh file=site/pro-source/examples/demo.sh\n```", + "allowed": false, + "reason": "every code fence must be an empty source include" + }, + { + "id": "source-fence-two-titles", + "name": "pages/reference/auth.mdx", + "content": "```sh title=\"x\" title=\"y\" file=site/pro-source/examples/demo.sh\n```", + "allowed": false, + "reason": "every code fence must be an empty source include" + }, + { + "id": "source-fence-title-after-file", + "name": "pages/reference/auth.mdx", + "content": "```sh file=site/pro-source/examples/demo.sh title=\"x\"\n```", + "allowed": false, + "reason": "every code fence must be an empty source include" + }, + { + "id": "source-fence-tab-indent", + "name": "pages/reference/auth.mdx", + "content": "\t```sh file=site/pro-source/examples/demo.sh\n\t```", + "allowed": false, + "reason": "unsupported source fence" + }, + { + "id": "source-fence-closing-trailing-space", + "name": "pages/reference/auth.mdx", + "content": "```sh file=site/pro-source/examples/demo.sh\n``` ", + "allowed": false, + "reason": "source fences must be empty" + }, + { + "id": "source-fence-closing-four-backticks", + "name": "pages/reference/auth.mdx", + "content": "```sh file=site/pro-source/examples/demo.sh\n````", + "allowed": false, + "reason": "source fences must be empty" + }, + { + "id": "source-fence-single-quoted-title", + "name": "pages/reference/auth.mdx", + "content": "```sh title='x' file=site/pro-source/examples/demo.sh\n```", + "allowed": false, + "reason": "every code fence must be an empty source include" + }, + { + "id": "source-fence-title-open-brace", + "name": "pages/reference/auth.mdx", + "content": "```sh title=\"x{y\" file=site/pro-source/examples/demo.sh\n```", + "allowed": false, + "reason": "every code fence must be an empty source include" + }, + { + "id": "source-fence-title-close-brace", + "name": "pages/reference/auth.mdx", + "content": "```sh title=\"x}y\" file=site/pro-source/examples/demo.sh\n```", + "allowed": false, + "reason": "every code fence must be an empty source include" + }, + { + "id": "source-fence-title-double-quote", + "name": "pages/reference/auth.mdx", + "content": "```sh title=\"x\"y\" file=site/pro-source/examples/demo.sh\n```", + "allowed": false, + "reason": "every code fence must be an empty source include" + }, + { + "id": "source-fence-title-backtick", + "name": "pages/reference/auth.mdx", + "content": "```sh title=\"x`y\" file=site/pro-source/examples/demo.sh\n```", + "allowed": false, + "reason": "backtick in fence info" + }, + { + "id": "indented-source-fence-with-title", + "name": "pages/reference/auth.mdx", + "content": " ```sh title=\"~/.demo file-1_2\" file=site/pro-source/examples/demo.sh\n ```", + "allowed": true, + "reason": "" + }, + { + "id": "source-fence-with-title", + "name": "pages/reference/auth.mdx", + "content": "```sh title=\"~/.demo file-1_2\" file=site/pro-source/examples/demo.sh\n```", + "allowed": true, + "reason": "" + }, + { + "id": "source-fence-leading-space", + "name": "pages/reference/auth.mdx", + "content": "``` sh file=site/pro-source/examples/demo.sh\n```", + "allowed": false, + "reason": "every code fence must be an empty source include" + }, + { + "id": "source-fence-double-space", + "name": "pages/reference/auth.mdx", + "content": "```sh file=site/pro-source/examples/demo.sh\n```", + "allowed": false, + "reason": "every code fence must be an empty source include" + }, + { + "id": "source-fence-tab-separator", + "name": "pages/reference/auth.mdx", + "content": "```sh\tfile=site/pro-source/examples/demo.sh\n```", + "allowed": false, + "reason": "every code fence must be an empty source include" + }, + { + "id": "source-fence-trailing-space", + "name": "pages/reference/auth.mdx", + "content": "```sh file=site/pro-source/examples/demo.sh \n```", + "allowed": false, + "reason": "every code fence must be an empty source include" + }, { "id": "source-fence", "name": "pages/reference/auth.mdx", From 3b63861ae6a12b52f25c35585bb20d2cf18a08de Mon Sep 17 00:00:00 2001 From: Vishal Rana Date: Wed, 4 Nov 2026 09:06:20 -0800 Subject: [PATCH 3/3] site: limit Pro source paths to the exporter's character set Match the exporter's explicit [a-zA-Z0-9_./#-]+ path class so both sides accept the same lines; the corpus copy adds a Unicode-space case. --- site/scripts/pro-publication.mjs | 2 +- site/scripts/testdata/publication-corpus.json | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/site/scripts/pro-publication.mjs b/site/scripts/pro-publication.mjs index 802cce91..02edd72c 100644 --- a/site/scripts/pro-publication.mjs +++ b/site/scripts/pro-publication.mjs @@ -10,7 +10,7 @@ const comments = /^\/\* Diataxis: (tutorial|how-to|reference|explanation)(; gene const declarationPackages = new Set(['admin', 'audit', 'audit/auditpg', 'audit/auditadmin', 'auth', 'auth/authbind', 'execution', 'execution/pgstore', 'execution/executionadmin', 'quota', 'quota/redisstore', 'quota/quotaadmin', 'quota/quotaotel', 'quota/quotaprom', 'quota/quotamcp', 'quota/quotastripe', 'quota/quotastripe/stripeadmin', 'tenant', 'webhook', 'webhook/webhookmem', 'webhook/webhookpg', 'webhook/webhookadmin']); const markdownNodes = new Set(['root', 'yaml', 'paragraph', 'text', 'heading', 'thematicBreak', 'blockquote', 'list', 'listItem', 'emphasis', 'strong', 'delete', 'break', 'inlineCode', 'code', 'link', 'image', 'definition', 'linkReference', 'imageReference']); // Match the complete line, exactly as the Go exporter's sourceFence does. -const sourceFence = /^ {0,3}```[a-zA-Z][a-zA-Z0-9_-]*(?: title="[a-zA-Z0-9_./~ -]+")? file=site\/pro-source\/([^\s`]+)$/; +const sourceFence = /^ {0,3}```[a-zA-Z][a-zA-Z0-9_-]*(?: title="[a-zA-Z0-9_./~ -]+")? file=site\/pro-source\/([a-zA-Z0-9_.\/#-]+)$/; export function checkDestination(name, input, files) { let destination = input.replace(/&(#x[0-9a-f]+|#\d+|[a-z][a-z0-9]+);/gi, (whole, entity) => { diff --git a/site/scripts/testdata/publication-corpus.json b/site/scripts/testdata/publication-corpus.json index cce8867b..ebbfd206 100644 --- a/site/scripts/testdata/publication-corpus.json +++ b/site/scripts/testdata/publication-corpus.json @@ -306,6 +306,13 @@ "allowed": true, "reason": "" }, + { + "id": "unicode-space-in-path", + "name": "pages/reference/auth.mdx", + "content": "```sh file=site/pro-source/examples/demo.sh#a b\n```", + "allowed": false, + "reason": "every code fence must be an empty source include" + }, { "id": "source-fence-joined-language", "name": "pages/reference/auth.mdx",