@byline/cli 3.12.1 → 3.13.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.
@@ -33,7 +33,14 @@ export const Docs = defineCollection({
33
33
  customStatuses: [{ name: 'needs_review', label: 'Needs Review', verb: 'Request Review' }],
34
34
  }),
35
35
  showStats: true,
36
- orderable: true,
36
+ // Document tree (docs/DOCUMENT-TREE.md): the docs collection is a
37
+ // single-parent ordered hierarchy. Mutually exclusive with `orderable` —
38
+ // the tree owns ordering (per-parent, on the edge), so `order_key` on
39
+ // `byline_documents` is inert here. Sibling order and nesting are edited via
40
+ // the sidebar tree-placement widget; the import script can also derive
41
+ // placement from the source directory layout (see
42
+ // byline/scripts/import-docs.ts --tree).
43
+ tree: true,
37
44
  search: { fields: ['title'] },
38
45
  useAsTitle: 'title',
39
46
  useAsPath: 'title',
@@ -110,6 +117,13 @@ export const Docs = defineCollection({
110
117
  `afterDelete: Document in collection ${collectionPath} with document ID ${documentId} deleted.`
111
118
  )
112
119
  },
120
+ // Fires on any structural tree change (place / reorder / re-parent /
121
+ // promote-on-delete) for a `tree: true` collection. The payload carries the
122
+ // affected set; a docs site typically invalidates its nav / breadcrumb /
123
+ // prev-next caches here.
124
+ afterTreeChange: async ({ collectionPath }) => {
125
+ console.log(`afterTreeChange: tree structure changed in collection ${collectionPath}.`)
126
+ },
113
127
  },
