@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.
Files changed (39) hide show
  1. package/README.md +5 -0
  2. package/dist/cli.js +2 -0
  3. package/dist/cli.js.map +1 -1
  4. package/dist/commands/setup.d.ts +1 -0
  5. package/dist/commands/setup.d.ts.map +1 -1
  6. package/dist/commands/setup.js +28 -0
  7. package/dist/commands/setup.js.map +1 -1
  8. package/dist/manifest/deps.d.ts.map +1 -1
  9. package/dist/manifest/deps.js +34 -0
  10. package/dist/manifest/deps.js.map +1 -1
  11. package/dist/templates/byline/i18n.ts +1 -1
  12. package/dist/templates/byline-examples/admin.config.ts +12 -0
  13. package/dist/templates/byline-examples/collections/media/components/media-list-view.module.css +1 -1
  14. package/dist/templates/byline-examples/collections/news/admin.tsx +7 -2
  15. package/dist/templates/byline-examples/collections/pages/admin.tsx +2 -1
  16. package/dist/templates/byline-examples/fields/ai-text.ts +47 -0
  17. package/dist/templates/byline-examples/fields/ai-textarea.ts +50 -0
  18. package/dist/templates/byline-examples/fields/ai-widgets/ai-field-label.tsx +48 -0
  19. package/dist/templates/byline-examples/fields/ai-widgets/ai-field-panel.tsx +42 -0
  20. package/dist/templates/byline-examples/fields/ai-widgets/ai-panel-store.ts +52 -0
  21. package/dist/templates/byline-examples/fields/lexical-richtext-ai.tsx +82 -0
  22. package/dist/templates/byline-examples/fields/lexical-richtext-compact.ts +8 -4
  23. package/dist/templates/byline-examples/i18n.ts +1 -1
  24. package/dist/templates/byline-examples/plugins/ai/ai-plugin-text.tsx +58 -0
  25. package/dist/templates/byline-examples/scripts/import-docs.ts +272 -0
  26. package/dist/templates/byline-examples/scripts/lib/frontmatter.ts +136 -0
  27. package/dist/templates/byline-examples/scripts/lib/mdast-to-lexical.ts +497 -0
  28. package/dist/templates/byline-examples/scripts/lib/strip-leading-h1.ts +31 -0
  29. package/dist/templates/migrations/{0000_slimy_lilandra.sql → 0000_cold_red_wolf.sql} +40 -34
  30. package/dist/templates/migrations/meta/0000_snapshot.json +100 -68
  31. package/dist/templates/migrations/meta/_journal.json +2 -2
  32. package/package.json +1 -1
  33. package/dist/templates/byline-examples/collections/docs/components/feature-formatter.tsx +0 -10
  34. package/dist/templates/byline-examples/collections/docs-categories/admin.tsx +0 -78
  35. package/dist/templates/byline-examples/collections/docs-categories/components/.gitkeep +0 -0
  36. package/dist/templates/byline-examples/collections/docs-categories/hooks/.gitkeep +0 -0
  37. package/dist/templates/byline-examples/collections/docs-categories/index.ts +0 -10
  38. package/dist/templates/byline-examples/collections/docs-categories/schema.ts +0 -33
  39. package/dist/templates/byline-examples/seeds/doc-categories.ts +0 -71
