@byline/cli 3.12.2 → 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.
- package/dist/templates/byline-examples/collections/docs/schema.ts +15 -1
- package/dist/templates/byline-examples/fields/lexical-richtext-ai.tsx +12 -2
- package/dist/templates/byline-examples/scripts/import-docs.ts +97 -11
- package/dist/templates/byline-examples/scripts/lib/frontmatter.ts +7 -8
- package/dist/templates/byline-examples/scripts/lib/mdast-to-lexical.test.node.ts +82 -9
- package/dist/templates/byline-examples/scripts/lib/mdast-to-lexical.ts +27 -0
- package/dist/templates/byline-examples/scripts/lib/parse-markdown.ts +131 -0
- package/dist/templates/migrations/{0000_rainy_starjammers.sql → 0000_ordinary_rhino.sql} +6 -5
- package/dist/templates/migrations/meta/0000_snapshot.json +31 -28
- package/dist/templates/migrations/meta/_journal.json +2 -2
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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(
|
|
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
|
-
|
|
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 "
|
|
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": "
|
|
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
|
-
"
|
|
1407
|
-
"name": "
|
|
1406
|
+
"child_document_id": {
|
|
1407
|
+
"name": "child_document_id",
|
|
1408
1408
|
"type": "uuid",
|
|
1409
1409
|
"primaryKey": false,
|
|
1410
1410
|
"notNull": true
|
|
1411
1411
|
},
|
|
1412
|
-
"
|
|
1413
|
-
"name": "
|
|
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
|
-
"
|
|
1428
|
-
"name": "
|
|
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": "
|
|
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
|
-
"
|
|
1460
|
-
"name": "
|
|
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
|
-
"
|
|
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
|
-
"
|
|
1473
|
-
"name": "
|
|
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
|
-
"
|
|
1481
|
+
"parent_document_id"
|
|
1478
1482
|
],
|
|
1479
1483
|
"columnsTo": [
|
|
1480
1484
|
"id"
|
|
1481
1485
|
],
|
|
1482
|
-
"onDelete": "
|
|
1486
|
+
"onDelete": "set null",
|
|
1483
1487
|
"onUpdate": "no action"
|
|
1484
1488
|
}
|
|
1485
1489
|
},
|
|
1486
1490
|
"compositePrimaryKeys": {},
|
|
1487
1491
|
"uniqueConstraints": {
|
|
1488
|
-
"
|
|
1489
|
-
"name": "
|
|
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
|
}
|