@byline/cli 2.1.3 → 2.2.1
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/README.md +5 -0
- package/dist/cli.js +2 -0
- package/dist/cli.js.map +1 -1
- package/dist/commands/setup.d.ts +1 -0
- package/dist/commands/setup.d.ts.map +1 -1
- package/dist/commands/setup.js +28 -0
- package/dist/commands/setup.js.map +1 -1
- package/dist/manifest/deps.d.ts.map +1 -1
- package/dist/manifest/deps.js +34 -0
- package/dist/manifest/deps.js.map +1 -1
- package/dist/templates/byline/i18n.ts +1 -1
- package/dist/templates/byline-examples/admin.config.ts +12 -0
- package/dist/templates/byline-examples/collections/media/components/media-list-view.module.css +1 -1
- package/dist/templates/byline-examples/collections/news/admin.tsx +7 -2
- package/dist/templates/byline-examples/collections/pages/admin.tsx +2 -1
- package/dist/templates/byline-examples/fields/ai-text.ts +47 -0
- package/dist/templates/byline-examples/fields/ai-textarea.ts +50 -0
- package/dist/templates/byline-examples/fields/ai-widgets/ai-field-label.tsx +48 -0
- package/dist/templates/byline-examples/fields/ai-widgets/ai-field-panel.tsx +42 -0
- package/dist/templates/byline-examples/fields/ai-widgets/ai-panel-store.ts +52 -0
- package/dist/templates/byline-examples/fields/lexical-richtext-ai.tsx +82 -0
- package/dist/templates/byline-examples/fields/lexical-richtext-compact.ts +8 -4
- package/dist/templates/byline-examples/i18n.ts +1 -1
- package/dist/templates/byline-examples/plugins/ai/ai-plugin-text.tsx +58 -0
- package/dist/templates/byline-examples/scripts/import-docs.ts +272 -0
- package/dist/templates/byline-examples/scripts/lib/frontmatter.ts +136 -0
- package/dist/templates/byline-examples/scripts/lib/mdast-to-lexical.ts +497 -0
- package/dist/templates/byline-examples/scripts/lib/strip-leading-h1.ts +31 -0
- package/dist/templates/migrations/{0000_slimy_lilandra.sql → 0000_cold_red_wolf.sql} +40 -34
- package/dist/templates/migrations/meta/0000_snapshot.json +100 -68
- package/dist/templates/migrations/meta/_journal.json +2 -2
- package/package.json +1 -1
- package/dist/templates/byline-examples/collections/docs/components/feature-formatter.tsx +0 -10
- package/dist/templates/byline-examples/collections/docs-categories/admin.tsx +0 -78
- package/dist/templates/byline-examples/collections/docs-categories/components/.gitkeep +0 -0
- package/dist/templates/byline-examples/collections/docs-categories/hooks/.gitkeep +0 -0
- package/dist/templates/byline-examples/collections/docs-categories/index.ts +0 -10
- package/dist/templates/byline-examples/collections/docs-categories/schema.ts +0 -33
- package/dist/templates/byline-examples/seeds/doc-categories.ts +0 -71
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import type * as React from 'react'
|
|
4
|
+
import { useCallback, useState } from 'react'
|
|
5
|
+
|
|
6
|
+
import { AiPluginText as AiPluginTextRoot } from '@byline/ai/plugins/text'
|
|
7
|
+
import { AiIcon, IconButton, Input } from '@byline/ui/react'
|
|
8
|
+
|
|
9
|
+
export function AiPluginText() {
|
|
10
|
+
const [inputText, setInputText] = useState('')
|
|
11
|
+
const [open, setOpen] = useState(false)
|
|
12
|
+
|
|
13
|
+
const handleToggleOpen = useCallback(() => {
|
|
14
|
+
setOpen((prevOpen) => !prevOpen)
|
|
15
|
+
}, [])
|
|
16
|
+
|
|
17
|
+
const handleInputChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
|
|
18
|
+
setInputText(event.target.value)
|
|
19
|
+
}, [])
|
|
20
|
+
|
|
21
|
+
const handleApplyResult = useCallback((nextText: string) => {
|
|
22
|
+
setInputText(nextText)
|
|
23
|
+
}, [])
|
|
24
|
+
|
|
25
|
+
const handleClearInput = useCallback(() => {
|
|
26
|
+
setInputText('')
|
|
27
|
+
}, [])
|
|
28
|
+
|
|
29
|
+
return (
|
|
30
|
+
<div className="ai-plugin-text">
|
|
31
|
+
<IconButton
|
|
32
|
+
onClick={handleToggleOpen}
|
|
33
|
+
variant="text"
|
|
34
|
+
size="md"
|
|
35
|
+
className="w-7 h-7 max-w-7 max-h-7 min-w-7 min-h-7"
|
|
36
|
+
>
|
|
37
|
+
<AiIcon />
|
|
38
|
+
</IconButton>
|
|
39
|
+
<Input
|
|
40
|
+
id="foo"
|
|
41
|
+
name="foo"
|
|
42
|
+
label="Simple Text Input"
|
|
43
|
+
type="text"
|
|
44
|
+
onChange={handleInputChange}
|
|
45
|
+
value={inputText}
|
|
46
|
+
helpText="Enter some text, or enter a prompt below to generate text."
|
|
47
|
+
placeholder="Start writing your content here..."
|
|
48
|
+
/>
|
|
49
|
+
<AiPluginTextRoot
|
|
50
|
+
inputText={inputText}
|
|
51
|
+
onApplyResult={handleApplyResult}
|
|
52
|
+
onClearInput={handleClearInput}
|
|
53
|
+
open={open}
|
|
54
|
+
onOpenChange={setOpen}
|
|
55
|
+
/>
|
|
56
|
+
</div>
|
|
57
|
+
)
|
|
58
|
+
}
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
// Examples...
|
|
10
|
+
//
|
|
11
|
+
// pnpm tsx --env-file=.env byline/scripts/import-docs.ts ../../docs/**/*.md --dry-run
|
|
12
|
+
// pnpm tsx --env-file=.env byline/scripts/import-docs.ts ../../docs/**/*.md --verbose
|
|
13
|
+
// pnpm tsx --env-file=.env byline/scripts/import-docs.ts '../../docs/*.md' --dry-run --verbose
|
|
14
|
+
// pnpm tsx --env-file=.env byline/scripts/import-docs.ts ../../docs/**/*.md
|
|
15
|
+
//
|
|
16
|
+
// Run tests...
|
|
17
|
+
//
|
|
18
|
+
// pnpm vitest run --mode=node byline/scripts/lib/
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Import markdown files into the `docs` collection.
|
|
22
|
+
*
|
|
23
|
+
* pnpm tsx --env-file=.env byline/scripts/import-docs.ts <path-or-glob...>
|
|
24
|
+
*
|
|
25
|
+
* Per-file flow:
|
|
26
|
+
* 1. Read the file, split frontmatter from body with gray-matter.
|
|
27
|
+
* 2. Parse the body to mdast via remark-parse + remark-gfm.
|
|
28
|
+
* 3. Convert mdast → Lexical SerializedEditorState.
|
|
29
|
+
* 4. Resolve `featureImage` (a `media` path) to a relation envelope.
|
|
30
|
+
* 5. `findByPath` to decide create vs update. On update, status and
|
|
31
|
+
* publishedOn are preserved — editorial state in Byline wins.
|
|
32
|
+
*
|
|
33
|
+
* Flags:
|
|
34
|
+
* --dry-run Parse + log, no DB writes.
|
|
35
|
+
* --verbose Print warnings for dropped/unsupported nodes.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
import 'dotenv/config'
|
|
39
|
+
import '../server.config.js'
|
|
40
|
+
|
|
41
|
+
import { readFileSync } from 'node:fs'
|
|
42
|
+
import { glob } from 'node:fs/promises'
|
|
43
|
+
import { resolve } from 'node:path'
|
|
44
|
+
|
|
45
|
+
import { createSuperAdminContext } from '@byline/auth'
|
|
46
|
+
import { type CollectionHandle, createBylineClient } from '@byline/client'
|
|
47
|
+
import { getServerConfig, slugify } from '@byline/core'
|
|
48
|
+
import type { Root } from 'mdast'
|
|
49
|
+
import remarkGfm from 'remark-gfm'
|
|
50
|
+
import remarkParse from 'remark-parse'
|
|
51
|
+
import { unified } from 'unified'
|
|
52
|
+
|
|
53
|
+
import { type DocFrontmatter, parseDocFile } from './lib/frontmatter.js'
|
|
54
|
+
import { type MdastToLexicalWarning, mdastToLexical } from './lib/mdast-to-lexical.js'
|
|
55
|
+
import { stripLeadingH1IfMatches } from './lib/strip-leading-h1.js'
|
|
56
|
+
|
|
57
|
+
const DOCS_COLLECTION = 'docs'
|
|
58
|
+
const MEDIA_COLLECTION = 'media'
|
|
59
|
+
|
|
60
|
+
interface Flags {
|
|
61
|
+
dryRun: boolean
|
|
62
|
+
verbose: boolean
|
|
63
|
+
patterns: string[]
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function parseFlags(argv: string[]): Flags {
|
|
67
|
+
const flags: Flags = { dryRun: false, verbose: false, patterns: [] }
|
|
68
|
+
for (const arg of argv) {
|
|
69
|
+
if (arg === '--dry-run') flags.dryRun = true
|
|
70
|
+
else if (arg === '--verbose') flags.verbose = true
|
|
71
|
+
else if (arg.startsWith('--')) throw new Error(`unknown flag: ${arg}`)
|
|
72
|
+
else flags.patterns.push(arg)
|
|
73
|
+
}
|
|
74
|
+
if (flags.patterns.length === 0) {
|
|
75
|
+
throw new Error('import-docs: provide at least one file path or glob (e.g. "docs/**/*.md")')
|
|
76
|
+
}
|
|
77
|
+
return flags
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function expandPatterns(patterns: string[]): Promise<string[]> {
|
|
81
|
+
const out = new Set<string>()
|
|
82
|
+
for (const pattern of patterns) {
|
|
83
|
+
for await (const file of glob(pattern)) {
|
|
84
|
+
if (file.endsWith('.md') || file.endsWith('.markdown')) {
|
|
85
|
+
out.add(resolve(file))
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return [...out].sort()
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function parseBodyToMdast(body: string): Root {
|
|
93
|
+
return unified().use(remarkParse).use(remarkGfm).parse(body) as Root
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function logWarnings(filePath: string, warnings: MdastToLexicalWarning[]): void {
|
|
97
|
+
if (warnings.length === 0) return
|
|
98
|
+
console.warn(` - ${warnings.length} warning(s) for ${filePath}:`)
|
|
99
|
+
for (const w of warnings) {
|
|
100
|
+
console.warn(` [${w.kind}] ${w.detail}`)
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
interface ResolvedFeatureImage {
|
|
105
|
+
targetCollectionId: string
|
|
106
|
+
targetDocumentId: string
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function resolveFeatureImage(
|
|
110
|
+
client: ReturnType<typeof createBylineClient>,
|
|
111
|
+
path: string
|
|
112
|
+
): Promise<ResolvedFeatureImage | null> {
|
|
113
|
+
const mediaCollectionId = await client.resolveCollectionId(MEDIA_COLLECTION)
|
|
114
|
+
const doc = await client.collection(MEDIA_COLLECTION).findByPath(path, {
|
|
115
|
+
status: 'any',
|
|
116
|
+
_bypassBeforeRead: true,
|
|
117
|
+
})
|
|
118
|
+
if (!doc) return null
|
|
119
|
+
return { targetCollectionId: mediaCollectionId, targetDocumentId: doc.id }
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
interface BuildPayloadArgs {
|
|
123
|
+
frontmatter: DocFrontmatter
|
|
124
|
+
lexicalState: unknown
|
|
125
|
+
featureImage: ResolvedFeatureImage | null
|
|
126
|
+
locale: string
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function buildDocPayload({
|
|
130
|
+
frontmatter,
|
|
131
|
+
lexicalState,
|
|
132
|
+
featureImage,
|
|
133
|
+
locale,
|
|
134
|
+
}: BuildPayloadArgs): Record<string, unknown> {
|
|
135
|
+
const payload: Record<string, unknown> = {
|
|
136
|
+
title: frontmatter.title,
|
|
137
|
+
content: [
|
|
138
|
+
{
|
|
139
|
+
_type: 'richTextBlock',
|
|
140
|
+
richText: lexicalState,
|
|
141
|
+
constrainedWidth: frontmatter.constrainedWidth ?? true,
|
|
142
|
+
},
|
|
143
|
+
],
|
|
144
|
+
// The `availableLanguages` field's built-in validator requires at
|
|
145
|
+
// least one checked locale; without it, opening the imported doc
|
|
146
|
+
// in the admin surfaces a validation error before any save. Seed
|
|
147
|
+
// the authoring locale so editors land on a valid form.
|
|
148
|
+
availableLanguages: { [locale]: true },
|
|
149
|
+
}
|
|
150
|
+
if (frontmatter.summary !== undefined) payload.summary = frontmatter.summary
|
|
151
|
+
if (frontmatter.publishedOn !== undefined) payload.publishedOn = frontmatter.publishedOn
|
|
152
|
+
if (featureImage) payload.featureImage = featureImage
|
|
153
|
+
return payload
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function derivePath(frontmatter: DocFrontmatter, locale: string): string {
|
|
157
|
+
if (frontmatter.path) return frontmatter.path
|
|
158
|
+
return slugify(frontmatter.title, { locale, collectionPath: DOCS_COLLECTION })
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
interface ProcessResult {
|
|
162
|
+
filePath: string
|
|
163
|
+
action: 'created' | 'updated' | 'skipped'
|
|
164
|
+
documentId?: string
|
|
165
|
+
path: string
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async function processFile(
|
|
169
|
+
filePath: string,
|
|
170
|
+
client: ReturnType<typeof createBylineClient>,
|
|
171
|
+
handle: CollectionHandle,
|
|
172
|
+
flags: Flags
|
|
173
|
+
): Promise<ProcessResult> {
|
|
174
|
+
const source = readFileSync(filePath, 'utf8')
|
|
175
|
+
const parsed = parseDocFile(source, filePath)
|
|
176
|
+
const locale = parsed.frontmatter.locale ?? client.defaultLocale
|
|
177
|
+
|
|
178
|
+
const mdast = stripLeadingH1IfMatches(parseBodyToMdast(parsed.body), parsed.frontmatter.title)
|
|
179
|
+
const { state, warnings } = mdastToLexical(mdast)
|
|
180
|
+
if (flags.verbose) logWarnings(filePath, warnings)
|
|
181
|
+
|
|
182
|
+
const featureImage = parsed.frontmatter.featureImage
|
|
183
|
+
? await resolveFeatureImage(client, parsed.frontmatter.featureImage)
|
|
184
|
+
: null
|
|
185
|
+
if (parsed.frontmatter.featureImage && !featureImage) {
|
|
186
|
+
console.warn(
|
|
187
|
+
` - featureImage '${parsed.frontmatter.featureImage}' not found in '${MEDIA_COLLECTION}' — dropping the field`
|
|
188
|
+
)
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const docPath = derivePath(parsed.frontmatter, locale)
|
|
192
|
+
const payload = buildDocPayload({
|
|
193
|
+
frontmatter: parsed.frontmatter,
|
|
194
|
+
lexicalState: state,
|
|
195
|
+
featureImage,
|
|
196
|
+
locale,
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
if (flags.dryRun) {
|
|
200
|
+
console.log(` • [dry-run] would upsert '${docPath}' (locale=${locale})`)
|
|
201
|
+
return { filePath, action: 'skipped', path: docPath }
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const existing = await handle.findByPath(docPath, {
|
|
205
|
+
locale,
|
|
206
|
+
status: 'any',
|
|
207
|
+
_bypassBeforeRead: true,
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
if (existing) {
|
|
211
|
+
// Editorial state wins on re-import: don't overwrite status, and
|
|
212
|
+
// don't clobber publishedOn if Byline already has one.
|
|
213
|
+
if (existing.fields?.publishedOn) {
|
|
214
|
+
delete payload.publishedOn
|
|
215
|
+
}
|
|
216
|
+
const result = await handle.update(existing.id, payload, { locale })
|
|
217
|
+
return { filePath, action: 'updated', documentId: result.documentId, path: docPath }
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const result = await handle.create(payload, {
|
|
221
|
+
locale,
|
|
222
|
+
status: parsed.frontmatter.status,
|
|
223
|
+
path: docPath,
|
|
224
|
+
})
|
|
225
|
+
return { filePath, action: 'created', documentId: result.documentId, path: docPath }
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
async function run(): Promise<void> {
|
|
229
|
+
const flags = parseFlags(process.argv.slice(2))
|
|
230
|
+
const files = await expandPatterns(flags.patterns)
|
|
231
|
+
if (files.length === 0) {
|
|
232
|
+
console.error('import-docs: no .md files matched the provided patterns.')
|
|
233
|
+
process.exit(1)
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
console.log(`import-docs: found ${files.length} file(s)${flags.dryRun ? ' (dry-run)' : ''}.`)
|
|
237
|
+
|
|
238
|
+
const config = getServerConfig()
|
|
239
|
+
const requestContext = createSuperAdminContext({ id: 'import-docs-script' })
|
|
240
|
+
const client = createBylineClient({ config, requestContext })
|
|
241
|
+
const handle = client.collection(DOCS_COLLECTION)
|
|
242
|
+
|
|
243
|
+
let created = 0
|
|
244
|
+
let updated = 0
|
|
245
|
+
let skipped = 0
|
|
246
|
+
let failed = 0
|
|
247
|
+
|
|
248
|
+
for (const file of files) {
|
|
249
|
+
try {
|
|
250
|
+
const result = await processFile(file, client, handle, flags)
|
|
251
|
+
if (result.action === 'created') created += 1
|
|
252
|
+
else if (result.action === 'updated') updated += 1
|
|
253
|
+
else skipped += 1
|
|
254
|
+
console.log(` ✓ ${result.action.padEnd(7)} ${result.path} ← ${file}`)
|
|
255
|
+
} catch (err) {
|
|
256
|
+
failed += 1
|
|
257
|
+
console.error(` ✗ failed ${file}`)
|
|
258
|
+
console.error(err instanceof Error ? ` ${err.message}` : err)
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
console.log(
|
|
263
|
+
`import-docs: ${created} created, ${updated} updated, ${skipped} skipped, ${failed} failed.`
|
|
264
|
+
)
|
|
265
|
+
if (failed > 0) process.exit(1)
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
run().catch((err) => {
|
|
269
|
+
console.error('import-docs: fatal error')
|
|
270
|
+
console.error(err)
|
|
271
|
+
process.exit(1)
|
|
272
|
+
})
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Frontmatter contract for `byline/scripts/import-docs.ts`.
|
|
11
|
+
*
|
|
12
|
+
* Required: title
|
|
13
|
+
* Optional: path, summary, status, locale, publishedOn, featureImage,
|
|
14
|
+
* constrainedWidth
|
|
15
|
+
*
|
|
16
|
+
* Titles are plain prose strings — no markdown formatting (no backticks,
|
|
17
|
+
* no emphasis). The body's leading H1 may carry inline formatting; the
|
|
18
|
+
* importer strips that H1 when its flattened text matches `title`, so
|
|
19
|
+
* a frontmatter title of `Client SDK (@byline/client)` correctly elides
|
|
20
|
+
* an `# Client SDK (\`@byline/client\`)` H1.
|
|
21
|
+
*
|
|
22
|
+
* Unknown keys are an error — typos in frontmatter would otherwise
|
|
23
|
+
* silently no-op.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import matter from 'gray-matter'
|
|
27
|
+
|
|
28
|
+
const KNOWN_KEYS = new Set([
|
|
29
|
+
'title',
|
|
30
|
+
'path',
|
|
31
|
+
'summary',
|
|
32
|
+
'status',
|
|
33
|
+
'locale',
|
|
34
|
+
'publishedOn',
|
|
35
|
+
'featureImage',
|
|
36
|
+
'constrainedWidth',
|
|
37
|
+
])
|
|
38
|
+
|
|
39
|
+
const ALLOWED_STATUSES = new Set(['draft', 'needs_review', 'published', 'archived'])
|
|
40
|
+
|
|
41
|
+
export interface DocFrontmatter {
|
|
42
|
+
title: string
|
|
43
|
+
path?: string
|
|
44
|
+
summary?: string
|
|
45
|
+
status?: 'draft' | 'needs_review' | 'published' | 'archived'
|
|
46
|
+
locale?: string
|
|
47
|
+
publishedOn?: Date
|
|
48
|
+
featureImage?: string
|
|
49
|
+
constrainedWidth?: boolean
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface ParsedDoc {
|
|
53
|
+
frontmatter: DocFrontmatter
|
|
54
|
+
body: string
|
|
55
|
+
rawFrontmatter: Record<string, unknown>
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function parseDocFile(source: string, filePath: string): ParsedDoc {
|
|
59
|
+
const parsed = matter(source)
|
|
60
|
+
const data = parsed.data as Record<string, unknown>
|
|
61
|
+
|
|
62
|
+
const unknown = Object.keys(data).filter((k) => !KNOWN_KEYS.has(k))
|
|
63
|
+
if (unknown.length > 0) {
|
|
64
|
+
throw new Error(
|
|
65
|
+
`${filePath}: unknown frontmatter keys: ${unknown.join(', ')}. ` +
|
|
66
|
+
`Allowed: ${[...KNOWN_KEYS].join(', ')}.`
|
|
67
|
+
)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (typeof data.title !== 'string' || data.title.trim().length === 0) {
|
|
71
|
+
throw new Error(`${filePath}: frontmatter is missing required 'title' string.`)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const fm: DocFrontmatter = { title: data.title.trim() }
|
|
75
|
+
|
|
76
|
+
if (data.path !== undefined) {
|
|
77
|
+
if (typeof data.path !== 'string' || data.path.trim().length === 0) {
|
|
78
|
+
throw new Error(`${filePath}: 'path' must be a non-empty string when provided.`)
|
|
79
|
+
}
|
|
80
|
+
fm.path = data.path.trim()
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (data.summary !== undefined) {
|
|
84
|
+
if (typeof data.summary !== 'string') {
|
|
85
|
+
throw new Error(`${filePath}: 'summary' must be a string.`)
|
|
86
|
+
}
|
|
87
|
+
fm.summary = data.summary
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (data.status !== undefined) {
|
|
91
|
+
if (typeof data.status !== 'string' || !ALLOWED_STATUSES.has(data.status)) {
|
|
92
|
+
throw new Error(
|
|
93
|
+
`${filePath}: 'status' must be one of ${[...ALLOWED_STATUSES].join(', ')}; got '${String(
|
|
94
|
+
data.status
|
|
95
|
+
)}'.`
|
|
96
|
+
)
|
|
97
|
+
}
|
|
98
|
+
fm.status = data.status as DocFrontmatter['status']
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (data.locale !== undefined) {
|
|
102
|
+
if (typeof data.locale !== 'string' || data.locale.trim().length === 0) {
|
|
103
|
+
throw new Error(`${filePath}: 'locale' must be a non-empty string.`)
|
|
104
|
+
}
|
|
105
|
+
fm.locale = data.locale.trim()
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (data.publishedOn !== undefined) {
|
|
109
|
+
const d =
|
|
110
|
+
data.publishedOn instanceof Date ? data.publishedOn : new Date(String(data.publishedOn))
|
|
111
|
+
if (Number.isNaN(d.getTime())) {
|
|
112
|
+
throw new Error(`${filePath}: 'publishedOn' is not a valid date.`)
|
|
113
|
+
}
|
|
114
|
+
fm.publishedOn = d
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (data.featureImage !== undefined) {
|
|
118
|
+
if (typeof data.featureImage !== 'string' || data.featureImage.trim().length === 0) {
|
|
119
|
+
throw new Error(`${filePath}: 'featureImage' must be a non-empty string (a media path).`)
|
|
120
|
+
}
|
|
121
|
+
fm.featureImage = data.featureImage.trim()
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (data.constrainedWidth !== undefined) {
|
|
125
|
+
if (typeof data.constrainedWidth !== 'boolean') {
|
|
126
|
+
throw new Error(`${filePath}: 'constrainedWidth' must be a boolean.`)
|
|
127
|
+
}
|
|
128
|
+
fm.constrainedWidth = data.constrainedWidth
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return {
|
|
132
|
+
frontmatter: fm,
|
|
133
|
+
body: parsed.content,
|
|
134
|
+
rawFrontmatter: data,
|
|
135
|
+
}
|
|
136
|
+
}
|