@byline/cli 2.1.3 → 2.2.0

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.
Files changed (32) hide show
  1. package/dist/manifest/deps.d.ts.map +1 -1
  2. package/dist/manifest/deps.js +34 -0
  3. package/dist/manifest/deps.js.map +1 -1
  4. package/dist/templates/byline/i18n.ts +1 -1
  5. package/dist/templates/byline-examples/admin.config.ts +12 -0
  6. package/dist/templates/byline-examples/collections/media/components/media-list-view.module.css +1 -1
  7. package/dist/templates/byline-examples/collections/news/admin.tsx +7 -2
  8. package/dist/templates/byline-examples/collections/pages/admin.tsx +2 -1
  9. package/dist/templates/byline-examples/fields/ai-text.ts +47 -0
  10. package/dist/templates/byline-examples/fields/ai-textarea.ts +50 -0
  11. package/dist/templates/byline-examples/fields/ai-widgets/ai-field-label.tsx +48 -0
  12. package/dist/templates/byline-examples/fields/ai-widgets/ai-field-panel.tsx +42 -0
  13. package/dist/templates/byline-examples/fields/ai-widgets/ai-panel-store.ts +52 -0
  14. package/dist/templates/byline-examples/fields/lexical-richtext-ai.tsx +82 -0
  15. package/dist/templates/byline-examples/fields/lexical-richtext-compact.ts +8 -4
  16. package/dist/templates/byline-examples/i18n.ts +1 -1
  17. package/dist/templates/byline-examples/plugins/ai/ai-plugin-text.tsx +58 -0
  18. package/dist/templates/byline-examples/scripts/import-docs.ts +272 -0
  19. package/dist/templates/byline-examples/scripts/lib/frontmatter.ts +136 -0
  20. package/dist/templates/byline-examples/scripts/lib/mdast-to-lexical.ts +497 -0
  21. package/dist/templates/byline-examples/scripts/lib/strip-leading-h1.ts +31 -0
  22. package/dist/templates/migrations/{0000_slimy_lilandra.sql → 0000_cold_red_wolf.sql} +40 -34
  23. package/dist/templates/migrations/meta/0000_snapshot.json +100 -68
  24. package/dist/templates/migrations/meta/_journal.json +2 -2
  25. package/package.json +1 -1
  26. package/dist/templates/byline-examples/collections/docs/components/feature-formatter.tsx +0 -10
  27. package/dist/templates/byline-examples/collections/docs-categories/admin.tsx +0 -78
  28. package/dist/templates/byline-examples/collections/docs-categories/components/.gitkeep +0 -0
  29. package/dist/templates/byline-examples/collections/docs-categories/hooks/.gitkeep +0 -0
  30. package/dist/templates/byline-examples/collections/docs-categories/index.ts +0 -10
  31. package/dist/templates/byline-examples/collections/docs-categories/schema.ts +0 -33
  32. package/dist/templates/byline-examples/seeds/doc-categories.ts +0 -71
@@ -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
+ }