@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
@@ -39,9 +39,18 @@ export const i18n = {
39
39
  interface: {
40
40
  defaultLocale: 'en',
41
41
  locales: interfaceLocales.map((l) => l.code),
42
+ // Optional display names for the admin language switcher. Lets you
43
+ // author `Español` rather than the lowercase `español` that
44
+ // Intl.DisplayNames returns; omit to fall back to Intl per code.
45
+ localeDefinitions: interfaceLocales.map((l) => ({ code: l.code, nativeName: l.label })),
42
46
  },
43
47
  content: {
44
48
  defaultLocale: 'en',
45
49
  locales: contentLocales.map((l) => l.code),
50
+ // Optional display names for content locales. Byline doesn't render
51
+ // these (content has no admin switcher) — a public frontend can read
52
+ // them from getServerConfig().i18n.content.localeDefinitions to label
53
+ // hreflang / "read this in…" affordances without a parallel map.
54
+ localeDefinitions: contentLocales.map((l) => ({ code: l.code, nativeName: l.label })),
46
55
  },
47
56
  }
@@ -200,6 +200,6 @@ export const DocsAdmin: CollectionAdminConfig = defineAdmin(Docs, {
200
200
  */
201
201
  layout: {
202
202
  main: ['tabs'],
203
- sidebar: ['publishedOn', 'availableLanguages'],
203
+ sidebar: ['publishedOn'],
204
204
  },
205
205
  })
@@ -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
 
@@ -214,6 +214,6 @@ export const NewsAdmin: CollectionAdminConfig = defineAdmin(News, {
214
214
  */
215
215
  layout: {
216
216
  main: ['main'],
217
- sidebar: ['featured', 'availableLanguages', 'publishedOn'],
217
+ sidebar: ['featured', 'publishedOn'],
218
218
  },
219
219
  })
@@ -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
 
@@ -205,6 +205,6 @@ export const PagesAdmin: CollectionAdminConfig = defineAdmin(Pages, {
205
205
  */
206
206
  layout: {
207
207
  main: ['tabs'],
208
- sidebar: ['publishedOn', 'availableLanguages'],
208
+ sidebar: ['publishedOn'],
209
209
  },
210
210
  })
@@ -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'
@@ -39,9 +39,18 @@ export const i18n = {
39
39
  interface: {
40
40
  defaultLocale: 'en',
41
41
  locales: interfaceLocales.map((l) => l.code),
42
+ // Optional display names for the admin language switcher. Lets you
43
+ // author `Español` rather than the lowercase `español` that
44
+ // Intl.DisplayNames returns; omit to fall back to Intl per code.
45
+ localeDefinitions: interfaceLocales.map((l) => ({ code: l.code, nativeName: l.label })),
42
46
  },
43
47
  content: {
44
48
  defaultLocale: 'en',
45
49
  locales: contentLocales.map((l) => l.code),
50
+ // Optional display names for content locales. Byline doesn't render
51
+ // these (content has no admin switcher) — a public frontend can read
52
+ // them from getServerConfig().i18n.content.localeDefinitions to label
53
+ // hreflang / "read this in…" affordances without a parallel map.
54
+ localeDefinitions: contentLocales.map((l) => ({ code: l.code, nativeName: l.label })),
46
55
  },
47
56
  }
@@ -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 --env-file=.env --env-file=.env.local byline/scripts/import-docs.ts ../../docs/**/*.md --dry-run
12
- // pnpm tsx --env-file=.env --env-file=.env.local byline/scripts/import-docs.ts ../../docs/**/*.md --verbose
13
- // pnpm tsx --env-file=.env --env-file=.env.local byline/scripts/import-docs.ts '../../docs/*.md' --dry-run --verbose
14
- // pnpm tsx --env-file=.env --env-file=.env.local byline/scripts/import-docs.ts ../../docs/**/*.md
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 --env-file=.env --env-file=.env.local byline/scripts/import-docs.ts <path-or-glob...>
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. Convert mdast Lexical SerializedEditorState.
29
- * 4. Resolve `featureImage` (a `media` path) to a relation envelope.
30
- * 5. `findByPath` to decide create vs update. On update, status and
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
- // Editorial state wins on re-import: don't overwrite status, and
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, { locale })
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: parsed.frontmatter.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