From f97250481be58b82eed7599d946fc23ae5dcdf35 Mon Sep 17 00:00:00 2001 From: Chris Campbell Date: Tue, 15 Sep 2026 17:22:37 -0700 Subject: [PATCH 1/2] fix: ignore comments that do not contain a known command An HTML comment is now only treated as a command if it uses the exact command syntax and refers to one of the known commands (`def`, `begin-def`, `end-def`, or `section`). Any other comment is left alone and passed through to the output, which allows a page to include directives that are intended for other tools, for example: Previously these were parsed as (malformed) commands and caused the build to fail. Commands that refer to a known command name are still validated, and a `def`, `begin-def`, or `section` command that is used without an identifier is now reported as an error. --- packages/docs-builder/README.md | 9 ++++ packages/docs-builder/src/command.ts | 22 ++++++++++ packages/docs-builder/src/parse.spec.ts | 52 +++++++++++++++++++++- packages/docs-builder/src/parse.ts | 57 ++++++++++++++++--------- 4 files changed, 117 insertions(+), 23 deletions(-) diff --git a/packages/docs-builder/README.md b/packages/docs-builder/README.md index 842447c..1a31947 100644 --- a/packages/docs-builder/README.md +++ b/packages/docs-builder/README.md @@ -155,6 +155,15 @@ This block has two paragraphs that are captured using a ``` +Only comments that use one of the command names listed above are treated as commands; +any other comment is left alone and passed through to the generated output. This means +that a page can include normal comments as well as directives that are intended for +other tools, for example: + +```md + +``` + TODO: Add more documentation about custom commands. For the time being, refer to the examples on [this page](../../examples/sample-docs/projects/sample-guide/content/page_1.md) diff --git a/packages/docs-builder/src/command.ts b/packages/docs-builder/src/command.ts index c48f71a..9e28c7f 100644 --- a/packages/docs-builder/src/command.ts +++ b/packages/docs-builder/src/command.ts @@ -24,3 +24,25 @@ export interface CommandSection { } export type Command = CommandDef | CommandBeginDef | CommandEndDef | CommandSection + +/** The kind of a command, for example, `def` or `section`. */ +export type CommandKind = Command['kind'] + +/** + * The set of command kinds that are recognized by the parser. An HTML comment is only + * treated as a command if it uses one of these names; any other comment is left alone, + * which allows the Markdown source to include normal comments as well as directives + * that are intended for other tools (for example, ``). + */ +const knownCommandKinds: readonly string[] = ['def', 'begin-def', 'end-def', 'section'] + +/** + * Return true if the given name is the name of a command that is recognized by the + * parser. + * + * @param name The command name, as it appears in an HTML comment. + * @returns True if the name refers to a known command, false otherwise. + */ +export function isKnownCommandKind(name: string): name is CommandKind { + return knownCommandKinds.includes(name) +} diff --git a/packages/docs-builder/src/parse.spec.ts b/packages/docs-builder/src/parse.spec.ts index b4264b2..63f3878 100644 --- a/packages/docs-builder/src/parse.spec.ts +++ b/packages/docs-builder/src/parse.spec.ts @@ -161,13 +161,61 @@ _This page was generated by [\`docs-builder\`](https://github.com/climateinterac `) }) - it('should throw an error if an unknown command is used', () => { + it('should ignore comments that do not contain a known command', () => { const md = `\ + + + + + + + + + +Hello +` + const enContext = new Context(config, 'en') + const parsed = parseMarkdownPageContent(enContext, 'page_1.md', md) + expect(parsed.raw).toContain('') + expect(parsed.raw).toContain('') + expect(parsed.raw).toContain('') + expect(parsed.raw).toContain('') + expect(parsed.raw).toContain('') + expect(parsed.raw).toContain('Hello') + }) + + it('should ignore a comment that uses a known command name but malformed syntax', () => { + const md = `\ + + +Hello +` + const enContext = new Context(config, 'en') + const parsed = parseMarkdownPageContent(enContext, 'page_1.md', md) + expect(parsed.raw).toContain('') + expect(parsed.raw).toContain('Hello') + }) + + it('should throw an error if a command that requires an identifier is used without one', () => { + const md = `\ + + +Hello +` + const enContext = new Context(config, 'en') + expect(() => parseMarkdownPageContent(enContext, 'page_1.md', md)).toThrow( + `Command 'def' requires an identifier (page=page_1.md)` + ) + }) + + it('should throw an error if a section command is used without an identifier', () => { + const md = `\ +# Section 1 ` const enContext = new Context(config, 'en') expect(() => parseMarkdownPageContent(enContext, 'page_1.md', md)).toThrow( - `Unknown command 'somecommand' (page=page_1.md)` + `Command 'section' requires an identifier (page=page_1.md)` ) }) diff --git a/packages/docs-builder/src/parse.ts b/packages/docs-builder/src/parse.ts index a30804f..662fa60 100644 --- a/packages/docs-builder/src/parse.ts +++ b/packages/docs-builder/src/parse.ts @@ -6,6 +6,7 @@ import { marked } from 'marked' import type { BlockId } from './block' import type { Command } from './command' +import { isKnownCommandKind } from './command' import type { Context } from './context' import { readTextFile } from './fs' import type { MarkdownPage } from './types' @@ -149,8 +150,16 @@ function processTokens(context: Context, state: ProcessState, tokens: marked.Tok } /** - * Parse the given `html` token and if it contains a command, return it, otherwise + * Parse the given `html` token and if it contains a known command, return it, otherwise * return undefined. + * + * A comment is only treated as a command if it uses the exact command syntax and refers + * to one of the known commands. Any other comment is ignored (and left in the output); + * this allows a page to include normal comments as well as directives that are intended + * for other tools, for example: + * ``` + * + * ``` */ function parseCommand(context: Context, token: marked.Token): Command | undefined { // def: @@ -173,51 +182,57 @@ function parseCommand(context: Context, token: marked.Token): Command | undefine return undefined } - // Note: the identifier is captured as a run of non-whitespace characters (rather + // Note: the comment must consist only of the command (the match is anchored at the + // start of the comment and the name must be followed by the end of the comment), so + // that a comment that only looks like a command (e.g. ``) is + // ignored instead of being reported as an error. + // Note also: the identifier is captured as a run of non-whitespace characters (rather // than `\w+`) so that an id containing invalid characters (e.g. `%`) is still // captured here and caught by the validation below, instead of causing the whole // match to fail and the command to be silently ignored - const m = raw.match(//) + const m = raw.match(/^/) if (!m) { return undefined } - if (m[3]) { - if (!m[3].match(/^[a-z0-9]+(?:_+[a-z0-9]+)*$/)) { + // Ignore the comment if it doesn't refer to one of the known commands + const kind = m[1] + if (!isKnownCommandKind(kind)) { + return undefined + } + + // Validate the identifier; note that `end-def` is the only command that doesn't + // take an identifier + const id = m[3] + if (id) { + if (!id.match(/^[a-z0-9]+(?:_+[a-z0-9]+)*$/)) { throw new Error( context.getScopedMessage( - `Identifier (${m[3]}) must contain only lowercase letters, digits, and underscores` + `Identifier (${id}) must contain only lowercase letters, digits, and underscores` ) ) } + } else if (kind !== 'end-def') { + throw new Error(context.getScopedMessage(`Command '${kind}' requires an identifier`)) } - const rawKind = m[1] - switch (rawKind) { + switch (kind) { case 'def': - case 'begin-def': { - const idPart = m[3] - const id = idPart + case 'begin-def': return { - kind: rawKind, + kind, id, hidden: m[2] === '[hidden]' } - } case 'end-def': return { - kind: rawKind + kind } - case 'section': { - const idPart = m[3] - const id = idPart + case 'section': return { - kind: rawKind, + kind, id } - } - default: - throw new Error(context.getScopedMessage(`Unknown command '${rawKind}'`)) } } From c1627b679fc277585caef87fe1b038eb76eb6314 Mon Sep 17 00:00:00 2001 From: Chris Campbell Date: Tue, 15 Sep 2026 17:27:42 -0700 Subject: [PATCH 2/2] docs: fix comment --- packages/docs-builder/src/command.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/docs-builder/src/command.ts b/packages/docs-builder/src/command.ts index 9e28c7f..aa94ddc 100644 --- a/packages/docs-builder/src/command.ts +++ b/packages/docs-builder/src/command.ts @@ -41,7 +41,7 @@ const knownCommandKinds: readonly string[] = ['def', 'begin-def', 'end-def', 'se * parser. * * @param name The command name, as it appears in an HTML comment. - * @returns True if the name refers to a known command, false otherwise. + * @returns true if the name refers to a known command, false otherwise. */ export function isKnownCommandKind(name: string): name is CommandKind { return knownCommandKinds.includes(name)