@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.
Files changed (23) hide show
  1. package/dist/templates/byline/i18n.ts +9 -0
  2. package/dist/templates/byline-examples/collections/docs/admin.tsx +1 -1
  3. package/dist/templates/byline-examples/collections/docs/schema.ts +1 -2
  4. package/dist/templates/byline-examples/collections/news/admin.tsx +1 -1
  5. package/dist/templates/byline-examples/collections/news/schema.ts +1 -2
  6. package/dist/templates/byline-examples/collections/pages/admin.tsx +1 -1
  7. package/dist/templates/byline-examples/collections/pages/schema.ts +1 -2
  8. package/dist/templates/byline-examples/fields/available-languages-field.ts +7 -0
  9. package/dist/templates/byline-examples/i18n.ts +9 -0
  10. package/dist/templates/byline-examples/scripts/backfill-version-locales.ts +46 -0
  11. package/dist/templates/byline-examples/scripts/import-docs.ts +107 -23
  12. package/dist/templates/byline-examples/scripts/lib/mdast-to-lexical.test.node.ts +262 -0
  13. package/dist/templates/byline-examples/scripts/lib/mdast-to-lexical.ts +8 -3
  14. package/dist/templates/byline-examples/scripts/lib/rewrite-doc-links.ts +141 -0
  15. package/dist/templates/byline-examples/scripts/lib/strip-leading-h1.test.node.ts +66 -0
  16. package/dist/templates/byline-examples/scripts/re-anchor.ts +102 -0
  17. package/dist/templates/byline-examples/scripts/regenerate-media.ts +1 -1
  18. package/dist/templates/migrations/{0000_black_sabra.sql → 0000_yielding_northstar.sql} +22 -2
  19. package/dist/templates/migrations/meta/0000_snapshot.json +164 -3
  20. package/dist/templates/migrations/meta/_journal.json +2 -2
  21. package/dist/templates/routes/_byline/route.lazy.tsx +16 -6
  22. package/dist/templates/routes/_byline/route.tsx +34 -9
  23. 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('![alt](https://example.com/x.png)')
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
- function normalizeCodeLang(lang: string | null | undefined): string | null {
276
- if (lang == null) return null
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 null
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 --env-file=.env --env-file=.env.local byline/scripts/regenerate-media.ts
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