@@ -0,0 +1,497 @@
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
+ * mdast → Lexical SerializedEditorState mapper.
11
+ *
12
+ * Pure, headless-editor-free conversion. The output JSON is shaped to
13
+ * match what the runtime serializer in
14
+ * `apps/webapp/src/ui/byline/components/richtext-lexical/serialize`
15
+ * expects to render (heading.tag, list.tag + list.listType, code
16
+ * children of type 'code-highlight' interspersed with 'linebreak',
17
+ * link.attributes, etc.).
18
+ *
19
+ * Inline format is a bitmask on each text node (Lexical convention) —
20
+ * we accumulate it down the walk so `**_foo_**` collapses to a single
21
+ * text node with format = BOLD | ITALIC instead of nested wrappers.
22
+ */
23
+
24
+ import type {
25
+ Blockquote,
26
+ Code,
27
+ Content,
28
+ Delete,
29
+ Emphasis,
30
+ Heading,
31
+ Image,
32
+ InlineCode,
33
+ Link,
34
+ List,
35
+ ListItem,
36
+ Paragraph,
37
+ PhrasingContent,
38
+ Root,
39
+ Strong,
40
+ Table,
41
+ TableCell,
42
+ TableRow,
43
+ Text,
44
+ ThematicBreak,
45
+ } from 'mdast'
46
+
47
+ // Lexical inline format bits — kept in sync with
48
+ // apps/webapp/src/ui/byline/components/richtext-lexical/serialize/richtext-node-formats.ts
49
+ const IS_BOLD = 1
50
+ const IS_ITALIC = 1 << 1
51
+ const IS_STRIKETHROUGH = 1 << 2
52
+ const IS_CODE = 1 << 4
53
+
54
+ export interface LexicalRoot {
55
+ root: {
56
+ type: 'root'
57
+ children: LexicalNode[]
58
+ direction: 'ltr' | null
59
+ format: ''
60
+ indent: 0
61
+ version: 1
62
+ }
63
+ }
64
+
65
+ interface LexicalNode {
66
+ type: string
67
+ version: number
68
+ children?: LexicalNode[]
69
+ [k: string]: unknown
70
+ }
71
+
72
+ export interface MdastToLexicalWarning {
73
+ kind: 'unsupported-node' | 'dropped-html' | 'dropped-image'
74
+ detail: string
75
+ }
76
+
77
+ export interface MdastToLexicalResult {
78
+ state: LexicalRoot
79
+ warnings: MdastToLexicalWarning[]
80
+ }
81
+
82
+ /**
83
+ * Convert an mdast tree to a Lexical SerializedEditorState. Returns the
84
+ * state alongside a list of warnings (unsupported node types, dropped
85
+ * HTML/image/table nodes) so callers can decide whether to fail the
86
+ * import.
87
+ */
88
+ export function mdastToLexical(root: Root): MdastToLexicalResult {
89
+ const warnings: MdastToLexicalWarning[] = []
90
+ const children = walkBlocks(root.children, warnings)
91
+ return {
92
+ state: {
93
+ root: {
94
+ type: 'root',
95
+ children: children.length > 0 ? children : [emptyParagraph()],
96
+ direction: 'ltr',
97
+ format: '',
98
+ indent: 0,
99
+ version: 1,
100
+ },
101
+ },
102
+ warnings,
103
+ }
104
+ }
105
+
106
+ function walkBlocks(nodes: Content[], warnings: MdastToLexicalWarning[]): LexicalNode[] {
107
+ const out: LexicalNode[] = []
108
+ for (const node of nodes) {
109
+ const converted = blockNode(node, warnings)
110
+ if (converted) out.push(converted)
111
+ }
112
+ return out
113
+ }
114
+
115
+ function blockNode(node: Content, warnings: MdastToLexicalWarning[]): LexicalNode | null {
116
+ switch (node.type) {
117
+ case 'paragraph':
118
+ return paragraphNode(node, warnings)
119
+ case 'heading':
120
+ return headingNode(node, warnings)
121
+ case 'list':
122
+ return listNode(node, warnings)
123
+ case 'blockquote':
124
+ return blockquoteNode(node, warnings)
125
+ case 'code':
126
+ return codeNode(node)
127
+ case 'thematicBreak':
128
+ return horizontalRuleNode(node)
129
+ case 'html':
130
+ warnings.push({
131
+ kind: 'dropped-html',
132
+ detail: `dropped HTML node: ${truncate((node as { value: string }).value)}`,
133
+ })
134
+ return null
135
+ case 'image':
136
+ warnings.push({
137
+ kind: 'dropped-image',
138
+ detail: `dropped image (alt=${(node as Image).alt ?? ''} url=${(node as Image).url})`,
139
+ })
140
+ return null
141
+ case 'table':
142
+ return tableNode(node as Table, warnings)
143
+ default:
144
+ warnings.push({
145
+ kind: 'unsupported-node',
146
+ detail: `dropped block-level node of type '${(node as { type: string }).type}'`,
147
+ })
148
+ return null
149
+ }
150
+ }
151
+
152
+ function paragraphNode(node: Paragraph, warnings: MdastToLexicalWarning[]): LexicalNode {
153
+ return {
154
+ type: 'paragraph',
155
+ version: 1,
156
+ direction: 'ltr',
157
+ format: '',
158
+ indent: 0,
159
+ textFormat: 0,
160
+ textStyle: '',
161
+ children: walkInlines(node.children, 0, warnings),
162
+ }
163
+ }
164
+
165
+ function headingNode(node: Heading, warnings: MdastToLexicalWarning[]): LexicalNode {
166
+ return {
167
+ type: 'heading',
168
+ version: 1,
169
+ direction: 'ltr',
170
+ format: '',
171
+ indent: 0,
172
+ tag: `h${node.depth}`,
173
+ children: walkInlines(node.children, 0, warnings),
174
+ }
175
+ }
176
+
177
+ function listNode(node: List, warnings: MdastToLexicalWarning[]): LexicalNode {
178
+ const ordered = node.ordered === true
179
+ const start = ordered ? (node.start ?? 1) : 1
180
+ return {
181
+ type: 'list',
182
+ version: 1,
183
+ direction: 'ltr',
184
+ format: '',
185
+ indent: 0,
186
+ listType: ordered ? 'number' : 'bullet',
187
+ tag: ordered ? 'ol' : 'ul',
188
+ start,
189
+ children: node.children.map((item, index) =>
190
+ listItemNode(item, ordered ? start + index : index + 1, warnings)
191
+ ),
192
+ }
193
+ }
194
+
195
+ function listItemNode(
196
+ node: ListItem,
197
+ value: number,
198
+ warnings: MdastToLexicalWarning[]
199
+ ): LexicalNode {
200
+ // mdast wraps list-item content in implicit paragraph(s). Lexical
201
+ // listitem expects inline children directly, so peel single-paragraph
202
+ // wrappers. Nested lists are passed through as block children.
203
+ const children: LexicalNode[] = []
204
+ for (const child of node.children) {
205
+ if (child.type === 'paragraph') {
206
+ children.push(...walkInlines(child.children, 0, warnings))
207
+ } else if (child.type === 'list') {
208
+ children.push(listNode(child, warnings))
209
+ } else {
210
+ const block = blockNode(child as Content, warnings)
211
+ if (block) children.push(block)
212
+ }
213
+ }
214
+ return {
215
+ type: 'listitem',
216
+ version: 1,
217
+ direction: 'ltr',
218
+ format: '',
219
+ indent: 0,
220
+ value,
221
+ children,
222
+ }
223
+ }
224
+
225
+ function blockquoteNode(node: Blockquote, warnings: MdastToLexicalWarning[]): LexicalNode {
226
+ // Lexical's quote node holds inline children, not blocks. mdast
227
+ // blockquotes wrap paragraphs — flatten single-paragraph blockquotes,
228
+ // and for multi-paragraph quotes inject a linebreak between them.
229
+ const inline: LexicalNode[] = []
230
+ let first = true
231
+ for (const child of node.children) {
232
+ if (child.type === 'paragraph') {
233
+ if (!first) inline.push({ type: 'linebreak', version: 1 })
234
+ inline.push(...walkInlines(child.children, 0, warnings))
235
+ first = false
236
+ } else {
237
+ warnings.push({
238
+ kind: 'unsupported-node',
239
+ detail: `blockquote contained non-paragraph child '${child.type}' — dropped`,
240
+ })
241
+ }
242
+ }
243
+ return {
244
+ type: 'quote',
245
+ version: 1,
246
+ direction: 'ltr',
247
+ format: '',
248
+ indent: 0,
249
+ children: inline,
250
+ }
251
+ }
252
+
253
+ // Markdown fence shorthands → prism-react-renderer language ids. Prism
254
+ // bundles `typescript` / `tsx` / `bash` etc. but not the common shorthand
255
+ // forms most authors write. Without normalization the imported language
256
+ // is unrecognised and falls back to plain — which is the "imported code
257
+ // doesn't highlight until I cut-paste it back" symptom (the editor's
258
+ // own CodeNode normalises on insert, which is why the round-trip fixes
259
+ // it).
260
+ const LANG_ALIASES: Record<string, string> = {
261
+ ts: 'typescript',
262
+ js: 'javascript',
263
+ sh: 'bash',
264
+ shell: 'bash',
265
+ zsh: 'bash',
266
+ yml: 'yaml',
267
+ jsonc: 'json',
268
+ py: 'python',
269
+ rb: 'ruby',
270
+ md: 'markdown',
271
+ html: 'markup',
272
+ xml: 'markup',
273
+ }
274
+
275
+ function normalizeCodeLang(lang: string | null | undefined): string | null {
276
+ if (lang == null) return null
277
+ const trimmed = lang.trim().toLowerCase()
278
+ if (trimmed.length === 0) return null
279
+ return LANG_ALIASES[trimmed] ?? trimmed
280
+ }
281
+
282
+ // @lexical/table's TableCellHeaderStates: NO_STATUS=0, ROW=1, COLUMN=2, BOTH=3.
283
+ // Markdown GFM tables only ever produce a ROW header (the first row).
284
+ const HEADER_STATE_NONE = 0
285
+ const HEADER_STATE_ROW = 1
286
+
287
+ function tableNode(node: Table, warnings: MdastToLexicalWarning[]): LexicalNode {
288
+ return {
289
+ type: 'table',
290
+ version: 1,
291
+ direction: 'ltr',
292
+ format: '',
293
+ indent: 0,
294
+ children: node.children.map((row, rowIndex) => tableRowNode(row, rowIndex === 0, warnings)),
295
+ }
296
+ }
297
+
298
+ function tableRowNode(
299
+ row: TableRow,
300
+ isHeaderRow: boolean,
301
+ warnings: MdastToLexicalWarning[]
302
+ ): LexicalNode {
303
+ return {
304
+ type: 'tablerow',
305
+ version: 1,
306
+ direction: 'ltr',
307
+ format: '',
308
+ indent: 0,
309
+ children: row.children.map((cell) => tableCellNode(cell, isHeaderRow, warnings)),
310
+ }
311
+ }
312
+
313
+ function tableCellNode(
314
+ cell: TableCell,
315
+ isHeaderRow: boolean,
316
+ warnings: MdastToLexicalWarning[]
317
+ ): LexicalNode {
318
+ // Lexical's TableCellNode expects block-level children (the editor
319
+ // typing flow inserts a paragraph and writes text into it), so wrap
320
+ // mdast's inline cell content in a single paragraph. Empty cells
321
+ // get an empty paragraph so the node still has structurally valid
322
+ // children.
323
+ const inline = walkInlines(cell.children, 0, warnings)
324
+ return {
325
+ type: 'tablecell',
326
+ version: 1,
327
+ direction: 'ltr',
328
+ format: '',
329
+ indent: 0,
330
+ headerState: isHeaderRow ? HEADER_STATE_ROW : HEADER_STATE_NONE,
331
+ colSpan: 1,
332
+ rowSpan: 1,
333
+ children: [
334
+ {
335
+ type: 'paragraph',
336
+ version: 1,
337
+ direction: 'ltr',
338
+ format: '',
339
+ indent: 0,
340
+ textFormat: 0,
341
+ textStyle: '',
342
+ children: inline,
343
+ },
344
+ ],
345
+ }
346
+ }
347
+
348
+ function codeNode(node: Code): LexicalNode {
349
+ // @lexical/code stores fenced code as a `code` element whose children
350
+ // are `code-highlight` text segments interspersed with `linebreak`s
351
+ // (one per line). At import time we don't tokenize — emit one
352
+ // code-highlight per line and let the runtime re-highlight on render.
353
+ const lines = node.value.split('\n')
354
+ const children: LexicalNode[] = []
355
+ lines.forEach((line, i) => {
356
+ if (i > 0) children.push({ type: 'linebreak', version: 1 })
357
+ if (line.length > 0) {
358
+ children.push({
359
+ type: 'code-highlight',
360
+ version: 1,
361
+ detail: 0,
362
+ format: 0,
363
+ mode: 'normal',
364
+ style: '',
365
+ text: line,
366
+ })
367
+ }
368
+ })
369
+ return {
370
+ type: 'code',
371
+ version: 1,
372
+ direction: 'ltr',
373
+ format: '',
374
+ indent: 0,
375
+ language: normalizeCodeLang(node.lang),
376
+ children,
377
+ }
378
+ }
379
+
380
+ function horizontalRuleNode(_node: ThematicBreak): LexicalNode {
381
+ return { type: 'horizontalrule', version: 1 }
382
+ }
383
+
384
+ function walkInlines(
385
+ nodes: PhrasingContent[],
386
+ format: number,
387
+ warnings: MdastToLexicalWarning[]
388
+ ): LexicalNode[] {
389
+ const out: LexicalNode[] = []
390
+ for (const node of nodes) {
391
+ const converted = inlineNode(node, format, warnings)
392
+ if (Array.isArray(converted)) out.push(...converted)
393
+ else if (converted) out.push(converted)
394
+ }
395
+ return out
396
+ }
397
+
398
+ function inlineNode(
399
+ node: PhrasingContent,
400
+ format: number,
401
+ warnings: MdastToLexicalWarning[]
402
+ ): LexicalNode | LexicalNode[] | null {
403
+ switch (node.type) {
404
+ case 'text':
405
+ return textNode((node as Text).value, format)
406
+ case 'strong':
407
+ return walkInlines((node as Strong).children, format | IS_BOLD, warnings)
408
+ case 'emphasis':
409
+ return walkInlines((node as Emphasis).children, format | IS_ITALIC, warnings)
410
+ case 'delete':
411
+ return walkInlines((node as Delete).children, format | IS_STRIKETHROUGH, warnings)
412
+ case 'inlineCode':
413
+ return textNode((node as InlineCode).value, format | IS_CODE)
414
+ case 'link':
415
+ return linkNode(node as Link, format, warnings)
416
+ case 'break':
417
+ return { type: 'linebreak', version: 1 }
418
+ case 'image':
419
+ warnings.push({
420
+ kind: 'dropped-image',
421
+ detail: `dropped inline image (alt=${(node as Image).alt ?? ''} url=${(node as Image).url})`,
422
+ })
423
+ return null
424
+ case 'html':
425
+ warnings.push({
426
+ kind: 'dropped-html',
427
+ detail: `dropped inline HTML: ${truncate((node as { value: string }).value)}`,
428
+ })
429
+ return null
430
+ default:
431
+ warnings.push({
432
+ kind: 'unsupported-node',
433
+ detail: `dropped inline node of type '${(node as { type: string }).type}'`,
434
+ })
435
+ return null
436
+ }
437
+ }
438
+
439
+ function textNode(text: string, format: number): LexicalNode {
440
+ return {
441
+ type: 'text',
442
+ version: 1,
443
+ detail: 0,
444
+ format,
445
+ mode: 'normal',
446
+ style: '',
447
+ text: collapseSoftNewlines(text),
448
+ }
449
+ }
450
+
451
+ // Hard-wrapped markdown paragraphs (common in `/docs`) leave the
452
+ // source newlines inside mdast text values. HTML would normally
453
+ // collapse those, but Lexical's editor view treats embedded `\n` as
454
+ // visible breaks — paragraphs render with mid-sentence wraps at the
455
+ // original 80-char column rather than reflowing to the container.
456
+ // Collapse a single newline plus any surrounding horizontal whitespace
457
+ // to one space; runs of plain spaces (which the author may have
458
+ // chosen) are left alone.
459
+ function collapseSoftNewlines(text: string): string {
460
+ return text.replace(/[ \t]*\n[ \t]*/g, ' ')
461
+ }
462
+
463
+ function linkNode(node: Link, format: number, warnings: MdastToLexicalWarning[]): LexicalNode {
464
+ const newTab = node.url.startsWith('http://') || node.url.startsWith('https://')
465
+ return {
466
+ type: 'link',
467
+ version: 2,
468
+ direction: 'ltr',
469
+ format: '',
470
+ indent: 0,
471
+ attributes: {
472
+ linkType: 'custom',
473
+ url: node.url,
474
+ newTab,
475
+ rel: newTab ? 'noopener' : null,
476
+ },
477
+ children: walkInlines(node.children, format, warnings),
478
+ }
479
+ }
480
+
481
+ function emptyParagraph(): LexicalNode {
482
+ return {
483
+ type: 'paragraph',
484
+ version: 1,
485
+ direction: 'ltr',
486
+ format: '',
487
+ indent: 0,
488
+ textFormat: 0,
489
+ textStyle: '',
490
+ children: [],
491
+ }
492
+ }
493
+
494
+ function truncate(s: string, max = 40): string {
495
+ const trimmed = s.trim().replace(/\s+/g, ' ')
496
+ return trimmed.length > max ? `${trimmed.slice(0, max)}…` : trimmed
497
+ }
@@ -0,0 +1,31 @@
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
+ * Drop the body's leading H1 when its text matches the frontmatter
11
+ * `title`. Frontmatter is the source of truth for the page title; if
12
+ * the body also opens with the same H1 the rendered doc would display
13
+ * the title twice.
14
+ *
15
+ * The match is case-insensitive and ignores surrounding whitespace, so
16
+ * minor presentation differences ("Authn / Authz" vs "authn / authz")
17
+ * don't leak a duplicate heading. Anything else — a different H1, a
18
+ * leading H2, no leading heading at all — is left untouched.
19
+ */
20
+
21
+ import type { Root } from 'mdast'
22
+ import { toString as mdastToString } from 'mdast-util-to-string'
23
+
24
+ export function stripLeadingH1IfMatches(root: Root, title: string): Root {
25
+ const first = root.children[0]
26
+ if (!first || first.type !== 'heading' || first.depth !== 1) return root
27
+ const headingText = mdastToString(first).trim().toLowerCase()
28
+ const wanted = title.trim().toLowerCase()
29
+ if (headingText !== wanted) return root
30
+ return { ...root, children: root.children.slice(1) }
31
+ }