@byline/cli 2.6.1 → 3.0.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/i18n.ts +9 -0
- package/dist/templates/byline-examples/collections/docs/admin.tsx +1 -1
- package/dist/templates/byline-examples/collections/docs/schema.ts +1 -2
- package/dist/templates/byline-examples/collections/news/admin.tsx +1 -1
- package/dist/templates/byline-examples/collections/news/schema.ts +1 -2
- package/dist/templates/byline-examples/collections/pages/admin.tsx +1 -1
- package/dist/templates/byline-examples/collections/pages/schema.ts +1 -2
- package/dist/templates/byline-examples/fields/available-languages-field.ts +7 -0
- package/dist/templates/byline-examples/i18n.ts +9 -0
- package/dist/templates/byline-examples/scripts/backfill-version-locales.ts +46 -0
- package/dist/templates/byline-examples/scripts/import-docs.ts +107 -23
- package/dist/templates/byline-examples/scripts/lib/mdast-to-lexical.test.node.ts +262 -0
- package/dist/templates/byline-examples/scripts/lib/mdast-to-lexical.ts +8 -3
- package/dist/templates/byline-examples/scripts/lib/rewrite-doc-links.ts +141 -0
- package/dist/templates/byline-examples/scripts/lib/strip-leading-h1.test.node.ts +66 -0
- package/dist/templates/byline-examples/scripts/re-anchor.ts +102 -0
- package/dist/templates/byline-examples/scripts/regenerate-media.ts +1 -1
- package/dist/templates/migrations/{0000_black_sabra.sql → 0000_yielding_northstar.sql} +22 -2
- package/dist/templates/migrations/meta/0000_snapshot.json +164 -3
- package/dist/templates/migrations/meta/_journal.json +2 -2
- package/dist/templates/routes/_byline/route.lazy.tsx +16 -6
- package/dist/templates/routes/_byline/route.tsx +34 -9
- package/package.json +1 -1
|
@@ -0,0 +1,262 @@
|
|
|
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
|
+
import type { Root } from 'mdast'
|
|
10
|
+
import remarkGfm from 'remark-gfm'
|
|
11
|
+
import remarkParse from 'remark-parse'
|
|
12
|
+
import { unified } from 'unified'
|
|
13
|
+
import { describe, expect, test } from 'vitest'
|
|
14
|
+
|
|
15
|
+
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
|
+
}
|
|
20
|
+
|
|
21
|
+
function convert(md: string) {
|
|
22
|
+
return mdastToLexical(parse(md))
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe('mdastToLexical', () => {
|
|
26
|
+
test('empty input yields a single empty paragraph', () => {
|
|
27
|
+
const { state, warnings } = convert('')
|
|
28
|
+
expect(warnings).toEqual([])
|
|
29
|
+
expect(state.root.type).toBe('root')
|
|
30
|
+
expect(state.root.children).toHaveLength(1)
|
|
31
|
+
expect(state.root.children[0]).toMatchObject({ type: 'paragraph', children: [] })
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
test('paragraph with plain text', () => {
|
|
35
|
+
const { state } = convert('hello world')
|
|
36
|
+
expect(state.root.children[0]).toMatchObject({
|
|
37
|
+
type: 'paragraph',
|
|
38
|
+
children: [{ type: 'text', text: 'hello world', format: 0 }],
|
|
39
|
+
})
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
test('heading depth maps to tag h1-h6', () => {
|
|
43
|
+
const { state } = convert('# h1\n\n## h2\n\n###### h6')
|
|
44
|
+
expect(state.root.children).toHaveLength(3)
|
|
45
|
+
expect(state.root.children[0]).toMatchObject({ type: 'heading', tag: 'h1' })
|
|
46
|
+
expect(state.root.children[1]).toMatchObject({ type: 'heading', tag: 'h2' })
|
|
47
|
+
expect(state.root.children[2]).toMatchObject({ type: 'heading', tag: 'h6' })
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
test('bold + italic compose into a single text node with bitmask format=3', () => {
|
|
51
|
+
const { state } = convert('***both***')
|
|
52
|
+
const paragraph = state.root.children[0] as unknown as {
|
|
53
|
+
children: Array<{ format: number; text: string }>
|
|
54
|
+
}
|
|
55
|
+
expect(paragraph.children).toHaveLength(1)
|
|
56
|
+
expect(paragraph.children[0]).toMatchObject({ text: 'both', format: 1 | 2 })
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
test('inline code carries the IS_CODE bit', () => {
|
|
60
|
+
const { state } = convert('a `b` c')
|
|
61
|
+
const paragraph = state.root.children[0] as unknown as {
|
|
62
|
+
children: Array<{ format: number; text: string }>
|
|
63
|
+
}
|
|
64
|
+
// 'a ', code 'b', ' c'
|
|
65
|
+
expect(paragraph.children.map((c) => ({ text: c.text, format: c.format }))).toEqual([
|
|
66
|
+
{ text: 'a ', format: 0 },
|
|
67
|
+
{ text: 'b', format: 1 << 4 },
|
|
68
|
+
{ text: ' c', format: 0 },
|
|
69
|
+
])
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
test('strikethrough (GFM) carries the strikethrough bit', () => {
|
|
73
|
+
const { state } = convert('a ~~b~~ c')
|
|
74
|
+
const paragraph = state.root.children[0] as unknown as {
|
|
75
|
+
children: Array<{ format: number; text: string }>
|
|
76
|
+
}
|
|
77
|
+
const struck = paragraph.children.find((c) => c.text === 'b')
|
|
78
|
+
expect(struck?.format).toBe(1 << 2)
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
test('unordered list emits listType=bullet, tag=ul, listitems with peeled paragraph children', () => {
|
|
82
|
+
const { state } = convert('- one\n- two')
|
|
83
|
+
const list = state.root.children[0] as unknown as {
|
|
84
|
+
type: string
|
|
85
|
+
listType: string
|
|
86
|
+
tag: string
|
|
87
|
+
children: Array<{ type: string; children: Array<{ type: string; text: string }> }>
|
|
88
|
+
}
|
|
89
|
+
expect(list).toMatchObject({ type: 'list', listType: 'bullet', tag: 'ul' })
|
|
90
|
+
expect(list.children).toHaveLength(2)
|
|
91
|
+
expect(list.children[0]).toMatchObject({
|
|
92
|
+
type: 'listitem',
|
|
93
|
+
children: [{ type: 'text', text: 'one' }],
|
|
94
|
+
})
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
test('ordered list propagates start and numbers values', () => {
|
|
98
|
+
const { state } = convert('3. a\n4. b')
|
|
99
|
+
const list = state.root.children[0] as unknown as {
|
|
100
|
+
listType: string
|
|
101
|
+
tag: string
|
|
102
|
+
start: number
|
|
103
|
+
children: Array<{ value: number }>
|
|
104
|
+
}
|
|
105
|
+
expect(list).toMatchObject({ listType: 'number', tag: 'ol', start: 3 })
|
|
106
|
+
expect(list.children[0].value).toBe(3)
|
|
107
|
+
expect(list.children[1].value).toBe(4)
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
test('nested list lives inside the parent listitem', () => {
|
|
111
|
+
const { state } = convert('- a\n - a1\n- b')
|
|
112
|
+
const list = state.root.children[0] as unknown as {
|
|
113
|
+
children: Array<{ children: Array<{ type: string }> }>
|
|
114
|
+
}
|
|
115
|
+
const firstItemChildren = list.children[0].children
|
|
116
|
+
expect(firstItemChildren.some((c) => c.type === 'list')).toBe(true)
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
test('link emits link node with custom attributes envelope', () => {
|
|
120
|
+
const { state } = convert('[label](https://example.com)')
|
|
121
|
+
const paragraph = state.root.children[0] as unknown as {
|
|
122
|
+
children: Array<{
|
|
123
|
+
type: string
|
|
124
|
+
attributes?: { linkType?: string; url?: string; newTab?: boolean }
|
|
125
|
+
children?: Array<{ text: string }>
|
|
126
|
+
}>
|
|
127
|
+
}
|
|
128
|
+
const link = paragraph.children[0]
|
|
129
|
+
expect(link.type).toBe('link')
|
|
130
|
+
expect(link.attributes).toMatchObject({
|
|
131
|
+
linkType: 'custom',
|
|
132
|
+
url: 'https://example.com',
|
|
133
|
+
newTab: true,
|
|
134
|
+
})
|
|
135
|
+
expect(link.children?.[0]).toMatchObject({ text: 'label' })
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
test('fenced code with language stores `language` and emits code-highlight + linebreak children', () => {
|
|
139
|
+
const { state } = convert('```ts\nconst x = 1\nconst y = 2\n```')
|
|
140
|
+
const code = state.root.children[0] as unknown as {
|
|
141
|
+
type: string
|
|
142
|
+
language: string
|
|
143
|
+
children: Array<{ type: string; text?: string }>
|
|
144
|
+
}
|
|
145
|
+
expect(code).toMatchObject({ type: 'code', language: 'typescript' })
|
|
146
|
+
expect(code.children[0]).toMatchObject({ type: 'code-highlight', text: 'const x = 1' })
|
|
147
|
+
expect(code.children[1]).toMatchObject({ type: 'linebreak' })
|
|
148
|
+
expect(code.children[2]).toMatchObject({ type: 'code-highlight', text: 'const y = 2' })
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
test('thematic break emits horizontalrule', () => {
|
|
152
|
+
const { state } = convert('---')
|
|
153
|
+
expect(state.root.children[0]).toMatchObject({ type: 'horizontalrule' })
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
test('blockquote flattens single paragraph into inline children', () => {
|
|
157
|
+
const { state } = convert('> hello')
|
|
158
|
+
const quote = state.root.children[0] as unknown as {
|
|
159
|
+
type: string
|
|
160
|
+
children: Array<{ type: string; text?: string }>
|
|
161
|
+
}
|
|
162
|
+
expect(quote.type).toBe('quote')
|
|
163
|
+
expect(quote.children).toEqual([expect.objectContaining({ type: 'text', text: 'hello' })])
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
test('image at block level is dropped with a warning', () => {
|
|
167
|
+
const { warnings, state } = convert('')
|
|
168
|
+
expect(warnings.some((w) => w.kind === 'dropped-image')).toBe(true)
|
|
169
|
+
// Paragraph survives as empty (image was its only child)
|
|
170
|
+
expect(state.root.children[0]).toMatchObject({ type: 'paragraph' })
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
test('GFM table maps to table/tablerow/tablecell with first-row headerState', () => {
|
|
174
|
+
const md = '| a | b |\n| - | - |\n| 1 | 2 |\n'
|
|
175
|
+
const { state, warnings } = convert(md)
|
|
176
|
+
expect(warnings).toEqual([])
|
|
177
|
+
|
|
178
|
+
const table = state.root.children[0] as unknown as {
|
|
179
|
+
type: string
|
|
180
|
+
children: Array<{
|
|
181
|
+
type: string
|
|
182
|
+
children: Array<{
|
|
183
|
+
type: string
|
|
184
|
+
headerState: number
|
|
185
|
+
children: Array<{ type: string; children: Array<{ text: string }> }>
|
|
186
|
+
}>
|
|
187
|
+
}>
|
|
188
|
+
}
|
|
189
|
+
expect(table.type).toBe('table')
|
|
190
|
+
expect(table.children).toHaveLength(2)
|
|
191
|
+
|
|
192
|
+
const [headerRow, bodyRow] = table.children
|
|
193
|
+
expect(headerRow.type).toBe('tablerow')
|
|
194
|
+
expect(bodyRow.type).toBe('tablerow')
|
|
195
|
+
expect(headerRow.children[0]).toMatchObject({ type: 'tablecell', headerState: 1 })
|
|
196
|
+
expect(headerRow.children[1]).toMatchObject({ type: 'tablecell', headerState: 1 })
|
|
197
|
+
expect(bodyRow.children[0]).toMatchObject({ type: 'tablecell', headerState: 0 })
|
|
198
|
+
expect(bodyRow.children[1]).toMatchObject({ type: 'tablecell', headerState: 0 })
|
|
199
|
+
|
|
200
|
+
// Inline cell content is wrapped in a paragraph.
|
|
201
|
+
const firstHeaderCell = headerRow.children[0]
|
|
202
|
+
expect(firstHeaderCell.children[0].type).toBe('paragraph')
|
|
203
|
+
expect(firstHeaderCell.children[0].children[0]).toMatchObject({ text: 'a' })
|
|
204
|
+
expect(bodyRow.children[1].children[0].children[0]).toMatchObject({ text: '2' })
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
test('empty cell emits a paragraph with no children', () => {
|
|
208
|
+
const md = '| a | b |\n| - | - |\n| | 2 |\n'
|
|
209
|
+
const { state } = convert(md)
|
|
210
|
+
const table = state.root.children[0] as unknown as {
|
|
211
|
+
children: Array<{
|
|
212
|
+
children: Array<{ children: Array<{ type: string; children: unknown[] }> }>
|
|
213
|
+
}>
|
|
214
|
+
}
|
|
215
|
+
const emptyCellParagraph = table.children[1].children[0].children[0]
|
|
216
|
+
expect(emptyCellParagraph).toMatchObject({ type: 'paragraph', children: [] })
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
test('inline formatting inside cells is preserved', () => {
|
|
220
|
+
const md = '| col |\n| - |\n| **bold** |\n'
|
|
221
|
+
const { state } = convert(md)
|
|
222
|
+
const table = state.root.children[0] as unknown as {
|
|
223
|
+
children: Array<{
|
|
224
|
+
children: Array<{
|
|
225
|
+
children: Array<{ children: Array<{ text: string; format: number }> }>
|
|
226
|
+
}>
|
|
227
|
+
}>
|
|
228
|
+
}
|
|
229
|
+
const bodyCellInline = table.children[1].children[0].children[0].children[0]
|
|
230
|
+
expect(bodyCellInline).toMatchObject({ text: 'bold', format: 1 })
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
test('code fence language is normalized to prism-known ids', () => {
|
|
234
|
+
const cases: Array<[string, string]> = [
|
|
235
|
+
['ts', 'typescript'],
|
|
236
|
+
['js', 'javascript'],
|
|
237
|
+
['sh', 'bash'],
|
|
238
|
+
['yml', 'yaml'],
|
|
239
|
+
['tsx', 'tsx'], // already prism-known, passes through
|
|
240
|
+
]
|
|
241
|
+
for (const [input, expected] of cases) {
|
|
242
|
+
const { state } = convert(`\`\`\`${input}\nx\n\`\`\``)
|
|
243
|
+
const code = state.root.children[0] as unknown as { language: string }
|
|
244
|
+
expect(code.language).toBe(expected)
|
|
245
|
+
}
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
test('hard-wrapped paragraph collapses internal newlines to spaces', () => {
|
|
249
|
+
const md =
|
|
250
|
+
'In a world where AI produces content fast,\ninto dozens of languages,\nwhy does a CMS matter?'
|
|
251
|
+
const { state } = convert(md)
|
|
252
|
+
const paragraph = state.root.children[0] as unknown as {
|
|
253
|
+
children: Array<{ text: string }>
|
|
254
|
+
}
|
|
255
|
+
// Single text node with newlines collapsed to single spaces — no
|
|
256
|
+
// <br> nodes and no embedded \n.
|
|
257
|
+
expect(paragraph.children).toHaveLength(1)
|
|
258
|
+
expect(paragraph.children[0].text).toBe(
|
|
259
|
+
'In a world where AI produces content fast, into dozens of languages, why does a CMS matter?'
|
|
260
|
+
)
|
|
261
|
+
})
|
|
262
|
+
})
|
|
@@ -272,10 +272,15 @@ const LANG_ALIASES: Record<string, string> = {
|
|
|
272
272
|
xml: 'markup',
|
|
273
273
|
}
|
|
274
274
|
|
|
275
|
-
|
|
276
|
-
|
|
275
|
+
// Default for unfenced / language-less code blocks. Prism crashes on
|
|
276
|
+
// null/undefined languages, and the project's docs are predominantly
|
|
277
|
+
// TypeScript, so fall back to that rather than 'plain'.
|
|
278
|
+
const DEFAULT_CODE_LANG = 'typescript'
|
|
279
|
+
|
|
280
|
+
function normalizeCodeLang(lang: string | null | undefined): string {
|
|
281
|
+
if (lang == null) return DEFAULT_CODE_LANG
|
|
277
282
|
const trimmed = lang.trim().toLowerCase()
|
|
278
|
-
if (trimmed.length === 0) return
|
|
283
|
+
if (trimmed.length === 0) return DEFAULT_CODE_LANG
|
|
279
284
|
return LANG_ALIASES[trimmed] ?? trimmed
|
|
280
285
|
}
|
|
281
286
|
|
|
@@ -0,0 +1,141 @@
|
|
|
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
|
+
* Rewrite relative markdown links so they work after import.
|
|
11
|
+
*
|
|
12
|
+
* Markdown docs commonly link to siblings via `./OTHER.md[#hash]`. Once
|
|
13
|
+
* imported into Lexical, those hrefs break — Lexical's link serializer
|
|
14
|
+
* passes them to `new URL(...)` and throws. We rewrite them in-place
|
|
15
|
+
* against a `sourcePath → importedDocPath` map built in a pre-pass.
|
|
16
|
+
*
|
|
17
|
+
* Links whose target isn't in the map (i.e. the .md file wasn't part of
|
|
18
|
+
* the same import batch) are stripped: the `link` node is replaced by
|
|
19
|
+
* its inline children, so the reader sees the link text but no broken
|
|
20
|
+
* URL. The original href is reported as a warning.
|
|
21
|
+
*
|
|
22
|
+
* Non-markdown hrefs (absolute URLs, mailto:, pure fragments, images,
|
|
23
|
+
* etc.) are left untouched.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { dirname, resolve } from 'node:path'
|
|
27
|
+
|
|
28
|
+
import type { Parent, Root, RootContent } from 'mdast'
|
|
29
|
+
|
|
30
|
+
export interface DocLinkRewriteWarning {
|
|
31
|
+
kind: 'rewritten-doc-link' | 'unresolved-doc-link' | 'stripped-empty-link'
|
|
32
|
+
href: string
|
|
33
|
+
resolvedTo?: string
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface RewriteDocLinksOptions {
|
|
37
|
+
/** Absolute path of the markdown file currently being converted. */
|
|
38
|
+
sourceFilePath: string
|
|
39
|
+
/** Map from absolute markdown source path → imported doc path (no leading slash). */
|
|
40
|
+
pathMap: Map<string, string>
|
|
41
|
+
/** URL prefix the imported docs live under, e.g. `/docs`. */
|
|
42
|
+
urlPrefix: string
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const MD_EXT_RE = /\.(md|markdown)$/i
|
|
46
|
+
// A leading `scheme:` like `https:`, `mailto:`, `data:` — anything we
|
|
47
|
+
// must not touch.
|
|
48
|
+
const SCHEME_RE = /^[a-z][a-z0-9+.-]*:/i
|
|
49
|
+
|
|
50
|
+
export function rewriteDocLinks(root: Root, opts: RewriteDocLinksOptions): DocLinkRewriteWarning[] {
|
|
51
|
+
const warnings: DocLinkRewriteWarning[] = []
|
|
52
|
+
const baseDir = dirname(opts.sourceFilePath)
|
|
53
|
+
const prefix = opts.urlPrefix.replace(/\/$/, '')
|
|
54
|
+
rewriteChildren(root as unknown as Parent, baseDir, prefix, opts.pathMap, warnings)
|
|
55
|
+
return warnings
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function rewriteChildren(
|
|
59
|
+
parent: Parent,
|
|
60
|
+
baseDir: string,
|
|
61
|
+
prefix: string,
|
|
62
|
+
pathMap: Map<string, string>,
|
|
63
|
+
warnings: DocLinkRewriteWarning[]
|
|
64
|
+
): void {
|
|
65
|
+
const next: RootContent[] = []
|
|
66
|
+
for (const child of parent.children as RootContent[]) {
|
|
67
|
+
if (child.type === 'link') {
|
|
68
|
+
const decision = classifyLink(child.url, baseDir, pathMap)
|
|
69
|
+
if (decision.kind === 'skip') {
|
|
70
|
+
// Still recurse — a link may contain other links via images, etc.
|
|
71
|
+
rewriteChildren(child as unknown as Parent, baseDir, prefix, pathMap, warnings)
|
|
72
|
+
next.push(child)
|
|
73
|
+
} else if (decision.kind === 'rewrite') {
|
|
74
|
+
const newUrl = `${prefix}/${decision.docPath}${decision.fragment}`
|
|
75
|
+
warnings.push({
|
|
76
|
+
kind: 'rewritten-doc-link',
|
|
77
|
+
href: child.url,
|
|
78
|
+
resolvedTo: newUrl,
|
|
79
|
+
})
|
|
80
|
+
child.url = newUrl
|
|
81
|
+
rewriteChildren(child as unknown as Parent, baseDir, prefix, pathMap, warnings)
|
|
82
|
+
next.push(child)
|
|
83
|
+
} else {
|
|
84
|
+
// Unresolved .md or empty/meaningless target — drop the link
|
|
85
|
+
// wrapper, keep the text so the reader still sees the prose.
|
|
86
|
+
warnings.push({
|
|
87
|
+
kind: decision.kind === 'unresolved' ? 'unresolved-doc-link' : 'stripped-empty-link',
|
|
88
|
+
href: child.url,
|
|
89
|
+
})
|
|
90
|
+
rewriteChildren(child as unknown as Parent, baseDir, prefix, pathMap, warnings)
|
|
91
|
+
for (const inner of child.children) next.push(inner as RootContent)
|
|
92
|
+
}
|
|
93
|
+
} else {
|
|
94
|
+
if (hasChildren(child)) {
|
|
95
|
+
rewriteChildren(child, baseDir, prefix, pathMap, warnings)
|
|
96
|
+
}
|
|
97
|
+
next.push(child)
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
parent.children = next as Parent['children']
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
type Decision =
|
|
104
|
+
| { kind: 'skip' }
|
|
105
|
+
| { kind: 'rewrite'; docPath: string; fragment: string }
|
|
106
|
+
| { kind: 'unresolved' }
|
|
107
|
+
| { kind: 'empty' }
|
|
108
|
+
|
|
109
|
+
// Targets that reference "the current directory" with no real path —
|
|
110
|
+
// `.`, `..`, `./`, `../`, etc. Authors sometimes write `[text](.)` as a
|
|
111
|
+
// placeholder; in the rendered doc it has no meaning and Lexical's
|
|
112
|
+
// link serializer crashes on it.
|
|
113
|
+
const EMPTY_TARGET_RE = /^\.{1,2}\/?$/
|
|
114
|
+
|
|
115
|
+
function classifyLink(
|
|
116
|
+
url: string | undefined | null,
|
|
117
|
+
baseDir: string,
|
|
118
|
+
pathMap: Map<string, string>
|
|
119
|
+
): Decision {
|
|
120
|
+
if (url == null) return { kind: 'skip' }
|
|
121
|
+
if (url.length === 0) return { kind: 'empty' }
|
|
122
|
+
if (url.startsWith('#')) return { kind: 'skip' }
|
|
123
|
+
if (url.startsWith('//')) return { kind: 'skip' }
|
|
124
|
+
if (SCHEME_RE.test(url)) return { kind: 'skip' }
|
|
125
|
+
|
|
126
|
+
const hashIdx = url.indexOf('#')
|
|
127
|
+
const target = hashIdx >= 0 ? url.slice(0, hashIdx) : url
|
|
128
|
+
const fragment = hashIdx >= 0 ? url.slice(hashIdx) : ''
|
|
129
|
+
|
|
130
|
+
if (target.length === 0 || EMPTY_TARGET_RE.test(target)) return { kind: 'empty' }
|
|
131
|
+
if (!MD_EXT_RE.test(target)) return { kind: 'skip' }
|
|
132
|
+
|
|
133
|
+
const abs = resolve(baseDir, target)
|
|
134
|
+
const mapped = pathMap.get(abs)
|
|
135
|
+
if (mapped) return { kind: 'rewrite', docPath: mapped, fragment }
|
|
136
|
+
return { kind: 'unresolved' }
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function hasChildren(node: RootContent): node is RootContent & Parent {
|
|
140
|
+
return 'children' in node && Array.isArray((node as Parent).children)
|
|
141
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
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
|
+
import type { Root } from 'mdast'
|
|
10
|
+
import remarkGfm from 'remark-gfm'
|
|
11
|
+
import remarkParse from 'remark-parse'
|
|
12
|
+
import { unified } from 'unified'
|
|
13
|
+
import { describe, expect, test } from 'vitest'
|
|
14
|
+
|
|
15
|
+
import { stripLeadingH1IfMatches } from './strip-leading-h1.js'
|
|
16
|
+
|
|
17
|
+
function parse(md: string): Root {
|
|
18
|
+
return unified().use(remarkParse).use(remarkGfm).parse(md) as Root
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe('stripLeadingH1IfMatches', () => {
|
|
22
|
+
test('removes the leading H1 when its text matches the title', () => {
|
|
23
|
+
const root = parse('# Authentication & Authorization\n\nbody')
|
|
24
|
+
const out = stripLeadingH1IfMatches(root, 'Authentication & Authorization')
|
|
25
|
+
expect(out.children).toHaveLength(1)
|
|
26
|
+
expect(out.children[0]).toMatchObject({ type: 'paragraph' })
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
test('match is case-insensitive and whitespace-tolerant', () => {
|
|
30
|
+
const root = parse('# authentication & authorization \n\nbody')
|
|
31
|
+
const out = stripLeadingH1IfMatches(root, 'Authentication & Authorization')
|
|
32
|
+
expect(out.children).toHaveLength(1)
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
test('match flattens inline formatting in the H1', () => {
|
|
36
|
+
// mdast-util-to-string drops backticks / emphasis markers from the
|
|
37
|
+
// H1's inline structure. Frontmatter titles are plain prose, so a
|
|
38
|
+
// body H1 of '# Client SDK (`@byline/client`)' compares equal to a
|
|
39
|
+
// frontmatter title of 'Client SDK (@byline/client)'.
|
|
40
|
+
const root = parse('# Client SDK (`@byline/client`)\n\nbody')
|
|
41
|
+
const out = stripLeadingH1IfMatches(root, 'Client SDK (@byline/client)')
|
|
42
|
+
expect(out.children).toHaveLength(1)
|
|
43
|
+
expect(out.children[0]).toMatchObject({ type: 'paragraph' })
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
test('leaves the body untouched when the H1 differs', () => {
|
|
47
|
+
const root = parse('# Different Title\n\nbody')
|
|
48
|
+
const out = stripLeadingH1IfMatches(root, 'Authentication & Authorization')
|
|
49
|
+
expect(out.children).toHaveLength(2)
|
|
50
|
+
expect(out.children[0]).toMatchObject({ type: 'heading', depth: 1 })
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
test('leaves the body untouched when there is no leading heading', () => {
|
|
54
|
+
const root = parse('just a paragraph')
|
|
55
|
+
const out = stripLeadingH1IfMatches(root, 'Anything')
|
|
56
|
+
expect(out.children).toHaveLength(1)
|
|
57
|
+
expect(out.children[0]).toMatchObject({ type: 'paragraph' })
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
test('leaves the body untouched when the leading heading is H2', () => {
|
|
61
|
+
const root = parse('## A Subheading\n\nbody')
|
|
62
|
+
const out = stripLeadingH1IfMatches(root, 'A Subheading')
|
|
63
|
+
expect(out.children).toHaveLength(2)
|
|
64
|
+
expect(out.children[0]).toMatchObject({ type: 'heading', depth: 2 })
|
|
65
|
+
})
|
|
66
|
+
})
|
|
@@ -0,0 +1,102 @@
|
|
|
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
|
+
* Bulk re-anchor: move every fully-translated document onto a new content
|
|
11
|
+
* source locale. This is the follow-up to switching `i18n.content.defaultLocale`
|
|
12
|
+
* — Slices 1–4 make the switch *safe* (existing docs keep reading their original
|
|
13
|
+
* anchor); this command actually moves the documents that are complete in the
|
|
14
|
+
* new locale onto it (its fallback floor, path locale, and completeness
|
|
15
|
+
* yardstick). Documents not yet fully translated into the target are skipped and
|
|
16
|
+
* listed — that list is the outstanding-translation backlog. Re-run as
|
|
17
|
+
* translation progresses; it is idempotent (each doc is its own transaction).
|
|
18
|
+
*
|
|
19
|
+
* cd apps/webapp && pnpm tsx byline/scripts/re-anchor.ts --to fr
|
|
20
|
+
* cd apps/webapp && pnpm tsx byline/scripts/re-anchor.ts --to fr --collection pages
|
|
21
|
+
* cd apps/webapp && pnpm tsx byline/scripts/re-anchor.ts --to fr --dry-run
|
|
22
|
+
*
|
|
23
|
+
* See docs/DEFAULT-LOCALE-SWITCHING.md.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import '../load-env.js'
|
|
27
|
+
import '../server.config.js'
|
|
28
|
+
|
|
29
|
+
import { parseArgs } from 'node:util'
|
|
30
|
+
|
|
31
|
+
import { getBylineCore, getServerConfig } from '@byline/core'
|
|
32
|
+
import type { PgAdapter } from '@byline/db-postgres'
|
|
33
|
+
|
|
34
|
+
const USAGE =
|
|
35
|
+
'Usage: pnpm tsx byline/scripts/re-anchor.ts --to <locale> [--collection <path>] [--dry-run]'
|
|
36
|
+
|
|
37
|
+
async function run() {
|
|
38
|
+
const { values } = parseArgs({
|
|
39
|
+
options: {
|
|
40
|
+
to: { type: 'string' },
|
|
41
|
+
collection: { type: 'string' },
|
|
42
|
+
'dry-run': { type: 'boolean', default: false },
|
|
43
|
+
},
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
const targetLocale = values.to
|
|
47
|
+
if (!targetLocale) {
|
|
48
|
+
console.error(`✗ missing required --to <locale>.\n${USAGE}`)
|
|
49
|
+
process.exit(1)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const config = getServerConfig()
|
|
53
|
+
|
|
54
|
+
const contentLocales = config.i18n.content.locales
|
|
55
|
+
if (!contentLocales.includes(targetLocale)) {
|
|
56
|
+
console.error(
|
|
57
|
+
`✗ --to '${targetLocale}' is not a configured content locale (${contentLocales.join(', ')})`
|
|
58
|
+
)
|
|
59
|
+
process.exit(1)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Resolve an optional collection-path filter to its id via the registered
|
|
63
|
+
// collection records (throws a clear error for an unknown path).
|
|
64
|
+
const collectionPath = values.collection
|
|
65
|
+
const collectionId = collectionPath
|
|
66
|
+
? getBylineCore().getCollectionRecord(collectionPath).collectionId
|
|
67
|
+
: undefined
|
|
68
|
+
|
|
69
|
+
const dryRun = values['dry-run'] ?? false
|
|
70
|
+
const db = config.db as PgAdapter
|
|
71
|
+
const scope = collectionPath ? `collection '${collectionPath}'` : 'all collections'
|
|
72
|
+
|
|
73
|
+
console.log(
|
|
74
|
+
`${dryRun ? '[dry-run] ' : ''}re-anchoring ${scope} → content source locale '${targetLocale}'…`
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
const report = await db.reAnchorDocuments({ targetLocale, collectionId, dryRun })
|
|
78
|
+
|
|
79
|
+
console.log(
|
|
80
|
+
`${dryRun ? '[dry-run] would re-anchor' : '✓ re-anchored'} ${report.reanchored}/${report.total} document(s) → '${targetLocale}'`
|
|
81
|
+
)
|
|
82
|
+
console.log(` • already anchored to '${targetLocale}': ${report.alreadyAnchored}`)
|
|
83
|
+
console.log(` • skipped (incomplete translation): ${report.skippedIncomplete}`)
|
|
84
|
+
|
|
85
|
+
if (report.skippedIncomplete > 0) {
|
|
86
|
+
console.log(
|
|
87
|
+
`\nDocuments needing a complete '${targetLocale}' translation before they can be re-anchored:`
|
|
88
|
+
)
|
|
89
|
+
for (const r of report.results) {
|
|
90
|
+
if (r.status === 'skipped-incomplete') {
|
|
91
|
+
console.log(` - ${r.documentId} (currently anchored to '${r.fromLocale}')`)
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
run()
|
|
98
|
+
.then(() => process.exit(0))
|
|
99
|
+
.catch((error) => {
|
|
100
|
+
console.error('✗ re-anchor failed:', error)
|
|
101
|
+
process.exit(1)
|
|
102
|
+
})
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
* to `'avif'` in `byline/collections/media/schema.ts`) to bring existing
|
|
19
19
|
* assets in line with the new pipeline:
|
|
20
20
|
*
|
|
21
|
-
* pnpm tsx
|
|
21
|
+
* pnpm tsx byline/scripts/regenerate-media.ts
|
|
22
22
|
*
|
|
23
23
|
* The script orchestrates the same two-step flow the admin UI uses
|
|
24
24
|
* for an existing document — upload (createDocument: false) followed by
|