@byline/cli 2.7.0 → 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-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/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
|
@@ -11,7 +11,6 @@ import { defineCollection, defineWorkflow } from '@byline/core'
|
|
|
11
11
|
|
|
12
12
|
import { PhotoBlock } from '../../blocks/photo-block.js'
|
|
13
13
|
import { RichTextBlock } from '../../blocks/richtext-block.js'
|
|
14
|
-
import { availableLanguagesField } from '../../fields/available-languages-field.js'
|
|
15
14
|
import { publishedOnField } from '../../fields/published-on-field.js'
|
|
16
15
|
|
|
17
16
|
// ---- Schema (server-safe, no UI concerns) ----
|
|
@@ -38,6 +37,7 @@ export const Docs = defineCollection({
|
|
|
38
37
|
search: { fields: ['title'] },
|
|
39
38
|
useAsTitle: 'title',
|
|
40
39
|
useAsPath: 'title',
|
|
40
|
+
advertiseLocales: true, // Renders the available-locales sidebar widget.
|
|
41
41
|
linksInEditor: true, // See type definition for details.
|
|
42
42
|
// All hooks can be a single function or an array of functions.
|
|
43
43
|
// If an array is provided, the functions will be executed in sequence.
|
|
@@ -156,7 +156,6 @@ export const Docs = defineCollection({
|
|
|
156
156
|
optional: true,
|
|
157
157
|
blocks: [RichTextBlock, PhotoBlock],
|
|
158
158
|
},
|
|
159
|
-
availableLanguagesField(),
|
|
160
159
|
],
|
|
161
160
|
})
|
|
162
161
|
|
|
@@ -9,7 +9,6 @@
|
|
|
9
9
|
import type { CollectionFieldData } from '@byline/core'
|
|
10
10
|
import { defineCollection, defineWorkflow } from '@byline/core'
|
|
11
11
|
|
|
12
|
-
import { availableLanguagesField } from '~/fields/available-languages-field.js'
|
|
13
12
|
import { publishedOnField } from '~/fields/published-on-field.js'
|
|
14
13
|
|
|
15
14
|
// ---- Schema (server-safe, no UI concerns) ----
|
|
@@ -34,6 +33,7 @@ export const News = defineCollection({
|
|
|
34
33
|
search: { fields: ['title'] },
|
|
35
34
|
useAsTitle: 'title',
|
|
36
35
|
useAsPath: 'title',
|
|
36
|
+
advertiseLocales: true, // Renders the available-locales sidebar widget.
|
|
37
37
|
linksInEditor: true, // See type definition for details.
|
|
38
38
|
fields: [
|
|
39
39
|
{ name: 'title', label: 'Title', type: 'text', localized: true },
|
|
@@ -82,7 +82,6 @@ export const News = defineCollection({
|
|
|
82
82
|
embedRelationsOnSave: true, // See type definition for details.
|
|
83
83
|
},
|
|
84
84
|
publishedOnField,
|
|
85
|
-
availableLanguagesField(),
|
|
86
85
|
],
|
|
87
86
|
})
|
|
88
87
|
|
|
@@ -11,7 +11,6 @@ import { defineCollection, defineWorkflow } from '@byline/core'
|
|
|
11
11
|
|
|
12
12
|
import { PhotoBlock } from '~/blocks/photo-block'
|
|
13
13
|
import { RichTextBlock } from '~/blocks/richtext-block'
|
|
14
|
-
import { availableLanguagesField } from '~/fields/available-languages-field.js'
|
|
15
14
|
import { publishedOnField } from '~/fields/published-on-field'
|
|
16
15
|
|
|
17
16
|
// ---- Schema (server-safe, no UI concerns) ----
|
|
@@ -36,6 +35,7 @@ export const Pages = defineCollection({
|
|
|
36
35
|
search: { fields: ['title'] },
|
|
37
36
|
useAsTitle: 'title',
|
|
38
37
|
useAsPath: 'title',
|
|
38
|
+
advertiseLocales: true, // Renders the available-locales sidebar widget.
|
|
39
39
|
/**
|
|
40
40
|
* Pages live at the site root (no `/pages/` prefix) and may be nested
|
|
41
41
|
* under an `area` segment. Same composition rule used by the admin
|
|
@@ -100,7 +100,6 @@ export const Pages = defineCollection({
|
|
|
100
100
|
blocks: [RichTextBlock, PhotoBlock],
|
|
101
101
|
},
|
|
102
102
|
publishedOnField,
|
|
103
|
-
availableLanguagesField(),
|
|
104
103
|
],
|
|
105
104
|
})
|
|
106
105
|
|
|
@@ -13,6 +13,13 @@
|
|
|
13
13
|
* the only imports are `@byline/core` types and the i18n locale list.
|
|
14
14
|
*
|
|
15
15
|
* See `docs/FIELDS.md` for the schema-vs-admin model.
|
|
16
|
+
*
|
|
17
|
+
* @deprecated **No longer needed** as of the content-locale-resolution work.
|
|
18
|
+
* The editorial "advertise these locales" control is now a core *system
|
|
19
|
+
* attribute* + ledger-aware sidebar widget (`availableLocales`) — see
|
|
20
|
+
* `docs/AVAILABLE-LOCALES.md`. This userland custom field is **left in place as
|
|
21
|
+
* a reference example** of the schema-side field-builder pattern; it is not used
|
|
22
|
+
* by the system widget and can be removed from collection schemas.
|
|
16
23
|
*/
|
|
17
24
|
|
|
18
25
|
import type { GroupField } from '@byline/core'
|
|
@@ -0,0 +1,46 @@
|
|
|
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
|
+
* One-time maintenance: populate the version-locale availability ledger
|
|
11
|
+
* (`byline_document_version_locales`) for document versions written before
|
|
12
|
+
* the ledger existed. After this runs, `localeFallback: 'strict'` reads can
|
|
13
|
+
* see pre-existing documents; new writes keep the ledger current on their own
|
|
14
|
+
* (createDocumentVersion step 6).
|
|
15
|
+
*
|
|
16
|
+
* Idempotent — safe to re-run. Uses the installation's configured default
|
|
17
|
+
* content locale (resolved from byline/i18n.ts via the server config).
|
|
18
|
+
*
|
|
19
|
+
* cd apps/webapp && pnpm tsx byline/scripts/backfill-version-locales.ts
|
|
20
|
+
*
|
|
21
|
+
* See docs/CONTENT-LOCALE-RESOLUTION.md.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import '../load-env.js'
|
|
25
|
+
import '../server.config.js'
|
|
26
|
+
|
|
27
|
+
import { getServerConfig } from '@byline/core'
|
|
28
|
+
import type { PgAdapter } from '@byline/db-postgres'
|
|
29
|
+
|
|
30
|
+
async function run() {
|
|
31
|
+
// `backfillVersionLocales` is a Postgres-adapter housekeeping method (off
|
|
32
|
+
// the core `IDbAdapter` contract), so annotate the registered adapter as
|
|
33
|
+
// `PgAdapter` — the documented pattern for scripts that need raw handles.
|
|
34
|
+
const db = getServerConfig().db as PgAdapter
|
|
35
|
+
const { rowsInserted } = await db.backfillVersionLocales()
|
|
36
|
+
console.log(
|
|
37
|
+
`✓ version-locale ledger backfilled — ${rowsInserted} (version, locale) row(s) inserted`
|
|
38
|
+
)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
run()
|
|
42
|
+
.then(() => process.exit(0))
|
|
43
|
+
.catch((error) => {
|
|
44
|
+
console.error('✗ version-locale backfill failed:', error)
|
|
45
|
+
process.exit(1)
|
|
46
|
+
})
|
|
@@ -8,10 +8,10 @@
|
|
|
8
8
|
|
|
9
9
|
// Examples...
|
|
10
10
|
//
|
|
11
|
-
// pnpm tsx
|
|
12
|
-
// pnpm tsx
|
|
13
|
-
// pnpm tsx
|
|
14
|
-
// pnpm tsx
|
|
11
|
+
// pnpm tsx byline/scripts/import-docs.ts ../../docs/**/*.md --dry-run
|
|
12
|
+
// pnpm tsx byline/scripts/import-docs.ts ../../docs/**/*.md --verbose
|
|
13
|
+
// pnpm tsx byline/scripts/import-docs.ts '../../docs/*.md' --dry-run --verbose
|
|
14
|
+
// pnpm tsx byline/scripts/import-docs.ts ../../docs/**/*.md
|
|
15
15
|
//
|
|
16
16
|
// Run tests...
|
|
17
17
|
//
|
|
@@ -20,14 +20,17 @@
|
|
|
20
20
|
/**
|
|
21
21
|
* Import markdown files into the `docs` collection.
|
|
22
22
|
*
|
|
23
|
-
* pnpm tsx
|
|
23
|
+
* pnpm tsx byline/scripts/import-docs.ts <path-or-glob...>
|
|
24
24
|
*
|
|
25
25
|
* Per-file flow:
|
|
26
26
|
* 1. Read the file, split frontmatter from body with gray-matter.
|
|
27
27
|
* 2. Parse the body to mdast via remark-parse + remark-gfm.
|
|
28
|
-
* 3.
|
|
29
|
-
*
|
|
30
|
-
*
|
|
28
|
+
* 3. Rewrite `./SIBLING.md[#hash]` links to `/docs/<imported-path>`
|
|
29
|
+
* using a sourcePath→docPath map built from a frontmatter pre-pass.
|
|
30
|
+
* Targets outside the batch are stripped to plain text.
|
|
31
|
+
* 4. Convert mdast → Lexical SerializedEditorState.
|
|
32
|
+
* 5. Resolve `featureImage` (a `media` path) to a relation envelope.
|
|
33
|
+
* 6. `findByPath` to decide create vs update. On update, status and
|
|
31
34
|
* publishedOn are preserved — editorial state in Byline wins.
|
|
32
35
|
*
|
|
33
36
|
* Flags:
|
|
@@ -44,7 +47,7 @@ import { resolve } from 'node:path'
|
|
|
44
47
|
|
|
45
48
|
import { createSuperAdminContext } from '@byline/auth'
|
|
46
49
|
import { type CollectionHandle, createBylineClient } from '@byline/client'
|
|
47
|
-
import { getServerConfig, slugify } from '@byline/core'
|
|
50
|
+
import { getCollectionDefinition, getServerConfig, slugify } from '@byline/core'
|
|
48
51
|
import type { Root } from 'mdast'
|
|
49
52
|
import remarkGfm from 'remark-gfm'
|
|
50
53
|
import remarkParse from 'remark-parse'
|
|
@@ -52,10 +55,13 @@ import { unified } from 'unified'
|
|
|
52
55
|
|
|
53
56
|
import { type DocFrontmatter, parseDocFile } from './lib/frontmatter.js'
|
|
54
57
|
import { type MdastToLexicalWarning, mdastToLexical } from './lib/mdast-to-lexical.js'
|
|
58
|
+
import { type DocLinkRewriteWarning, rewriteDocLinks } from './lib/rewrite-doc-links.js'
|
|
55
59
|
import { stripLeadingH1IfMatches } from './lib/strip-leading-h1.js'
|
|
56
60
|
|
|
57
61
|
const DOCS_COLLECTION = 'docs'
|
|
58
62
|
const MEDIA_COLLECTION = 'media'
|
|
63
|
+
const DOCS_URL_PREFIX = '/docs'
|
|
64
|
+
const DEFAULT_IMPORT_STATUS = 'published'
|
|
59
65
|
|
|
60
66
|
interface Flags {
|
|
61
67
|
dryRun: boolean
|
|
@@ -101,6 +107,20 @@ function logWarnings(filePath: string, warnings: MdastToLexicalWarning[]): void
|
|
|
101
107
|
}
|
|
102
108
|
}
|
|
103
109
|
|
|
110
|
+
function logLinkWarnings(filePath: string, warnings: DocLinkRewriteWarning[]): void {
|
|
111
|
+
if (warnings.length === 0) return
|
|
112
|
+
console.warn(` - ${warnings.length} link rewrite(s) for ${filePath}:`)
|
|
113
|
+
for (const w of warnings) {
|
|
114
|
+
if (w.kind === 'rewritten-doc-link') {
|
|
115
|
+
console.warn(` [rewrite] ${w.href} → ${w.resolvedTo}`)
|
|
116
|
+
} else if (w.kind === 'unresolved-doc-link') {
|
|
117
|
+
console.warn(` [unresolved] ${w.href} (stripped to plain text)`)
|
|
118
|
+
} else {
|
|
119
|
+
console.warn(` [empty] '${w.href}' (stripped to plain text)`)
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
104
124
|
interface ResolvedFeatureImage {
|
|
105
125
|
targetCollectionId: string
|
|
106
126
|
targetDocumentId: string
|
|
@@ -123,14 +143,12 @@ interface BuildPayloadArgs {
|
|
|
123
143
|
frontmatter: DocFrontmatter
|
|
124
144
|
lexicalState: unknown
|
|
125
145
|
featureImage: ResolvedFeatureImage | null
|
|
126
|
-
locale: string
|
|
127
146
|
}
|
|
128
147
|
|
|
129
148
|
function buildDocPayload({
|
|
130
149
|
frontmatter,
|
|
131
150
|
lexicalState,
|
|
132
151
|
featureImage,
|
|
133
|
-
locale,
|
|
134
152
|
}: BuildPayloadArgs): Record<string, unknown> {
|
|
135
153
|
const payload: Record<string, unknown> = {
|
|
136
154
|
title: frontmatter.title,
|
|
@@ -141,11 +159,6 @@ function buildDocPayload({
|
|
|
141
159
|
constrainedWidth: frontmatter.constrainedWidth ?? true,
|
|
142
160
|
},
|
|
143
161
|
],
|
|
144
|
-
// The `availableLanguages` field's built-in validator requires at
|
|
145
|
-
// least one checked locale; without it, opening the imported doc
|
|
146
|
-
// in the admin surfaces a validation error before any save. Seed
|
|
147
|
-
// the authoring locale so editors land on a valid form.
|
|
148
|
-
availableLanguages: { [locale]: true },
|
|
149
162
|
}
|
|
150
163
|
if (frontmatter.summary !== undefined) payload.summary = frontmatter.summary
|
|
151
164
|
if (frontmatter.publishedOn !== undefined) payload.publishedOn = frontmatter.publishedOn
|
|
@@ -158,6 +171,27 @@ function derivePath(frontmatter: DocFrontmatter, locale: string): string {
|
|
|
158
171
|
return slugify(frontmatter.title, { locale, collectionPath: DOCS_COLLECTION })
|
|
159
172
|
}
|
|
160
173
|
|
|
174
|
+
/**
|
|
175
|
+
* Walk a document's status forward to `targetStatus`. The workflow only
|
|
176
|
+
* permits ±1 step transitions, so jumping draft → published has to step
|
|
177
|
+
* through any intermediate statuses (e.g. needs_review). No-op when the
|
|
178
|
+
* workflow doesn't include the target or when already at/past it.
|
|
179
|
+
*/
|
|
180
|
+
async function walkToStatus(
|
|
181
|
+
handle: CollectionHandle,
|
|
182
|
+
documentId: string,
|
|
183
|
+
workflowStatuses: readonly { name: string }[],
|
|
184
|
+
currentStatus: string,
|
|
185
|
+
targetStatus: string
|
|
186
|
+
): Promise<void> {
|
|
187
|
+
const currentIdx = workflowStatuses.findIndex((s) => s.name === currentStatus)
|
|
188
|
+
const targetIdx = workflowStatuses.findIndex((s) => s.name === targetStatus)
|
|
189
|
+
if (currentIdx === -1 || targetIdx === -1 || targetIdx <= currentIdx) return
|
|
190
|
+
for (let i = currentIdx + 1; i <= targetIdx; i++) {
|
|
191
|
+
await handle.changeStatus(documentId, workflowStatuses[i].name)
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
161
195
|
interface ProcessResult {
|
|
162
196
|
filePath: string
|
|
163
197
|
action: 'created' | 'updated' | 'skipped'
|
|
@@ -165,17 +199,45 @@ interface ProcessResult {
|
|
|
165
199
|
path: string
|
|
166
200
|
}
|
|
167
201
|
|
|
202
|
+
/**
|
|
203
|
+
* Build a map of absolute markdown source paths → imported doc paths,
|
|
204
|
+
* by reading just the frontmatter of every file in the batch. Needed so
|
|
205
|
+
* the link-rewrite step in pass 2 can resolve `./SIBLING.md` to the URL
|
|
206
|
+
* its target will be served at after import.
|
|
207
|
+
*/
|
|
208
|
+
function buildSourcePathMap(files: string[], defaultLocale: string): Map<string, string> {
|
|
209
|
+
const map = new Map<string, string>()
|
|
210
|
+
for (const file of files) {
|
|
211
|
+
try {
|
|
212
|
+
const source = readFileSync(file, 'utf8')
|
|
213
|
+
const parsed = parseDocFile(source, file)
|
|
214
|
+
const locale = parsed.frontmatter.locale ?? defaultLocale
|
|
215
|
+
map.set(file, derivePath(parsed.frontmatter, locale))
|
|
216
|
+
} catch {
|
|
217
|
+
// Skip — pass 2 will surface the parse error against this file.
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
return map
|
|
221
|
+
}
|
|
222
|
+
|
|
168
223
|
async function processFile(
|
|
169
224
|
filePath: string,
|
|
170
225
|
client: ReturnType<typeof createBylineClient>,
|
|
171
226
|
handle: CollectionHandle,
|
|
172
|
-
flags: Flags
|
|
227
|
+
flags: Flags,
|
|
228
|
+
pathMap: Map<string, string>
|
|
173
229
|
): Promise<ProcessResult> {
|
|
174
230
|
const source = readFileSync(filePath, 'utf8')
|
|
175
231
|
const parsed = parseDocFile(source, filePath)
|
|
176
232
|
const locale = parsed.frontmatter.locale ?? client.defaultLocale
|
|
177
233
|
|
|
178
234
|
const mdast = stripLeadingH1IfMatches(parseBodyToMdast(parsed.body), parsed.frontmatter.title)
|
|
235
|
+
const linkWarnings = rewriteDocLinks(mdast, {
|
|
236
|
+
sourceFilePath: filePath,
|
|
237
|
+
pathMap,
|
|
238
|
+
urlPrefix: DOCS_URL_PREFIX,
|
|
239
|
+
})
|
|
240
|
+
if (flags.verbose) logLinkWarnings(filePath, linkWarnings)
|
|
179
241
|
const { state, warnings } = mdastToLexical(mdast)
|
|
180
242
|
if (flags.verbose) logWarnings(filePath, warnings)
|
|
181
243
|
|
|
@@ -193,7 +255,6 @@ async function processFile(
|
|
|
193
255
|
frontmatter: parsed.frontmatter,
|
|
194
256
|
lexicalState: state,
|
|
195
257
|
featureImage,
|
|
196
|
-
locale,
|
|
197
258
|
})
|
|
198
259
|
|
|
199
260
|
if (flags.dryRun) {
|
|
@@ -201,6 +262,15 @@ async function processFile(
|
|
|
201
262
|
return { filePath, action: 'skipped', path: docPath }
|
|
202
263
|
}
|
|
203
264
|
|
|
265
|
+
// Re-imports default to the published status (overridable per-file via
|
|
266
|
+
// frontmatter `status:`). `update` always resets to the workflow's
|
|
267
|
+
// default status on a new version, so we walk transitions forward
|
|
268
|
+
// afterwards; `create` accepts an initial status directly.
|
|
269
|
+
const desiredStatus = parsed.frontmatter.status ?? DEFAULT_IMPORT_STATUS
|
|
270
|
+
const definition = getCollectionDefinition(DOCS_COLLECTION)
|
|
271
|
+
const workflowStatuses = definition?.workflow?.statuses ?? []
|
|
272
|
+
const defaultStatus = workflowStatuses[0]?.name ?? 'draft'
|
|
273
|
+
|
|
204
274
|
const existing = await handle.findByPath(docPath, {
|
|
205
275
|
locale,
|
|
206
276
|
status: 'any',
|
|
@@ -208,19 +278,29 @@ async function processFile(
|
|
|
208
278
|
})
|
|
209
279
|
|
|
210
280
|
if (existing) {
|
|
211
|
-
//
|
|
212
|
-
// don't clobber publishedOn if Byline already has one.
|
|
281
|
+
// Don't clobber publishedOn if Byline already has one.
|
|
213
282
|
if (existing.fields?.publishedOn) {
|
|
214
283
|
delete payload.publishedOn
|
|
215
284
|
}
|
|
216
|
-
const result = await handle.update(existing.id, payload, {
|
|
285
|
+
const result = await handle.update(existing.id, payload, {
|
|
286
|
+
locale,
|
|
287
|
+
// Advertise the imported locale (editorial available-locales set), merged
|
|
288
|
+
// with whatever is already advertised so a later-locale re-import doesn't
|
|
289
|
+
// clobber an earlier one. The public set is still gated by the version
|
|
290
|
+
// completeness ledger (intersection). See docs/AVAILABLE-LOCALES.md.
|
|
291
|
+
availableLocales: [...new Set([...(existing.availableLocales ?? []), locale])],
|
|
292
|
+
})
|
|
293
|
+
await walkToStatus(handle, result.documentId, workflowStatuses, defaultStatus, desiredStatus)
|
|
217
294
|
return { filePath, action: 'updated', documentId: result.documentId, path: docPath }
|
|
218
295
|
}
|
|
219
296
|
|
|
220
297
|
const result = await handle.create(payload, {
|
|
221
298
|
locale,
|
|
222
|
-
status:
|
|
299
|
+
status: desiredStatus,
|
|
223
300
|
path: docPath,
|
|
301
|
+
// Advertise the authoring locale; the public advertised set is the
|
|
302
|
+
// intersection of this editorial set with the completeness ledger.
|
|
303
|
+
availableLocales: [locale],
|
|
224
304
|
})
|
|
225
305
|
return { filePath, action: 'created', documentId: result.documentId, path: docPath }
|
|
226
306
|
}
|
|
@@ -240,6 +320,10 @@ async function run(): Promise<void> {
|
|
|
240
320
|
const client = createBylineClient({ config, requestContext })
|
|
241
321
|
const handle = client.collection(DOCS_COLLECTION)
|
|
242
322
|
|
|
323
|
+
// Pre-pass: map each source file to the path its imported doc will
|
|
324
|
+
// live at, so pass 2 can rewrite cross-doc markdown links.
|
|
325
|
+
const pathMap = buildSourcePathMap(files, client.defaultLocale)
|
|
326
|
+
|
|
243
327
|
let created = 0
|
|
244
328
|
let updated = 0
|
|
245
329
|
let skipped = 0
|
|
@@ -247,7 +331,7 @@ async function run(): Promise<void> {
|
|
|
247
331
|
|
|
248
332
|
for (const file of files) {
|
|
249
333
|
try {
|
|
250
|
-
const result = await processFile(file, client, handle, flags)
|
|
334
|
+
const result = await processFile(file, client, handle, flags, pathMap)
|
|
251
335
|
if (result.action === 'created') created += 1
|
|
252
336
|
else if (result.action === 'updated') updated += 1
|
|
253
337
|
else skipped += 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
|
+
})
|