@thegetty/quire-cli 1.0.0-rc.38 → 1.0.0-rc.39
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -1
- package/src/commands/help.js +60 -0
- package/src/commands/help.test.js +132 -0
- package/src/errors/help/help-topic-not-found-error.js +22 -0
- package/src/errors/help/index.js +8 -0
- package/src/errors/index.js +6 -0
- package/src/errors/input/index.js +8 -0
- package/src/errors/input/invalid-input-error.js +21 -0
- package/src/helpers/pager.js +59 -0
- package/src/helpers/pager.test.js +85 -0
- package/src/lib/help/frontmatter.js +77 -0
- package/src/lib/help/frontmatter.test.js +139 -0
- package/src/lib/help/index.js +135 -0
- package/src/lib/help/index.test.js +160 -0
- package/src/lib/help/topics/configuration.md +77 -0
- package/src/lib/help/topics/debugging.md +69 -0
- package/src/lib/help/topics/epub.md +74 -0
- package/src/lib/help/topics/pdf.md +74 -0
- package/src/lib/help/topics/publishing.md +80 -0
- package/src/lib/help/topics/workflows.md +50 -0
- package/src/main.js +14 -1
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@thegetty/quire-cli",
|
|
3
3
|
"description": "Quire command-line interface",
|
|
4
|
-
"version": "1.0.0-rc.
|
|
4
|
+
"version": "1.0.0-rc.39",
|
|
5
5
|
"author": "Getty Digital",
|
|
6
6
|
"license": "SEE LICENSE IN https://github.com/thegetty/quire/blob/main/LICENSE",
|
|
7
7
|
"bugs": {
|
|
@@ -70,6 +70,8 @@
|
|
|
70
70
|
"inquirer": "^9.1.4",
|
|
71
71
|
"js-yaml": "^4.1.0",
|
|
72
72
|
"loglevel": "^1.8.1",
|
|
73
|
+
"marked": "^15.0.12",
|
|
74
|
+
"marked-terminal": "^7.3.0",
|
|
73
75
|
"node-fetch": "^3.3.2",
|
|
74
76
|
"open": "^8.4.0",
|
|
75
77
|
"ora": "^6.1.2",
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import Command from '#src/Command.js'
|
|
2
|
+
import { outputWithPaging } from '#helpers/pager.js'
|
|
3
|
+
import { getTopicContent, getTopicList } from '#lib/help/index.js'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Quire CLI `help` Command
|
|
7
|
+
*
|
|
8
|
+
* Display help for a specific topic or list available topics.
|
|
9
|
+
* Complements Commander's built-in help with extended documentation.
|
|
10
|
+
*
|
|
11
|
+
* @example
|
|
12
|
+
* quire help # List available topics
|
|
13
|
+
* quire help workflows # Show workflows topic
|
|
14
|
+
* quire help --list # List available topics
|
|
15
|
+
*
|
|
16
|
+
* @class HelpCommand
|
|
17
|
+
* @extends {Command}
|
|
18
|
+
*/
|
|
19
|
+
export default class HelpCommand extends Command {
|
|
20
|
+
static definition = {
|
|
21
|
+
name: 'help',
|
|
22
|
+
aliases: ['h'],
|
|
23
|
+
description: 'Display help for a topic or list available topics',
|
|
24
|
+
summary: 'show help for a topic',
|
|
25
|
+
docsLink: 'quire-commands/',
|
|
26
|
+
helpText: `
|
|
27
|
+
Examples:
|
|
28
|
+
quire help List available help topics
|
|
29
|
+
quire help workflows Show common workflow examples
|
|
30
|
+
quire help pdf Show PDF generation guide
|
|
31
|
+
quire help --list List all available topics
|
|
32
|
+
`,
|
|
33
|
+
version: '1.0.0',
|
|
34
|
+
args: [
|
|
35
|
+
['[topic]', 'help topic to display']
|
|
36
|
+
],
|
|
37
|
+
options: [
|
|
38
|
+
['--list', 'list all available topics'],
|
|
39
|
+
],
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
constructor() {
|
|
43
|
+
super(HelpCommand.definition)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async action(topic, options, command) {
|
|
47
|
+
this.debug('called with topic=%s options=%O', topic, options)
|
|
48
|
+
|
|
49
|
+
// List topics if no topic specified or --list flag
|
|
50
|
+
if (!topic || options.list) {
|
|
51
|
+
const list = await getTopicList()
|
|
52
|
+
process.stdout.write(list + '\n')
|
|
53
|
+
return
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Display requested topic with paging (throws HelpTopicNotFoundError if missing)
|
|
57
|
+
const content = await getTopicContent(topic)
|
|
58
|
+
await outputWithPaging(content)
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import esmock from 'esmock'
|
|
2
|
+
import sinon from 'sinon'
|
|
3
|
+
import test from 'ava'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Help Command Integration Tests
|
|
7
|
+
*
|
|
8
|
+
* Tests the help command behavior with mocked dependencies.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
test.serial('help command outputs topic list when called without arguments', async (t) => {
|
|
12
|
+
const sandbox = sinon.createSandbox()
|
|
13
|
+
const stdoutWrite = sandbox.stub(process.stdout, 'write')
|
|
14
|
+
|
|
15
|
+
try {
|
|
16
|
+
const mockList = 'Available help topics:\n debugging Troubleshooting help'
|
|
17
|
+
|
|
18
|
+
const HelpCommand = await esmock('./help.js', {
|
|
19
|
+
'#lib/help/index.js': {
|
|
20
|
+
getTopicList: sandbox.stub().resolves(mockList),
|
|
21
|
+
getTopicContent: sandbox.stub()
|
|
22
|
+
},
|
|
23
|
+
'#helpers/pager.js': {
|
|
24
|
+
outputWithPaging: sandbox.stub()
|
|
25
|
+
}
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
const command = new HelpCommand.default()
|
|
29
|
+
await command.action(undefined, {}, {})
|
|
30
|
+
|
|
31
|
+
t.true(stdoutWrite.calledOnce)
|
|
32
|
+
t.true(stdoutWrite.calledWith(mockList + '\n'))
|
|
33
|
+
} finally {
|
|
34
|
+
sandbox.restore()
|
|
35
|
+
}
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
test.serial('help command outputs topic list when --list flag is provided', async (t) => {
|
|
39
|
+
const sandbox = sinon.createSandbox()
|
|
40
|
+
const stdoutWrite = sandbox.stub(process.stdout, 'write')
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
const mockList = 'Available help topics:\n pdf PDF generation'
|
|
44
|
+
|
|
45
|
+
const HelpCommand = await esmock('./help.js', {
|
|
46
|
+
'#lib/help/index.js': {
|
|
47
|
+
getTopicList: sandbox.stub().resolves(mockList),
|
|
48
|
+
getTopicContent: sandbox.stub()
|
|
49
|
+
},
|
|
50
|
+
'#helpers/pager.js': {
|
|
51
|
+
outputWithPaging: sandbox.stub()
|
|
52
|
+
}
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
const command = new HelpCommand.default()
|
|
56
|
+
await command.action('ignored', { list: true }, {})
|
|
57
|
+
|
|
58
|
+
t.true(stdoutWrite.calledOnce)
|
|
59
|
+
t.true(stdoutWrite.calledWith(mockList + '\n'))
|
|
60
|
+
} finally {
|
|
61
|
+
sandbox.restore()
|
|
62
|
+
}
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
test('help command outputs topic content with paging when topic is specified', async (t) => {
|
|
66
|
+
const sandbox = sinon.createSandbox()
|
|
67
|
+
|
|
68
|
+
try {
|
|
69
|
+
const mockRendered = 'Rendered workflows content'
|
|
70
|
+
const mockOutputWithPaging = sandbox.stub().resolves()
|
|
71
|
+
|
|
72
|
+
const HelpCommand = await esmock('./help.js', {
|
|
73
|
+
'#lib/help/index.js': {
|
|
74
|
+
getTopicList: sandbox.stub(),
|
|
75
|
+
getTopicContent: sandbox.stub().resolves(mockRendered)
|
|
76
|
+
},
|
|
77
|
+
'#helpers/pager.js': {
|
|
78
|
+
outputWithPaging: mockOutputWithPaging
|
|
79
|
+
}
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
const command = new HelpCommand.default()
|
|
83
|
+
await command.action('workflows', {}, {})
|
|
84
|
+
|
|
85
|
+
t.true(mockOutputWithPaging.calledOnce)
|
|
86
|
+
t.true(mockOutputWithPaging.calledWith(mockRendered))
|
|
87
|
+
} finally {
|
|
88
|
+
sandbox.restore()
|
|
89
|
+
}
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
test('help command propagates HelpTopicNotFoundError from getTopicContent', async (t) => {
|
|
93
|
+
const sandbox = sinon.createSandbox()
|
|
94
|
+
|
|
95
|
+
try {
|
|
96
|
+
const { default: HelpTopicNotFoundError } = await import('#src/errors/help/help-topic-not-found-error.js')
|
|
97
|
+
|
|
98
|
+
const HelpCommand = await esmock('./help.js', {
|
|
99
|
+
'#lib/help/index.js': {
|
|
100
|
+
getTopicList: sandbox.stub(),
|
|
101
|
+
getTopicContent: sandbox.stub().rejects(new HelpTopicNotFoundError('nonexistent'))
|
|
102
|
+
},
|
|
103
|
+
'#helpers/pager.js': {
|
|
104
|
+
outputWithPaging: sandbox.stub()
|
|
105
|
+
}
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
const command = new HelpCommand.default()
|
|
109
|
+
|
|
110
|
+
const error = await t.throwsAsync(
|
|
111
|
+
() => command.action('nonexistent', {}, {}),
|
|
112
|
+
{ name: 'HelpTopicNotFoundError' }
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
t.is(error.code, 'HELP_TOPIC_NOT_FOUND')
|
|
116
|
+
t.is(error.exitCode, 2)
|
|
117
|
+
t.is(error.topic, 'nonexistent')
|
|
118
|
+
} finally {
|
|
119
|
+
sandbox.restore()
|
|
120
|
+
}
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
test('help command has correct definition', async (t) => {
|
|
124
|
+
const HelpCommand = await import('./help.js')
|
|
125
|
+
const command = new HelpCommand.default()
|
|
126
|
+
|
|
127
|
+
t.is(command.name, 'help')
|
|
128
|
+
t.deepEqual(command.aliases, ['h'])
|
|
129
|
+
t.truthy(command.description)
|
|
130
|
+
t.truthy(command.args)
|
|
131
|
+
t.truthy(command.options)
|
|
132
|
+
})
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import InvalidInputError from '../input/invalid-input-error.js'
|
|
2
|
+
import QuireError from '../quire-error.js'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Error thrown when a requested help topic does not exist
|
|
6
|
+
*/
|
|
7
|
+
export default class HelpTopicNotFoundError extends InvalidInputError {
|
|
8
|
+
constructor(topic) {
|
|
9
|
+
super(
|
|
10
|
+
`Unknown help topic: ${topic}`,
|
|
11
|
+
{
|
|
12
|
+
code: 'HELP_TOPIC_NOT_FOUND',
|
|
13
|
+
exitCode: 2,
|
|
14
|
+
inputValue: topic,
|
|
15
|
+
inputName: 'topic',
|
|
16
|
+
suggestion: 'Run "quire help --list" to see available topics',
|
|
17
|
+
docsUrl: `${QuireError.DOCS_BASE}/quire-commands/`
|
|
18
|
+
}
|
|
19
|
+
)
|
|
20
|
+
this.topic = topic
|
|
21
|
+
}
|
|
22
|
+
}
|
package/src/errors/index.js
CHANGED
|
@@ -46,3 +46,9 @@ export {
|
|
|
46
46
|
|
|
47
47
|
// Validation errors (exit code: 4)
|
|
48
48
|
export { default as ValidationError } from './validation/validation-error.js'
|
|
49
|
+
|
|
50
|
+
// Input errors (exit code: 2)
|
|
51
|
+
export { InvalidInputError } from './input/index.js'
|
|
52
|
+
|
|
53
|
+
// Help errors (exit code: 2)
|
|
54
|
+
export { HelpTopicNotFoundError } from './help/index.js'
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import QuireError from '../quire-error.js'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Error thrown when user input is invalid
|
|
5
|
+
*
|
|
6
|
+
* Generic error for invalid arguments, options, or other user-provided values.
|
|
7
|
+
* Can be extended for specific input validation scenarios.
|
|
8
|
+
*/
|
|
9
|
+
export default class InvalidInputError extends QuireError {
|
|
10
|
+
constructor(message, options = {}) {
|
|
11
|
+
super(message, {
|
|
12
|
+
code: options.code || 'INVALID_INPUT',
|
|
13
|
+
exitCode: options.exitCode ?? 2,
|
|
14
|
+
suggestion: options.suggestion,
|
|
15
|
+
docsUrl: options.docsUrl,
|
|
16
|
+
...options
|
|
17
|
+
})
|
|
18
|
+
this.inputValue = options.inputValue
|
|
19
|
+
this.inputName = options.inputName
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Terminal pager utility
|
|
3
|
+
*
|
|
4
|
+
* Provides paged output for long content in interactive terminals.
|
|
5
|
+
* Paging can be disabled with:
|
|
6
|
+
* - `--no-pager` CLI flag (sets NO_PAGER env var)
|
|
7
|
+
* - `NO_PAGER=1` environment variable
|
|
8
|
+
* - `PAGER=cat` traditional Unix convention (content passes through without paging)
|
|
9
|
+
*
|
|
10
|
+
* @module helpers/pager
|
|
11
|
+
*/
|
|
12
|
+
import { spawn } from 'node:child_process'
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Output content with optional paging for long content
|
|
16
|
+
*
|
|
17
|
+
* Uses system pager (less/more) when:
|
|
18
|
+
* - Output is to an interactive terminal (TTY)
|
|
19
|
+
* - Content exceeds terminal height
|
|
20
|
+
* - Paging is not disabled via NO_PAGER env var
|
|
21
|
+
*
|
|
22
|
+
* The PAGER env var selects the pager program (default: less on Unix, more on
|
|
23
|
+
* Windows). Setting PAGER=cat effectively disables paging as a traditional
|
|
24
|
+
* Unix convention.
|
|
25
|
+
*
|
|
26
|
+
* @param {string} content - Content to output
|
|
27
|
+
* @returns {Promise<void>}
|
|
28
|
+
*/
|
|
29
|
+
export async function outputWithPaging(content) {
|
|
30
|
+
const noPager = process.env.NO_PAGER
|
|
31
|
+
const lines = content.split('\n').length
|
|
32
|
+
const isTTY = process.stdout.isTTY
|
|
33
|
+
const terminalRows = process.stdout.rows || 24
|
|
34
|
+
|
|
35
|
+
// Use pager if content exceeds terminal height and we're in a TTY
|
|
36
|
+
if (!noPager && isTTY && lines > terminalRows) {
|
|
37
|
+
const pager = process.env.PAGER || (process.platform === 'win32' ? 'more' : 'less')
|
|
38
|
+
const pagerArgs = pager === 'less' ? ['-R'] : [] // -R preserves ANSI colors
|
|
39
|
+
|
|
40
|
+
return new Promise((resolve) => {
|
|
41
|
+
const child = spawn(pager, pagerArgs, {
|
|
42
|
+
stdio: ['pipe', 'inherit', 'inherit']
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
child.stdin.write(content + '\n')
|
|
46
|
+
child.stdin.end()
|
|
47
|
+
|
|
48
|
+
child.on('close', resolve)
|
|
49
|
+
child.on('error', () => {
|
|
50
|
+
// Fallback to direct output if pager fails
|
|
51
|
+
process.stdout.write(content + '\n')
|
|
52
|
+
resolve()
|
|
53
|
+
})
|
|
54
|
+
})
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Direct output for non-TTY or short content
|
|
58
|
+
process.stdout.write(content + '\n')
|
|
59
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import sinon from 'sinon'
|
|
2
|
+
import test from 'ava'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Pager Helper Tests
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
test.beforeEach((t) => {
|
|
9
|
+
t.context.sandbox = sinon.createSandbox()
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
test.afterEach.always((t) => {
|
|
13
|
+
t.context.sandbox.restore()
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
test.serial('outputWithPaging() writes directly when not a TTY', async (t) => {
|
|
17
|
+
const { sandbox } = t.context
|
|
18
|
+
const originalIsTTY = process.stdout.isTTY
|
|
19
|
+
const stdoutWrite = sandbox.stub(process.stdout, 'write')
|
|
20
|
+
|
|
21
|
+
try {
|
|
22
|
+
// Simulate non-TTY (piped output)
|
|
23
|
+
Object.defineProperty(process.stdout, 'isTTY', { value: false, configurable: true })
|
|
24
|
+
|
|
25
|
+
const { outputWithPaging } = await import('./pager.js')
|
|
26
|
+
await outputWithPaging('Short content')
|
|
27
|
+
|
|
28
|
+
t.true(stdoutWrite.calledWith('Short content\n'))
|
|
29
|
+
} finally {
|
|
30
|
+
Object.defineProperty(process.stdout, 'isTTY', { value: originalIsTTY, configurable: true })
|
|
31
|
+
}
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
test.serial('outputWithPaging() writes directly when content is short', async (t) => {
|
|
35
|
+
const { sandbox } = t.context
|
|
36
|
+
const originalIsTTY = process.stdout.isTTY
|
|
37
|
+
const originalRows = process.stdout.rows
|
|
38
|
+
const stdoutWrite = sandbox.stub(process.stdout, 'write')
|
|
39
|
+
|
|
40
|
+
try {
|
|
41
|
+
// Simulate TTY with 24 rows
|
|
42
|
+
Object.defineProperty(process.stdout, 'isTTY', { value: true, configurable: true })
|
|
43
|
+
Object.defineProperty(process.stdout, 'rows', { value: 24, configurable: true })
|
|
44
|
+
|
|
45
|
+
const { outputWithPaging } = await import('./pager.js')
|
|
46
|
+
await outputWithPaging('Line 1\nLine 2\nLine 3')
|
|
47
|
+
|
|
48
|
+
t.true(stdoutWrite.calledWith('Line 1\nLine 2\nLine 3\n'))
|
|
49
|
+
} finally {
|
|
50
|
+
Object.defineProperty(process.stdout, 'isTTY', { value: originalIsTTY, configurable: true })
|
|
51
|
+
Object.defineProperty(process.stdout, 'rows', { value: originalRows, configurable: true })
|
|
52
|
+
}
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
test.serial('outputWithPaging() writes directly when NO_PAGER is set', async (t) => {
|
|
56
|
+
const { sandbox } = t.context
|
|
57
|
+
const originalIsTTY = process.stdout.isTTY
|
|
58
|
+
const originalRows = process.stdout.rows
|
|
59
|
+
const originalNoPager = process.env.NO_PAGER
|
|
60
|
+
const stdoutWrite = sandbox.stub(process.stdout, 'write')
|
|
61
|
+
|
|
62
|
+
try {
|
|
63
|
+
// Simulate TTY with small terminal (would normally trigger pager)
|
|
64
|
+
Object.defineProperty(process.stdout, 'isTTY', { value: true, configurable: true })
|
|
65
|
+
Object.defineProperty(process.stdout, 'rows', { value: 5, configurable: true })
|
|
66
|
+
process.env.NO_PAGER = '1'
|
|
67
|
+
|
|
68
|
+
const { outputWithPaging } = await import('./pager.js')
|
|
69
|
+
|
|
70
|
+
// Content longer than terminal height
|
|
71
|
+
const longContent = Array.from({ length: 20 }, (_, i) => `Line ${i + 1}`).join('\n')
|
|
72
|
+
await outputWithPaging(longContent)
|
|
73
|
+
|
|
74
|
+
// Should write directly despite long content, because pager is disabled
|
|
75
|
+
t.true(stdoutWrite.calledWith(longContent + '\n'))
|
|
76
|
+
} finally {
|
|
77
|
+
Object.defineProperty(process.stdout, 'isTTY', { value: originalIsTTY, configurable: true })
|
|
78
|
+
Object.defineProperty(process.stdout, 'rows', { value: originalRows, configurable: true })
|
|
79
|
+
if (originalNoPager === undefined) {
|
|
80
|
+
delete process.env.NO_PAGER
|
|
81
|
+
} else {
|
|
82
|
+
process.env.NO_PAGER = originalNoPager
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
})
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* YAML frontmatter parsing utilities
|
|
3
|
+
*
|
|
4
|
+
* Extracts and strips YAML frontmatter from markdown content.
|
|
5
|
+
* Frontmatter is delimited by `---` on its own line.
|
|
6
|
+
*
|
|
7
|
+
* @module lib/help/frontmatter
|
|
8
|
+
*/
|
|
9
|
+
import yaml from 'js-yaml'
|
|
10
|
+
|
|
11
|
+
/** Regex to match YAML frontmatter block */
|
|
12
|
+
const FRONTMATTER_REGEX = /^---\n([\s\S]*?)\n---/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Extract YAML frontmatter from markdown content
|
|
16
|
+
*
|
|
17
|
+
* @param {string} content - Markdown content with optional frontmatter
|
|
18
|
+
* @returns {Object} Parsed frontmatter data or empty object if none/invalid
|
|
19
|
+
*
|
|
20
|
+
* @example
|
|
21
|
+
* const meta = extractFrontmatter(`---
|
|
22
|
+
* title: My Topic
|
|
23
|
+
* description: A description
|
|
24
|
+
* ---
|
|
25
|
+
* # Content`)
|
|
26
|
+
* // => { title: 'My Topic', description: 'A description' }
|
|
27
|
+
*/
|
|
28
|
+
export function extractFrontmatter(content) {
|
|
29
|
+
const match = content.match(FRONTMATTER_REGEX)
|
|
30
|
+
if (!match) return {}
|
|
31
|
+
|
|
32
|
+
try {
|
|
33
|
+
return yaml.load(match[1]) || {}
|
|
34
|
+
} catch {
|
|
35
|
+
return {}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Strip YAML frontmatter from markdown content
|
|
41
|
+
*
|
|
42
|
+
* @param {string} content - Markdown content with optional frontmatter
|
|
43
|
+
* @returns {string} Content without frontmatter block
|
|
44
|
+
*
|
|
45
|
+
* @example
|
|
46
|
+
* const body = stripFrontmatter(`---
|
|
47
|
+
* title: My Topic
|
|
48
|
+
* ---
|
|
49
|
+
* # Content`)
|
|
50
|
+
* // => '# Content'
|
|
51
|
+
*/
|
|
52
|
+
export function stripFrontmatter(content) {
|
|
53
|
+
return content.replace(/^---\n[\s\S]*?\n---\n?/, '')
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Parse markdown content with frontmatter
|
|
58
|
+
*
|
|
59
|
+
* Convenience function that returns both metadata and content body.
|
|
60
|
+
*
|
|
61
|
+
* @param {string} content - Markdown content with optional frontmatter
|
|
62
|
+
* @returns {{ data: Object, content: string }} Parsed frontmatter and body
|
|
63
|
+
*
|
|
64
|
+
* @example
|
|
65
|
+
* const { data, content } = parseFrontmatter(`---
|
|
66
|
+
* title: My Topic
|
|
67
|
+
* ---
|
|
68
|
+
* # Content`)
|
|
69
|
+
* // data => { title: 'My Topic' }
|
|
70
|
+
* // content => '# Content'
|
|
71
|
+
*/
|
|
72
|
+
export function parseFrontmatter(content) {
|
|
73
|
+
return {
|
|
74
|
+
data: extractFrontmatter(content),
|
|
75
|
+
content: stripFrontmatter(content)
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import test from 'ava'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
extractFrontmatter,
|
|
5
|
+
stripFrontmatter,
|
|
6
|
+
parseFrontmatter
|
|
7
|
+
} from './frontmatter.js'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Frontmatter Parsing Tests
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
// extractFrontmatter tests
|
|
14
|
+
|
|
15
|
+
test('extractFrontmatter() returns parsed YAML data', (t) => {
|
|
16
|
+
const content = `---
|
|
17
|
+
title: Test Topic
|
|
18
|
+
description: A test description
|
|
19
|
+
---
|
|
20
|
+
# Content`
|
|
21
|
+
|
|
22
|
+
const result = extractFrontmatter(content)
|
|
23
|
+
|
|
24
|
+
t.deepEqual(result, {
|
|
25
|
+
title: 'Test Topic',
|
|
26
|
+
description: 'A test description'
|
|
27
|
+
})
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
test('extractFrontmatter() returns empty object when no frontmatter', (t) => {
|
|
31
|
+
const content = '# Just Content\n\nNo frontmatter here.'
|
|
32
|
+
|
|
33
|
+
const result = extractFrontmatter(content)
|
|
34
|
+
|
|
35
|
+
t.deepEqual(result, {})
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
test('extractFrontmatter() returns empty object for invalid YAML', (t) => {
|
|
39
|
+
const content = `---
|
|
40
|
+
title: [invalid yaml
|
|
41
|
+
---
|
|
42
|
+
# Content`
|
|
43
|
+
|
|
44
|
+
const result = extractFrontmatter(content)
|
|
45
|
+
|
|
46
|
+
t.deepEqual(result, {})
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
test('extractFrontmatter() handles values containing colons', (t) => {
|
|
50
|
+
const content = `---
|
|
51
|
+
url: https://example.com/path
|
|
52
|
+
title: "Part 1: Introduction"
|
|
53
|
+
---
|
|
54
|
+
# Content`
|
|
55
|
+
|
|
56
|
+
const result = extractFrontmatter(content)
|
|
57
|
+
|
|
58
|
+
t.is(result.url, 'https://example.com/path')
|
|
59
|
+
t.is(result.title, 'Part 1: Introduction')
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
test('extractFrontmatter() handles arrays and nested objects', (t) => {
|
|
63
|
+
const content = `---
|
|
64
|
+
tags:
|
|
65
|
+
- one
|
|
66
|
+
- two
|
|
67
|
+
author:
|
|
68
|
+
name: Test
|
|
69
|
+
email: test@example.com
|
|
70
|
+
---
|
|
71
|
+
# Content`
|
|
72
|
+
|
|
73
|
+
const result = extractFrontmatter(content)
|
|
74
|
+
|
|
75
|
+
t.deepEqual(result.tags, ['one', 'two'])
|
|
76
|
+
t.deepEqual(result.author, { name: 'Test', email: 'test@example.com' })
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
// stripFrontmatter tests
|
|
80
|
+
|
|
81
|
+
test('stripFrontmatter() removes frontmatter block', (t) => {
|
|
82
|
+
const content = `---
|
|
83
|
+
title: Test
|
|
84
|
+
---
|
|
85
|
+
# Content
|
|
86
|
+
|
|
87
|
+
Body text.`
|
|
88
|
+
|
|
89
|
+
const result = stripFrontmatter(content)
|
|
90
|
+
|
|
91
|
+
t.is(result, '# Content\n\nBody text.')
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
test('stripFrontmatter() returns content unchanged when no frontmatter', (t) => {
|
|
95
|
+
const content = '# Just Content\n\nNo frontmatter.'
|
|
96
|
+
|
|
97
|
+
const result = stripFrontmatter(content)
|
|
98
|
+
|
|
99
|
+
t.is(result, content)
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
test('stripFrontmatter() handles frontmatter without trailing newline', (t) => {
|
|
103
|
+
const content = `---
|
|
104
|
+
title: Test
|
|
105
|
+
---# Content`
|
|
106
|
+
|
|
107
|
+
const result = stripFrontmatter(content)
|
|
108
|
+
|
|
109
|
+
t.is(result, '# Content')
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
// parseFrontmatter tests
|
|
113
|
+
|
|
114
|
+
test('parseFrontmatter() returns both data and content', (t) => {
|
|
115
|
+
const content = `---
|
|
116
|
+
title: Test Topic
|
|
117
|
+
description: A description
|
|
118
|
+
---
|
|
119
|
+
# Heading
|
|
120
|
+
|
|
121
|
+
Body content.`
|
|
122
|
+
|
|
123
|
+
const result = parseFrontmatter(content)
|
|
124
|
+
|
|
125
|
+
t.deepEqual(result.data, {
|
|
126
|
+
title: 'Test Topic',
|
|
127
|
+
description: 'A description'
|
|
128
|
+
})
|
|
129
|
+
t.is(result.content, '# Heading\n\nBody content.')
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
test('parseFrontmatter() returns empty data when no frontmatter', (t) => {
|
|
133
|
+
const content = '# Just Content'
|
|
134
|
+
|
|
135
|
+
const result = parseFrontmatter(content)
|
|
136
|
+
|
|
137
|
+
t.deepEqual(result.data, {})
|
|
138
|
+
t.is(result.content, '# Just Content')
|
|
139
|
+
})
|