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
9 changes: 9 additions & 0 deletions packages/docs-builder/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,15 @@ This block has two paragraphs that are captured using a
<!-- end-def -->
```

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
<!-- cSpell:disable -->
```

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)
Expand Down
22 changes: 22 additions & 0 deletions packages/docs-builder/src/command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, `<!-- cSpell:disable -->`).
*/
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)
}
52 changes: 50 additions & 2 deletions packages/docs-builder/src/parse.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = `\
<!-- cSpell:disable -->

<!-- cspell:ignore enroads -->

<!-- prettier-ignore -->

<!-- Just a normal comment -->

<!-- somecommand:key -->

Hello
`
const enContext = new Context(config, 'en')
const parsed = parseMarkdownPageContent(enContext, 'page_1.md', md)
expect(parsed.raw).toContain('<!-- cSpell:disable -->')
expect(parsed.raw).toContain('<!-- cspell:ignore enroads -->')
expect(parsed.raw).toContain('<!-- prettier-ignore -->')
expect(parsed.raw).toContain('<!-- Just a normal comment -->')
expect(parsed.raw).toContain('<!-- somecommand:key -->')
expect(parsed.raw).toContain('Hello')
})

it('should ignore a comment that uses a known command name but malformed syntax', () => {
const md = `\
<!-- def[Hidden]:example_1 -->

Hello
`
const enContext = new Context(config, 'en')
const parsed = parseMarkdownPageContent(enContext, 'page_1.md', md)
expect(parsed.raw).toContain('<!-- def[Hidden]:example_1 -->')
expect(parsed.raw).toContain('Hello')
})

it('should throw an error if a command that requires an identifier is used without one', () => {
const md = `\
<!-- def -->

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 -->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)`
)
})

Expand Down
57 changes: 36 additions & 21 deletions packages/docs-builder/src/parse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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:
* ```
* <!-- cSpell:disable -->
* ```
*/
function parseCommand(context: Context, token: marked.Token): Command | undefined {
// def:<id>
Expand All @@ -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. `<!-- cSpell:disable -->`) 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(/<!--\s*([a-z-]+)(\[hidden\])?:?(\S+)?\s*-->/)
const m = raw.match(/^<!--\s*([a-z][a-z-]*)(\[hidden\])?(?::(\S*))?\s*-->/)
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}'`))
}
}

Expand Down