114
128
  fields: [
115
129
  {
@@ -27,7 +27,7 @@
27
27
 
28
28
  import { AiLexicalExtension } from '@byline/ai/plugins/lexical'
29
29
  import type { FieldAdminConfig, RichTextEditorProps } from '@byline/core'
30
- import { lexicalEditor } from '@byline/richtext-lexical'
30
+ import { builtInExtensions, lexicalEditor } from '@byline/richtext-lexical/config'
31
31
 
32
32
  /**
33
33
  * AI-enabled wrapper around `@byline/richtext-lexical`'s editor.
@@ -49,7 +49,17 @@ import { lexicalEditor } from '@byline/richtext-lexical'
49
49
  * **Per-field** opt-in — see `aiRichTextAdmin()` below.
50
50
  */
51
51
  export const LexicalRichTextAi = lexicalEditor((c) => {
52
- c.extensions.add(AiLexicalExtension)
52
+ c.extensions.add(AiLexicalExtension).remove(builtInExtensions.FloatingTextFormat)
53
+ // Document-level "view as markdown source" toolbar toggle (the capital-M
54
+ // button) enabled site-wide on the AI editor. Settings live on the baked
55
+ // registration config so every richtext field keeps the AI assistant AND
56
+ // the markdown toggle — a schema-side `editorConfig` would override this
57
+ // baked config and strip the AI extension (it can't carry extensions).
58
+ c.settings.options.markdownToggle = true
59
+ // Inline as-you-type markdown shortcuts (`# ` → H1, `- ` → list, `**x**`
60
+ // → bold, `| a | b |` → table, `:::note` → admonition, …). Uses the same
61
+ // BYLINE_TRANSFORMERS set as the source toggle. Remove this line to disable.
62
+ c.settings.options.markdownShortcutPlugin = true
53
63
  return c
54
64
  }) satisfies (props: RichTextEditorProps) => React.JSX.Element
55
65
 
@@ -24,7 +24,9 @@
24
24
  *
25
25
  * Per-file flow:
26
26
  * 1. Read the file, split frontmatter from body with gray-matter.
27
- * 2. Parse the body to mdast via remark-parse + remark-gfm.
27
+ * 2. Parse the body to mdast via remark-parse + remark-gfm, lifting
28
+ * Docusaurus-style admonition fences (`:::note[Title] … :::`) into
29
+ * synthetic nodes (see lib/parse-markdown.ts).
28
30
  * 3. Rewrite `./SIBLING.md[#hash]` links to `/docs/<imported-path>`
29
31
  * using a sourcePath→docPath map built from a frontmatter pre-pass.
30
32
  * Targets outside the batch are stripped to plain text.
@@ -36,6 +38,17 @@
36
38
  * Flags:
37
39
  * --dry-run Parse + log, no DB writes.
38
40
  * --verbose Print warnings for dropped/unsupported nodes.
41
+ * --tree After importing, build the document tree from the source
42
+ * directory layout (the "folder + index.md" convention). A flat
43
+ * `D/leaf.md` nests under `D/index.md`; a `D/index.md` (a node
44
+ * that owns a folder) nests under the grandparent's index,
45
+ * `dirname(D)/index.md`; a node with no `index.md` parent is a
46
+ * root. A folder appears exactly when a node has children, at
47
+ * any depth. Sibling order follows the source file sort, so
48
+ * number directories/files with `NN-` prefixes to curate the
49
+ * TOC — prefixes never reach the slug (paths come from
50
+ * frontmatter). The `docs` collection must be `tree: true`.
51
+ * See docs/DOCUMENT-TREE.md.
39
52
  */
40
53
 
41
54
  import '../load-env.js'
@@ -43,18 +56,15 @@ import '../server.config.js'
43
56
 
44
57
  import { readFileSync } from 'node:fs'
45
58
  import { glob } from 'node:fs/promises'
46
- import { resolve } from 'node:path'
59
+ import { basename, dirname, join, resolve } from 'node:path'
47
60
 
48
61
  import { createSuperAdminContext } from '@byline/auth'
49
62
  import { type CollectionHandle, createBylineClient } from '@byline/client'
50
63
  import { getCollectionDefinition, getServerConfig, slugify } from '@byline/core'
51
- import type { Root } from 'mdast'
52
- import remarkGfm from 'remark-gfm'
53
- import remarkParse from 'remark-parse'
54
- import { unified } from 'unified'
55
64
 
56
65
  import { type DocFrontmatter, parseDocFile } from './lib/frontmatter.js'
57
66
  import { type MdastToLexicalWarning, mdastToLexical } from './lib/mdast-to-lexical.js'
67
+ import { parseBodyToMdast } from './lib/parse-markdown.js'
58
68
  import { type DocLinkRewriteWarning, rewriteDocLinks } from './lib/rewrite-doc-links.js'
59
69
  import { stripLeadingH1IfMatches } from './lib/strip-leading-h1.js'
60
70
 
@@ -66,14 +76,16 @@ const DEFAULT_IMPORT_STATUS = 'published'
66
76
  interface Flags {
67
77
  dryRun: boolean
68
78
  verbose: boolean
79
+ tree: boolean
69
80
  patterns: string[]
70
81
  }
71
82
 
72
83
  function parseFlags(argv: string[]): Flags {
73
- const flags: Flags = { dryRun: false, verbose: false, patterns: [] }
84
+ const flags: Flags = { dryRun: false, verbose: false, tree: false, patterns: [] }
74
85
  for (const arg of argv) {
75
86
  if (arg === '--dry-run') flags.dryRun = true
76
87
  else if (arg === '--verbose') flags.verbose = true
88
+ else if (arg === '--tree') flags.tree = true
77
89
  else if (arg.startsWith('--')) throw new Error(`unknown flag: ${arg}`)
78
90
  else flags.patterns.push(arg)
79
91
  }
@@ -95,10 +107,6 @@ async function expandPatterns(patterns: string[]): Promise<string[]> {
95
107
  return [...out].sort()
96
108
  }
97
109
 
98
- function parseBodyToMdast(body: string): Root {
99
- return unified().use(remarkParse).use(remarkGfm).parse(body) as Root
100
- }
101
-
102
110
  function logWarnings(filePath: string, warnings: MdastToLexicalWarning[]): void {
103
111
  if (warnings.length === 0) return
104
112
  console.warn(` - ${warnings.length} warning(s) for ${filePath}:`)
@@ -305,6 +313,73 @@ async function processFile(
305
313
  return { filePath, action: 'created', documentId: result.documentId, path: docPath }
306
314
  }
307
315
 
316
+ /**
317
+ * Resolve the source file of a node's parent under the "folder + index.md"
318
+ * convention: a flat `D/leaf.md` is a child of `D/index.md`; a `D/index.md` (a
319
+ * node that owns a folder) is a child of the grandparent's index,
320
+ * `dirname(D)/index.md`. The returned path may not exist (e.g. the docs root
321
+ * has no `index.md`); the caller treats a miss as "this node is a root".
322
+ */
323
+ function parentIndexFile(filePath: string): string {
324
+ const dir = dirname(filePath)
325
+ const base = basename(filePath)
326
+ const isIndex = base === 'index.md' || base === 'index.markdown'
327
+ return join(isIndex ? dirname(dir) : dir, 'index.md')
328
+ }
329
+
330
+ /**
331
+ * Build the document tree from the source directory layout, after every file
332
+ * has been imported (so all parent documents exist). A node's parent is the
333
+ * `index.md` resolved by {@link parentIndexFile}; a node whose parent index is
334
+ * not in the batch is a root. Structure is filesystem-derived (not name-matched
335
+ * to paths), so directory names are free and `NN-` prefixes only affect order.
336
+ *
337
+ * Siblings are appended after the previous sibling in their group so they get
338
+ * distinct, monotonically-increasing per-parent keys (placing with no
339
+ * neighbours would mint the same first key for every sibling). Files are
340
+ * processed in sorted order, so prefix order carries straight through.
341
+ */
342
+ async function placeTreeFromDirectories(
343
+ handle: CollectionHandle,
344
+ results: ProcessResult[]
345
+ ): Promise<void> {
346
+ const placeable = results.filter(
347
+ (r): r is ProcessResult & { documentId: string } => r.documentId != null
348
+ )
349
+ // Index nodes by source file so a child resolves its parent's `index.md`
350
+ // structurally (by path on disk), not by matching directory names to slugs.
351
+ const idByFile = new Map(placeable.map((r) => [r.filePath, r.documentId]))
352
+
353
+ const ROOT_GROUP = '__root__'
354
+ const lastSiblingByGroup = new Map<string, string>()
355
+
356
+ let rooted = 0
357
+ let placed = 0
358
+ let failed = 0
359
+ for (const r of placeable) {
360
+ const parentId = idByFile.get(parentIndexFile(r.filePath))
361
+ const parentDocumentId = parentId != null && parentId !== r.documentId ? parentId : null
362
+ const groupKey = parentDocumentId ?? ROOT_GROUP
363
+ const beforeDocumentId = lastSiblingByGroup.get(groupKey) ?? null
364
+ try {
365
+ await handle.placeTreeNode(r.documentId, { parentDocumentId, beforeDocumentId })
366
+ lastSiblingByGroup.set(groupKey, r.documentId)
367
+ if (parentDocumentId != null) {
368
+ placed += 1
369
+ console.log(` ↳ placed ${r.path} under ${basename(dirname(r.filePath))}`)
370
+ } else {
371
+ rooted += 1
372
+ }
373
+ } catch (err) {
374
+ failed += 1
375
+ console.error(` ✗ tree ${r.path}: ${err instanceof Error ? err.message : err}`)
376
+ }
377
+ }
378
+ console.log(
379
+ `import-docs: tree — ${rooted} root(s), ${placed} child placement(s), ${failed} failed.`
380
+ )
381
+ }
382
+
308
383
  async function run(): Promise<void> {
309
384
  const flags = parseFlags(process.argv.slice(2))
310
385
  const files = await expandPatterns(flags.patterns)
@@ -328,10 +403,12 @@ async function run(): Promise<void> {
328
403
  let updated = 0
329
404
  let skipped = 0
330
405
  let failed = 0
406
+ const results: ProcessResult[] = []
331
407
 
332
408
  for (const file of files) {
333
409
  try {
334
410
  const result = await processFile(file, client, handle, flags, pathMap)
411
+ results.push(result)
335
412
  if (result.action === 'created') created += 1
336
413
  else if (result.action === 'updated') updated += 1
337
414
  else skipped += 1
@@ -346,6 +423,15 @@ async function run(): Promise<void> {
346
423
  console.log(
347
424
  `import-docs: ${created} created, ${updated} updated, ${skipped} skipped, ${failed} failed.`
348
425
  )
426
+
427
+ // Build the tree from the directory layout, once every parent exists.
428
+ if (flags.tree && !flags.dryRun) {
429
+ console.log('import-docs: building document tree from directory layout…')
430
+ await placeTreeFromDirectories(handle, results)
431
+ } else if (flags.tree && flags.dryRun) {
432
+ console.log('import-docs: --tree requested but skipped under --dry-run (no DB writes).')
433
+ }
434
+
349
435
  if (failed > 0) process.exit(1)
350
436
  }
351
437
 
@@ -87,14 +87,13 @@ export function parseDocFile(source: string, filePath: string): ParsedDoc {
87
87
  fm.summary = data.summary
88
88
  }
89
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
- }
90
+ // `status` is overloaded: a workflow status (draft / published / …) is an
91
+ // import directive, but Byline's own design docs also use `status:` for
92
+ // *descriptive* metadata (e.g. "PARTIALLY IMPLEMENTED — …"). Treat a value
93
+ // that isn't a workflow status as descriptive ignore it for import (the
94
+ // file falls back to the default import status) rather than rejecting the
95
+ // whole file. Only a workflow status is carried through as a directive.
96
+ if (typeof data.status === 'string' && ALLOWED_STATUSES.has(data.status)) {
98
97
  fm.status = data.status as DocFrontmatter['status']
99
98
  }
100
99
 
@@ -6,20 +6,13 @@
6
6
  * Copyright (c) Infonomic Company Limited
7
7
  */
8
8
 
9
- import type { Root } from 'mdast'
10
- import remarkGfm from 'remark-gfm'
11
- import remarkParse from 'remark-parse'
12
- import { unified } from 'unified'
13
9
  import { describe, expect, test } from 'vitest'
14
10
 
15
11
  import { mdastToLexical } from './mdast-to-lexical.js'
16
-
17
- function parse(md: string): Root {
18
- return unified().use(remarkParse).use(remarkGfm).parse(md) as Root
19
- }
12
+ import { parseBodyToMdast } from './parse-markdown.js'
20
13
 
21
14
  function convert(md: string) {
22
- return mdastToLexical(parse(md))
15
+ return mdastToLexical(parseBodyToMdast(md))
23
16
  }
24
17
 
25
18
  describe('mdastToLexical', () => {
@@ -259,4 +252,84 @@ describe('mdastToLexical', () => {
259
252
  'In a world where AI produces content fast, into dozens of languages, why does a CMS matter?'
260
253
  )
261
254
  })
255
+
256
+ describe('admonitions', () => {
257
+ test('titled note becomes an admonition node with body paragraphs', () => {
258
+ const md = [':::note[Heads up]', 'First line.', '', 'Second line.', ':::'].join('\n')
259
+ const { state, warnings } = convert(md)
260
+ expect(warnings).toEqual([])
261
+ expect(state.root.children).toHaveLength(1)
262
+ const adm = state.root.children[0] as unknown as {
263
+ type: string
264
+ admonitionType: string
265
+ title: string
266
+ children: Array<{ type: string; children: Array<{ text: string }> }>
267
+ }
268
+ expect(adm).toMatchObject({ type: 'admonition', admonitionType: 'note', title: 'Heads up' })
269
+ expect(adm.children).toHaveLength(2)
270
+ expect(adm.children[0]).toMatchObject({ type: 'paragraph' })
271
+ expect(adm.children[0].children[0].text).toBe('First line.')
272
+ expect(adm.children[1].children[0].text).toBe('Second line.')
273
+ })
274
+
275
+ test('inline formatting + links survive inside an admonition body', () => {
276
+ const md = [
277
+ ':::warning[Careful]',
278
+ 'This is **bold** and a [link](https://x.io).',
279
+ ':::',
280
+ ].join('\n')
281
+ const { state } = convert(md)
282
+ const adm = state.root.children[0] as unknown as {
283
+ admonitionType: string
284
+ children: Array<{ children: Array<{ type: string; text?: string; format?: number }> }>
285
+ }
286
+ expect(adm.admonitionType).toBe('warning')
287
+ const inline = adm.children[0].children
288
+ expect(inline).toContainEqual(expect.objectContaining({ text: 'bold', format: 1 }))
289
+ expect(inline).toContainEqual(expect.objectContaining({ type: 'link' }))
290
+ })
291
+
292
+ test('untitled admonition carries an empty title', () => {
293
+ const { state } = convert([':::tip', 'A quick tip.', ':::'].join('\n'))
294
+ const adm = state.root.children[0] as unknown as { admonitionType: string; title: string }
295
+ expect(adm).toMatchObject({ admonitionType: 'tip', title: '' })
296
+ })
297
+
298
+ test('an empty admonition body is seeded with one empty paragraph', () => {
299
+ const { state } = convert([':::danger[Stop]', ':::'].join('\n'))
300
+ const adm = state.root.children[0] as unknown as {
301
+ admonitionType: string
302
+ children: Array<{ type: string; children: unknown[] }>
303
+ }
304
+ expect(adm.admonitionType).toBe('danger')
305
+ expect(adm.children).toEqual([expect.objectContaining({ type: 'paragraph', children: [] })])
306
+ })
307
+
308
+ test('admonitions sit alongside ordinary blocks in source order', () => {
309
+ const md = ['# Title', '', ':::note[Note]', 'Body.', ':::', '', 'After.'].join('\n')
310
+ const { state } = convert(md)
311
+ expect(state.root.children.map((c) => (c as { type: string }).type)).toEqual([
312
+ 'heading',
313
+ 'admonition',
314
+ 'paragraph',
315
+ ])
316
+ })
317
+
318
+ test('colon-bearing prose is never mistaken for a directive', () => {
319
+ // The line scanner only matches the four container fences, so inline
320
+ // colons (`9:30`, `1:2:3`, `note:foo`) pass through untouched.
321
+ const { state, warnings } = convert('Run at 9:30 with a 1:2:3 ratio and note:foo.')
322
+ expect(warnings).toEqual([])
323
+ const paragraph = state.root.children[0] as unknown as { children: Array<{ text: string }> }
324
+ expect(paragraph.children).toHaveLength(1)
325
+ expect(paragraph.children[0].text).toBe('Run at 9:30 with a 1:2:3 ratio and note:foo.')
326
+ })
327
+
328
+ test('a `:::` fence inside a code block is treated as literal code', () => {
329
+ const md = ['```', ':::note[Not an admonition]', 'still code', ':::', '```'].join('\n')
330
+ const { state } = convert(md)
331
+ expect(state.root.children).toHaveLength(1)
332
+ expect(state.root.children[0]).toMatchObject({ type: 'code' })
333
+ })
334
+ })
262
335
  })
@@ -44,6 +44,8 @@ import type {
44
44
  ThematicBreak,
45
45
  } from 'mdast'
46
46
 
47
+ import type { AdmonitionDirective } from './parse-markdown.js'
48
+
47
49
  // Lexical inline format bits — kept in sync with
48
50
  // apps/webapp/src/ui/byline/components/richtext-lexical/serialize/richtext-node-formats.ts
49
51
  const IS_BOLD = 1
@@ -113,6 +115,11 @@ function walkBlocks(nodes: Content[], warnings: MdastToLexicalWarning[]): Lexica
113
115
  }
114
116
 
115
117
  function blockNode(node: Content, warnings: MdastToLexicalWarning[]): LexicalNode | null {
118
+ // Synthetic admonition container injected by `parse-markdown` — handled
119
+ // ahead of the mdast switch since it isn't a real mdast node type.
120
+ if ((node as { type: string }).type === 'admonitionDirective') {
121
+ return admonitionNode(node as unknown as AdmonitionDirective, warnings)
122
+ }
116
123
  switch (node.type) {
117
124
  case 'paragraph':
118
125
  return paragraphNode(node, warnings)
@@ -250,6 +257,26 @@ function blockquoteNode(node: Blockquote, warnings: MdastToLexicalWarning[]): Le
250
257
  }
251
258
  }
252
259
 
260
+ // Byline admonition (callout). An ElementNode whose body lives as real
261
+ // block children — paragraphs + inline content — matching the editor's
262
+ // `AdmonitionNode`. `admonitionType` / `title` ride the node, not the body.
263
+ function admonitionNode(node: AdmonitionDirective, warnings: MdastToLexicalWarning[]): LexicalNode {
264
+ const children = walkBlocks(node.children as Content[], warnings)
265
+ // Never leave the body empty — mirrors the editor transformer, which
266
+ // seeds an empty paragraph so the caret has somewhere to land.
267
+ if (children.length === 0) children.push(emptyParagraph())
268
+ return {
269
+ type: 'admonition',
270
+ version: 1,
271
+ direction: 'ltr',
272
+ format: '',
273
+ indent: 0,
274
+ admonitionType: node.admonitionType,
275
+ title: node.title,
276
+ children,
277
+ }
278
+ }
279
+
253
280
  // Markdown fence shorthands → prism-react-renderer language ids. Prism
254
281
  // bundles `typescript` / `tsx` / `bash` etc. but not the common shorthand
255
282
  // forms most authors write. Without normalization the imported language
@@ -0,0 +1,131 @@
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
+ * Markdown body → mdast, with Byline admonition extraction.
11
+ *
12
+ * Plain markdown is parsed with remark-parse + remark-gfm. On top of that
13
+ * we recognise the Docusaurus-style admonition container blocks the editor
14
+ * round-trips:
15
+ *
16
+ * :::note[Optional Title]
17
+ * body markdown (paragraphs + inline)
18
+ * :::
19
+ *
20
+ * The fences are line-based and matched with the *same* regexes as the live
21
+ * editor transformer (`packages/richtext-lexical/src/field/markdown/
22
+ * transformers.ts`), so authored markdown round-trips identically through
23
+ * bulk import and the editor's markdown toggle.
24
+ *
25
+ * Why a line scanner rather than `remark-directive`: remark-directive also
26
+ * recognises inline (`:name`) and leaf (`::name`) directives, which turns
27
+ * ordinary prose like `9:30`, `1:2:3`, or `note:foo` into `textDirective`
28
+ * nodes — silently corrupting colon-bearing technical prose. Restricting
29
+ * recognition to the line-anchored container fence avoids that blast radius
30
+ * entirely and is lossless for every non-admonition character.
31
+ *
32
+ * Each admonition becomes a synthetic `admonitionDirective` mdast node whose
33
+ * `children` are the parsed body blocks. It is consumed by
34
+ * `mdast-to-lexical`'s `blockNode`. The node still carries a `children`
35
+ * array, so the link-rewrite pass traverses into admonition bodies normally.
36
+ */
37
+
38
+ import type { Root, RootContent } from 'mdast'
39
+ import remarkGfm from 'remark-gfm'
40
+ import remarkParse from 'remark-parse'
41
+ import { unified } from 'unified'
42
+
43
+ export type AdmonitionType = 'note' | 'tip' | 'warning' | 'danger'
44
+
45
+ /**
46
+ * Synthetic mdast node for a Byline admonition container. Not a real mdast
47
+ * type — produced here and handled by `mdast-to-lexical`. `title` is the raw
48
+ * text from the opening fence's `[...]` (empty string when absent).
49
+ */
50
+ export interface AdmonitionDirective {
51
+ type: 'admonitionDirective'
52
+ admonitionType: AdmonitionType
53
+ title: string
54
+ children: RootContent[]
55
+ }
56
+
57
+ // Mirrors transformers.ts: only the four Docusaurus types start an
58
+ // admonition; the optional `[Title]` rides the opening fence.
59
+ const ADMONITION_START_RE = /^:::(note|tip|warning|danger)(?:\[([^\]]*)\])?\s*$/
60
+ const ADMONITION_END_RE = /^:::\s*$/
61
+ // Fenced code-block delimiter — `:::` lines inside a code fence are literal
62
+ // content, not admonition fences. (A coarse toggle; remark remains the
63
+ // source of truth for the actual code parse.)
64
+ const CODE_FENCE_RE = /^\s*(```|~~~)/
65
+
66
+ function parseSegment(text: string): RootContent[] {
67
+ if (text.trim().length === 0) return []
68
+ const tree = unified().use(remarkParse).use(remarkGfm).parse(text) as Root
69
+ return tree.children
70
+ }
71
+
72
+ export function parseBodyToMdast(body: string): Root {
73
+ const lines = body.split('\n')
74
+ const children: RootContent[] = []
75
+ let buffer: string[] = []
76
+ let inCode = false
77
+ let i = 0
78
+
79
+ const flushBuffer = (): void => {
80
+ if (buffer.length > 0) {
81
+ children.push(...parseSegment(buffer.join('\n')))
82
+ buffer = []
83
+ }
84
+ }
85
+
86
+ while (i < lines.length) {
87
+ const line = lines[i]
88
+
89
+ if (CODE_FENCE_RE.test(line)) {
90
+ inCode = !inCode
91
+ buffer.push(line)
92
+ i += 1
93
+ continue
94
+ }
95
+
96
+ const start = inCode ? null : ADMONITION_START_RE.exec(line)
97
+ if (start) {
98
+ flushBuffer()
99
+ const admonitionType = start[1] as AdmonitionType
100
+ const title = start[2] ?? ''
101
+ const inner: string[] = []
102
+ let innerCode = false
103
+ i += 1
104
+ while (i < lines.length) {
105
+ const bodyLine = lines[i]
106
+ if (CODE_FENCE_RE.test(bodyLine)) {
107
+ innerCode = !innerCode
108
+ } else if (!innerCode && ADMONITION_END_RE.test(bodyLine)) {
109
+ i += 1 // consume the closing fence
110
+ break
111
+ }
112
+ inner.push(bodyLine)
113
+ i += 1
114
+ }
115
+ const node: AdmonitionDirective = {
116
+ type: 'admonitionDirective',
117
+ admonitionType,
118
+ title,
119
+ children: parseSegment(inner.join('\n')),
120
+ }
121
+ children.push(node as unknown as RootContent)
122
+ continue
123
+ }
124
+
125
+ buffer.push(line)
126
+ i += 1
127
+ }
128
+ flushBuffer()
129
+
130
+ return { type: 'root', children: children as Root['children'] }
131
+ }
@@ -149,10 +149,12 @@ CREATE TABLE "byline_document_paths" (
149
149
  );
150
150
  --> statement-breakpoint
151
151
  CREATE TABLE "byline_document_relationships" (
152
- "parent_document_id" uuid NOT NULL,
153
152
  "child_document_id" uuid NOT NULL,
153
+ "parent_document_id" uuid,
154
+ "order_key" varchar(128) COLLATE "C" NOT NULL,
154
155
  "created_at" timestamp (6) with time zone DEFAULT now() NOT NULL,
155
- CONSTRAINT "byline_document_relationships_parent_document_id_child_document_id_unique" UNIQUE("parent_document_id","child_document_id")
156
+ "updated_at" timestamp (6) with time zone DEFAULT now() NOT NULL,
157
+ CONSTRAINT "uq_document_relationships_child" UNIQUE("child_document_id")
156
158
  );
157
159
  --> statement-breakpoint
158
160
  CREATE TABLE "byline_document_version_locales" (
@@ -303,8 +305,8 @@ ALTER TABLE "byline_document_available_locales" ADD CONSTRAINT "byline_document_
303
305
  ALTER TABLE "byline_document_available_locales" ADD CONSTRAINT "byline_document_available_locales_collection_id_byline_collections_id_fk" FOREIGN KEY ("collection_id") REFERENCES "public"."byline_collections"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
304
306
  ALTER TABLE "byline_document_paths" ADD CONSTRAINT "byline_document_paths_document_id_byline_documents_id_fk" FOREIGN KEY ("document_id") REFERENCES "public"."byline_documents"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
305
307
  ALTER TABLE "byline_document_paths" ADD CONSTRAINT "byline_document_paths_collection_id_byline_collections_id_fk" FOREIGN KEY ("collection_id") REFERENCES "public"."byline_collections"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
306
- ALTER TABLE "byline_document_relationships" ADD CONSTRAINT "byline_document_relationships_parent_document_id_byline_documents_id_fk" FOREIGN KEY ("parent_document_id") REFERENCES "public"."byline_documents"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
307
308
  ALTER TABLE "byline_document_relationships" ADD CONSTRAINT "byline_document_relationships_child_document_id_byline_documents_id_fk" FOREIGN KEY ("child_document_id") REFERENCES "public"."byline_documents"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
309
+ ALTER TABLE "byline_document_relationships" ADD CONSTRAINT "byline_document_relationships_parent_document_id_byline_documents_id_fk" FOREIGN KEY ("parent_document_id") REFERENCES "public"."byline_documents"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
308
310
  ALTER TABLE "byline_document_version_locales" ADD CONSTRAINT "byline_document_version_locales_document_version_id_byline_document_versions_id_fk" FOREIGN KEY ("document_version_id") REFERENCES "public"."byline_document_versions"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
309
311
  ALTER TABLE "byline_document_versions" ADD CONSTRAINT "byline_document_versions_document_id_byline_documents_id_fk" FOREIGN KEY ("document_id") REFERENCES "public"."byline_documents"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
310
312
  ALTER TABLE "byline_document_versions" ADD CONSTRAINT "byline_document_versions_collection_id_byline_collections_id_fk" FOREIGN KEY ("collection_id") REFERENCES "public"."byline_collections"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
@@ -341,8 +343,7 @@ CREATE INDEX "idx_datetime_path_date" ON "byline_store_datetime" USING btree ("f
341
343
  CREATE INDEX "idx_datetime_collection_date" ON "byline_store_datetime" USING btree ("collection_id","value_timestamp_tz");--> statement-breakpoint
342
344
  CREATE INDEX "idx_document_available_locales_document_id" ON "byline_document_available_locales" USING btree ("document_id");--> statement-breakpoint
343
345
  CREATE INDEX "idx_document_paths_document_id" ON "byline_document_paths" USING btree ("document_id");--> statement-breakpoint
344
- CREATE INDEX "idx_document_relationships_parent" ON "byline_document_relationships" USING btree ("parent_document_id");--> statement-breakpoint
345
- CREATE INDEX "idx_document_relationships_child" ON "byline_document_relationships" USING btree ("child_document_id");--> statement-breakpoint
346
+ CREATE INDEX "idx_document_relationships_parent_order" ON "byline_document_relationships" USING btree ("parent_document_id","order_key");--> statement-breakpoint
346
347
  CREATE INDEX "idx_documents_document_id" ON "byline_document_versions" USING btree ("document_id");--> statement-breakpoint
347
348
  CREATE INDEX "idx_documents_collection_document_deleted" ON "byline_document_versions" USING btree ("collection_id","document_id","is_deleted");--> statement-breakpoint
348
349
  CREATE INDEX "idx_documents_current_view" ON "byline_document_versions" USING btree ("collection_id","document_id","is_deleted","id");--> statement-breakpoint
@@ -1,5 +1,5 @@
1
1
  {
2
- "id": "00066c6d-bed5-4d32-8431-d950cb3ce641",
2
+ "id": "aebcb197-52c4-4601-a3b5-da89fe333c09",
3
3
  "prevId": "00000000-0000-0000-0000-000000000000",
4
4
  "version": "7",
5
5
  "dialect": "postgresql",
@@ -1403,16 +1403,22 @@
1403
1403
  "name": "byline_document_relationships",
1404
1404
  "schema": "",
1405
1405
  "columns": {
1406
- "parent_document_id": {
1407
- "name": "parent_document_id",
1406
+ "child_document_id": {
1407
+ "name": "child_document_id",
1408
1408
  "type": "uuid",
1409
1409
  "primaryKey": false,
1410
1410
  "notNull": true
1411
1411
  },
1412
- "child_document_id": {
1413
- "name": "child_document_id",
1412
+ "parent_document_id": {
1413
+ "name": "parent_document_id",
1414
1414
  "type": "uuid",
1415
1415
  "primaryKey": false,
1416
+ "notNull": false
1417
+ },
1418
+ "order_key": {
1419
+ "name": "order_key",
1420
+ "type": "varchar(128) COLLATE \"C\"",
1421
+ "primaryKey": false,
1416
1422
  "notNull": true
1417
1423
  },
1418
1424
  "created_at": {
@@ -1421,29 +1427,27 @@
1421
1427
  "primaryKey": false,
1422
1428
  "notNull": true,
1423
1429
  "default": "now()"
1430
+ },
1431
+ "updated_at": {
1432
+ "name": "updated_at",
1433
+ "type": "timestamp (6) with time zone",
1434
+ "primaryKey": false,
1435
+ "notNull": true,
1436
+ "default": "now()"
1424
1437
  }
1425
1438
  },
1426
1439
  "indexes": {
1427
- "idx_document_relationships_parent": {
1428
- "name": "idx_document_relationships_parent",
1440
+ "idx_document_relationships_parent_order": {
1441
+ "name": "idx_document_relationships_parent_order",
1429
1442
  "columns": [
1430
1443
  {
1431
1444
  "expression": "parent_document_id",
1432
1445
  "isExpression": false,
1433
1446
  "asc": true,
1434
1447
  "nulls": "last"
1435
- }
1436
- ],
1437
- "isUnique": false,
1438
- "concurrently": false,
1439
- "method": "btree",
1440
- "with": {}
1441
- },
1442
- "idx_document_relationships_child": {
1443
- "name": "idx_document_relationships_child",
1444
- "columns": [
1448
+ },
1445
1449
  {
1446
- "expression": "child_document_id",
1450
+ "expression": "order_key",
1447
1451
  "isExpression": false,
1448
1452
  "asc": true,
1449
1453
  "nulls": "last"
@@ -1456,12 +1460,12 @@
1456
1460
  }
1457
1461
  },
1458
1462
  "foreignKeys": {
1459
- "byline_document_relationships_parent_document_id_byline_documents_id_fk": {
1460
- "name": "byline_document_relationships_parent_document_id_byline_documents_id_fk",
1463
+ "byline_document_relationships_child_document_id_byline_documents_id_fk": {
1464
+ "name": "byline_document_relationships_child_document_id_byline_documents_id_fk",
1461
1465
  "tableFrom": "byline_document_relationships",
1462
1466
  "tableTo": "byline_documents",
1463
1467
  "columnsFrom": [
1464
- "parent_document_id"
1468
+ "child_document_id"
1465
1469
  ],
1466
1470
  "columnsTo": [
1467
1471
  "id"
@@ -1469,27 +1473,26 @@
1469
1473
  "onDelete": "cascade",
1470
1474
  "onUpdate": "no action"
1471
1475
  },
1472
- "byline_document_relationships_child_document_id_byline_documents_id_fk": {
1473
- "name": "byline_document_relationships_child_document_id_byline_documents_id_fk",
1476
+ "byline_document_relationships_parent_document_id_byline_documents_id_fk": {
1477
+ "name": "byline_document_relationships_parent_document_id_byline_documents_id_fk",
1474
1478
  "tableFrom": "byline_document_relationships",
1475
1479
  "tableTo": "byline_documents",
1476
1480
  "columnsFrom": [
1477
- "child_document_id"
1481
+ "parent_document_id"
1478
1482
  ],
1479
1483
  "columnsTo": [
1480
1484
  "id"
1481
1485
  ],
1482
- "onDelete": "cascade",
1486
+ "onDelete": "set null",
1483
1487
  "onUpdate": "no action"
1484
1488
  }
1485
1489
  },
1486
1490
  "compositePrimaryKeys": {},
1487
1491
  "uniqueConstraints": {
1488
- "byline_document_relationships_parent_document_id_child_document_id_unique": {
1489
- "name": "byline_document_relationships_parent_document_id_child_document_id_unique",
1492
+ "uq_document_relationships_child": {
1493
+ "name": "uq_document_relationships_child",
1490
1494
  "nullsNotDistinct": false,
1491
1495
  "columns": [
1492
- "parent_document_id",
1493
1496
  "child_document_id"
1494
1497
  ]
1495
1498
  }
@@ -5,8 +5,8 @@
5
5
  {
6
6
  "idx": 0,
7
7
  "version": "7",
8
- "when": 1781318465897,
9
- "tag": "0000_rainy_starjammers",
8
+ "when": 1781967902464,
9
+ "tag": "0000_ordinary_rhino",
10
10
  "breakpoints": true
11
11
  }
12
12
  ]
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@byline/cli",
3
3
  "private": false,
4
4
  "license": "MPL-2.0",
5
- "version": "3.12.1",
5
+ "version": "3.13.0",
6
6
  "engines": {
7
7
  "node": ">=20.9.0"
8
8
  },