@byline/cli 3.21.0 → 4.1.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 (183) hide show
  1. package/README.md +29 -3
  2. package/dist/cli.js +5 -2
  3. package/dist/cli.js.map +1 -1
  4. package/dist/commands/doctor.d.ts.map +1 -1
  5. package/dist/commands/doctor.js +1 -0
  6. package/dist/commands/doctor.js.map +1 -1
  7. package/dist/commands/init.d.ts +3 -0
  8. package/dist/commands/init.d.ts.map +1 -1
  9. package/dist/commands/init.js +10 -14
  10. package/dist/commands/init.js.map +1 -1
  11. package/dist/commands/setup-checks.d.ts +9 -0
  12. package/dist/commands/setup-checks.d.ts.map +1 -1
  13. package/dist/commands/setup-checks.js +43 -18
  14. package/dist/commands/setup-checks.js.map +1 -1
  15. package/dist/commands/setup.js +1 -1
  16. package/dist/commands/setup.js.map +1 -1
  17. package/dist/context.d.ts +2 -0
  18. package/dist/context.d.ts.map +1 -1
  19. package/dist/context.js +6 -0
  20. package/dist/context.js.map +1 -1
  21. package/dist/lib/dependency-version.d.ts +12 -0
  22. package/dist/lib/dependency-version.d.ts.map +1 -0
  23. package/dist/lib/dependency-version.js +101 -0
  24. package/dist/lib/dependency-version.js.map +1 -0
  25. package/dist/lib/planned-writes.d.ts +8 -0
  26. package/dist/lib/planned-writes.d.ts.map +1 -0
  27. package/dist/lib/planned-writes.js +177 -0
  28. package/dist/lib/planned-writes.js.map +1 -0
  29. package/dist/lib/planned-writes.test.d.ts +2 -0
  30. package/dist/lib/planned-writes.test.d.ts.map +1 -0
  31. package/dist/lib/planned-writes.test.js +113 -0
  32. package/dist/lib/planned-writes.test.js.map +1 -0
  33. package/dist/lib/release-policy.d.ts +12 -0
  34. package/dist/lib/release-policy.d.ts.map +1 -0
  35. package/dist/lib/release-policy.js +20 -0
  36. package/dist/lib/release-policy.js.map +1 -0
  37. package/dist/lib/release-policy.test.d.ts +2 -0
  38. package/dist/lib/release-policy.test.d.ts.map +1 -0
  39. package/dist/lib/release-policy.test.js +36 -0
  40. package/dist/lib/release-policy.test.js.map +1 -0
  41. package/dist/lib/route-config.d.ts +40 -0
  42. package/dist/lib/route-config.d.ts.map +1 -0
  43. package/dist/lib/route-config.js +322 -0
  44. package/dist/lib/route-config.js.map +1 -0
  45. package/dist/lib/static-config.d.ts +20 -0
  46. package/dist/lib/static-config.d.ts.map +1 -0
  47. package/dist/lib/static-config.js +444 -0
  48. package/dist/lib/static-config.js.map +1 -0
  49. package/dist/lib/static-config.test.d.ts +2 -0
  50. package/dist/lib/static-config.test.d.ts.map +1 -0
  51. package/dist/lib/static-config.test.js +105 -0
  52. package/dist/lib/static-config.test.js.map +1 -0
  53. package/dist/lib/template-hook-boundary.test.node.d.ts +2 -0
  54. package/dist/lib/template-hook-boundary.test.node.d.ts.map +1 -0
  55. package/dist/lib/template-hook-boundary.test.node.js +30 -0
  56. package/dist/lib/template-hook-boundary.test.node.js.map +1 -0
  57. package/dist/lib/template-path.d.ts +5 -0
  58. package/dist/lib/template-path.d.ts.map +1 -0
  59. package/dist/lib/template-path.js +9 -0
  60. package/dist/lib/template-path.js.map +1 -0
  61. package/dist/lib/workspace-root.d.ts +28 -0
  62. package/dist/lib/workspace-root.d.ts.map +1 -0
  63. package/dist/lib/workspace-root.js +205 -0
  64. package/dist/lib/workspace-root.js.map +1 -0
  65. package/dist/manifest/deps.d.ts +1 -1
  66. package/dist/manifest/deps.d.ts.map +1 -1
  67. package/dist/manifest/deps.js +24 -8
  68. package/dist/manifest/deps.js.map +1 -1
  69. package/dist/phases/deps.d.ts +14 -1
  70. package/dist/phases/deps.d.ts.map +1 -1
  71. package/dist/phases/deps.js +312 -137
  72. package/dist/phases/deps.js.map +1 -1
  73. package/dist/phases/deps.test.d.ts +2 -0
  74. package/dist/phases/deps.test.d.ts.map +1 -0
  75. package/dist/phases/deps.test.js +343 -0
  76. package/dist/phases/deps.test.js.map +1 -0
  77. package/dist/phases/preflight.d.ts.map +1 -1
  78. package/dist/phases/preflight.js +59 -11
  79. package/dist/phases/preflight.js.map +1 -1
  80. package/dist/phases/prompts.d.ts +2 -0
  81. package/dist/phases/prompts.d.ts.map +1 -1
  82. package/dist/phases/prompts.js +51 -2
  83. package/dist/phases/prompts.js.map +1 -1
  84. package/dist/phases/routes-ui.test.d.ts +2 -0
  85. package/dist/phases/routes-ui.test.d.ts.map +1 -0
  86. package/dist/phases/routes-ui.test.js +766 -0
  87. package/dist/phases/routes-ui.test.js.map +1 -0
  88. package/dist/phases/routes.d.ts +4 -1
  89. package/dist/phases/routes.d.ts.map +1 -1
  90. package/dist/phases/routes.js +610 -122
  91. package/dist/phases/routes.js.map +1 -1
  92. package/dist/phases/scaffold-smoke.test.d.ts +2 -0
  93. package/dist/phases/scaffold-smoke.test.d.ts.map +1 -0
  94. package/dist/phases/scaffold-smoke.test.js +175 -0
  95. package/dist/phases/scaffold-smoke.test.js.map +1 -0
  96. package/dist/phases/scaffold.d.ts +3 -2
  97. package/dist/phases/scaffold.d.ts.map +1 -1
  98. package/dist/phases/scaffold.js +268 -91
  99. package/dist/phases/scaffold.js.map +1 -1
  100. package/dist/phases/scaffold.test.js +171 -22
  101. package/dist/phases/scaffold.test.js.map +1 -1
  102. package/dist/phases/ui.d.ts +4 -1
  103. package/dist/phases/ui.d.ts.map +1 -1
  104. package/dist/phases/ui.js +51 -59
  105. package/dist/phases/ui.js.map +1 -1
  106. package/dist/phases/wire/index.d.ts.map +1 -1
  107. package/dist/phases/wire/index.js +10 -6
  108. package/dist/phases/wire/index.js.map +1 -1
  109. package/dist/phases/wire/shared.d.ts +4 -1
  110. package/dist/phases/wire/shared.d.ts.map +1 -1
  111. package/dist/phases/wire/vite-config.d.ts.map +1 -1
  112. package/dist/phases/wire/vite-config.js +69 -20
  113. package/dist/phases/wire/vite-config.js.map +1 -1
  114. package/dist/phases/wire/vite-config.test.d.ts +2 -0
  115. package/dist/phases/wire/vite-config.test.d.ts.map +1 -0
  116. package/dist/phases/wire/vite-config.test.js +97 -0
  117. package/dist/phases/wire/vite-config.test.js.map +1 -0
  118. package/dist/prompts.d.ts +1 -0
  119. package/dist/prompts.d.ts.map +1 -1
  120. package/dist/prompts.js +7 -2
  121. package/dist/prompts.js.map +1 -1
  122. package/dist/runner.js +1 -1
  123. package/dist/runner.js.map +1 -1
  124. package/dist/templates/byline/collection-types.contract.ts +26 -1
  125. package/dist/templates/byline/collections/index.ts +1 -1
  126. package/dist/templates/byline/collections/server-hooks.ts +7 -0
  127. package/dist/templates/byline/generated/collection-types.ts +14 -6
  128. package/dist/templates/byline/routes.ts +7 -6
  129. package/dist/templates/byline/server.config.ts +10 -1
  130. package/dist/templates/byline-examples/admin.config.ts +7 -12
  131. package/dist/templates/byline-examples/blocks/photo-block.ts +3 -2
  132. package/dist/templates/byline-examples/collection-types.contract.ts +26 -1
  133. package/dist/templates/byline-examples/collections/docs/hooks.ts +23 -13
  134. package/dist/templates/byline-examples/collections/docs/schema.ts +2 -11
  135. package/dist/templates/byline-examples/collections/index.ts +1 -1
  136. package/dist/templates/byline-examples/collections/media/components/media-list-view.tsx +11 -6
  137. package/dist/templates/byline-examples/collections/media/schema.ts +3 -0
  138. package/dist/templates/byline-examples/collections/news/schema.ts +4 -3
  139. package/dist/templates/byline-examples/collections/server-hooks.ts +8 -0
  140. package/dist/templates/byline-examples/fields/ai-text.ts +1 -1
  141. package/dist/templates/byline-examples/fields/ai-textarea.ts +1 -1
  142. package/dist/templates/byline-examples/fields/lexical-richtext-ai.tsx +3 -3
  143. package/dist/templates/byline-examples/generated/collection-types.ts +138 -130
  144. package/dist/templates/byline-examples/routes.ts +7 -6
  145. package/dist/templates/byline-examples/scripts/backfill-version-locales.ts +1 -1
  146. package/dist/templates/byline-examples/scripts/import-docs.ts +10 -11
  147. package/dist/templates/byline-examples/scripts/lib/mdast-to-lexical.test.node.ts +93 -233
  148. package/dist/templates/byline-examples/scripts/lib/mdast-to-lexical.ts +2 -2
  149. package/dist/templates/byline-examples/scripts/lib/strip-leading-h1.test.node.ts +0 -4
  150. package/dist/templates/byline-examples/scripts/re-anchor.ts +3 -3
  151. package/dist/templates/byline-examples/scripts/regenerate-media-operation.ts +112 -0
  152. package/dist/templates/byline-examples/scripts/regenerate-media.ts +129 -92
  153. package/dist/templates/byline-examples/server.config.ts +14 -6
  154. package/dist/templates/host/vite.config.ts +2 -0
  155. package/dist/templates/routes/_byline/route.lazy.tsx +1 -2
  156. package/dist/templates/routes/_byline/route.tsx +1 -1
  157. package/dist/templates/ui-byline/blocks/photo-block/index.tsx +18 -22
  158. package/dist/templates/ui-byline/blocks/richtext-block/index.tsx +1 -1
  159. package/dist/templates/ui-byline/components/admonition/index.tsx +11 -8
  160. package/dist/templates/ui-byline/components/code/code.tsx +6 -4
  161. package/dist/templates/ui-byline/components/link/lang-link.tsx +4 -8
  162. package/dist/templates/ui-byline/components/link/link-field.tsx +3 -2
  163. package/dist/templates/ui-byline/components/link/link-lexical.tsx +9 -8
  164. package/dist/templates/ui-byline/render-blocks.tsx +38 -34
  165. package/dist/templates/ui-byline/types/content.ts +25 -0
  166. package/dist/templates/ui-byline/types/i18n.ts +4 -8
  167. package/dist/test-helpers.d.ts +5 -0
  168. package/dist/test-helpers.d.ts.map +1 -0
  169. package/dist/test-helpers.js +52 -0
  170. package/dist/test-helpers.js.map +1 -0
  171. package/dist/types.d.ts +21 -1
  172. package/dist/types.d.ts.map +1 -1
  173. package/dist/ui-template-contract.test.d.ts +2 -0
  174. package/dist/ui-template-contract.test.d.ts.map +1 -0
  175. package/dist/ui-template-contract.test.js +150 -0
  176. package/dist/ui-template-contract.test.js.map +1 -0
  177. package/dist/workspace-root.test.d.ts +2 -0
  178. package/dist/workspace-root.test.d.ts.map +1 -0
  179. package/dist/workspace-root.test.js +311 -0
  180. package/dist/workspace-root.test.js.map +1 -0
  181. package/package.json +9 -1
  182. package/dist/templates/byline/client.server.ts +0 -32
  183. package/dist/templates/byline-examples/client.server.ts +0 -32
@@ -23,21 +23,28 @@
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
25
25
  * a document update — but as a single "one-shot" Client SDK pass per
26
- * media item. Variants and the original written under fresh paths;
27
- * the previous original + variant files are deleted once the document
28
- * has been re-pointed at the new value.
26
+ * media item. Variants and the original are written under fresh paths.
27
+ * Previous files are retained because immutable historical versions — or an
28
+ * older published version while the current version is draft — may still
29
+ * reference them. Storage reclamation requires a separate reference-aware
30
+ * garbage collector.
29
31
  *
30
32
  * Bypass-MIME images (SVG / GIF) are passed through untouched — Sharp
31
33
  * does not produce variants for them, so the only effect is a new
32
34
  * storagePath on a re-stored copy.
33
35
  *
34
- * Status preservation: `updateDocument` always stamps new versions with
35
- * the workflow's default status (`getDefaultStatus`, i.e. the first
36
- * declared status — `'draft'` for media). After the update we step the
37
- * new version forward through `changeStatus` until it lands in the same
38
- * slot the original occupied. Skipping this leaves the previously-
39
- * published version (with the old variant set) as what
40
- * `current_published_documents` returns to public readers.
36
+ * Status preservation: `updateDocument` stamps new versions with the
37
+ * workflow's default status (`'draft'` for media). The script wraps that
38
+ * update and a direct restoration of the captured status in one database
39
+ * transaction. This is a deliberate maintenance-only use of adapter
40
+ * commands: an archived item is never temporarily published, and callers
41
+ * cannot observe the intermediate draft. Run without concurrent editorial
42
+ * writes to the media collection.
43
+ *
44
+ * Safety: the generated variant names must exactly match the current schema
45
+ * before the document is changed. A failed upload/update is compensated by
46
+ * deleting the newly-written files, failures are collected per document, and
47
+ * the process exits non-zero after attempting the remaining items.
41
48
  *
42
49
  * Currently requires the local storage provider — pulls bytes back via
43
50
  * the provider's `uploadDir`. Variant generation itself is provider-
@@ -53,17 +60,22 @@ import fs from 'node:fs'
53
60
  import path from 'node:path'
54
61
 
55
62
  import { createSuperAdminContext } from '@byline/auth'
56
- import { type CollectionHandle, createBylineClient } from '@byline/client'
63
+ import { createBylineClient } from '@byline/client'
57
64
  import {
58
65
  type FieldUploadContext,
59
66
  getCollectionDefinition,
60
67
  getServerConfig,
61
- getWorkflowStatuses,
62
- type StoredFileValue,
63
68
  } from '@byline/core'
64
69
  import { extractImageMeta, generateImageVariants, isBypassMimeType } from '@byline/core/image'
65
70
  import { uploadField as coreUploadField } from '@byline/core/services'
66
71
 
72
+ import type { MediaFields } from '@byline/generated-types'
73
+ import {
74
+ assertCompleteVariantSet,
75
+ replaceMediaVersionPreservingStatus,
76
+ storedFilePaths,
77
+ } from './regenerate-media-operation.js'
78
+
67
79
  const COLLECTION_PATH = 'media'
68
80
  const FIELD_NAME = 'image'
69
81
 
@@ -102,13 +114,13 @@ async function run(): Promise<void> {
102
114
  // Snapshot the full set up-front. Each update bumps `updated_at` and
103
115
  // reorders the default sort, so paging through a moving window would
104
116
  // either skip or re-visit rows. Capture `status` so we can restore
105
- // each doc to its original workflow slot after the regenerated draft
106
- // is written (see `restoreStatus` below).
117
+ // each doc to its original workflow slot inside the replacement
118
+ // transaction.
107
119
  const allDocs: {
108
120
  id: string
109
121
  path: string
110
122
  status: string
111
- fields: Record<string, any>
123
+ fields: MediaFields
112
124
  }[] = []
113
125
  const pageSize = 100
114
126
  for (let page = 1; ; page++) {
@@ -123,7 +135,7 @@ async function run(): Promise<void> {
123
135
  id: d.id,
124
136
  path: d.path,
125
137
  status: d.status,
126
- fields: d.fields as Record<string, any>,
138
+ fields: d.fields,
127
139
  })
128
140
  }
129
141
  if (result.docs.length < pageSize) break
@@ -150,113 +162,138 @@ async function run(): Promise<void> {
150
162
  },
151
163
  }
152
164
 
165
+ const imageField = definition.fields.find((field) => field.name === FIELD_NAME)
166
+ if (imageField == null || (imageField.type !== 'image' && imageField.type !== 'file')) {
167
+ throw new Error(
168
+ `regenerate-media: '${COLLECTION_PATH}.${FIELD_NAME}' is not an image/file field.`
169
+ )
170
+ }
171
+ if (imageField.upload == null) {
172
+ throw new Error(
173
+ `regenerate-media: '${COLLECTION_PATH}.${FIELD_NAME}' has no upload configuration.`
174
+ )
175
+ }
176
+
153
177
  let processed = 0
154
178
  let skipped = 0
179
+ const failures: Error[] = []
155
180
 
156
181
  for (const doc of allDocs) {
157
- const image = doc.fields[FIELD_NAME] as StoredFileValue | undefined | null
182
+ const image = doc.fields[FIELD_NAME]
158
183
  if (image == null || !image.storagePath) {
159
184
  console.log(` - skip ${doc.id} (${doc.path}) — no image value`)
160
185
  skipped += 1
161
186
  continue
162
187
  }
163
188
 
189
+ const oldPaths = storedFilePaths(image)
164
190
  const sourceAbsolutePath = path.join(uploadDir, image.storagePath)
165
191
  let buffer: Buffer
166
192
  try {
167
193
  buffer = await fs.promises.readFile(sourceAbsolutePath)
168
194
  } catch (err) {
169
195
  console.error(
170
- ` ! skip ${doc.id} (${doc.path}) — failed to read original at ${sourceAbsolutePath}:`,
196
+ ` ! fail ${doc.id} (${doc.path}) — failed to read original at ${sourceAbsolutePath}:`,
171
197
  err
172
198
  )
173
- skipped += 1
199
+ failures.push(asDocumentError(doc.id, doc.path, err))
174
200
  continue
175
201
  }
176
202
 
177
- const uploadResult = await coreUploadField(
178
- { ...baseUploadCtx, requestContext },
179
- {
180
- buffer,
181
- originalFilename: image.originalFilename || image.filename,
182
- mimeType: image.mimeType,
183
- fileSize: buffer.byteLength,
184
- shouldCreateDocument: false,
185
- }
186
- )
187
-
188
- const nextFields = { ...doc.fields, [FIELD_NAME]: uploadResult.storedFile }
189
- await handle.update(doc.id, nextFields)
203
+ let freshPaths = new Set<string>()
204
+ try {
205
+ const uploadResult = await coreUploadField(
206
+ { ...baseUploadCtx, requestContext },
207
+ {
208
+ buffer,
209
+ originalFilename: image.originalFilename || image.filename,
210
+ mimeType: image.mimeType,
211
+ fileSize: buffer.byteLength,
212
+ shouldCreateDocument: false,
213
+ }
214
+ )
215
+ freshPaths = storedFilePaths(uploadResult.storedFile)
190
216
 
191
- // `updateDocument` always stamps the new version with the workflow's
192
- // default status (the first key — 'draft' for media), so without this
193
- // step the public read path keeps returning the prior published
194
- // version with the old variant set. Walk the new version forward to
195
- // the slot the doc originally occupied.
196
- await restoreStatus(handle, definition, doc.id, doc.status)
217
+ const expectedVariantNames = isBypassMimeType(image.mimeType)
218
+ ? []
219
+ : (imageField.upload.sizes ?? []).map((size) => size.name)
220
+ assertCompleteVariantSet(uploadResult.storedFile, expectedVariantNames)
197
221
 
198
- // Best-effort orphan cleanup. Failures are non-fatal — the new
199
- // value is already persisted, the doc is consistent, the unused
200
- // bytes are just dead weight on disk.
201
- const orphans: string[] = [
202
- image.storagePath,
203
- ...(image.variants ?? []).map((v) => v.storagePath).filter((p): p is string => Boolean(p)),
204
- ]
205
- for (const orphan of orphans) {
206
- try {
207
- await storage.delete(orphan)
208
- } catch (err) {
209
- console.warn(` ! failed to delete orphan ${orphan}:`, err)
222
+ const collisions = [...freshPaths].filter((storagePath) => oldPaths.has(storagePath))
223
+ if (collisions.length > 0) {
224
+ throw new Error(
225
+ `regenerate-media: upload hooks reused existing storage path(s): ${collisions.join(', ')}. ` +
226
+ 'Fresh paths are required for an atomic replacement.'
227
+ )
210
228
  }
211
- }
212
229
 
213
- const variantSummary =
214
- uploadResult.storedFile.variants?.map((v) => `${v.name}:${v.format ?? '?'}`).join(', ') ??
215
- '(none)'
216
- console.log(
217
- ` - ${doc.id} (${doc.path}) → ${uploadResult.storedFile.storagePath} [${variantSummary}]`
218
- )
219
- processed += 1
230
+ const nextFields = { ...doc.fields, [FIELD_NAME]: uploadResult.storedFile }
231
+ await replaceMediaVersionPreservingStatus({
232
+ db: config.db,
233
+ definition,
234
+ collectionId,
235
+ handle,
236
+ documentId: doc.id,
237
+ fields: nextFields,
238
+ targetStatus: doc.status,
239
+ })
240
+
241
+ const variantSummary =
242
+ uploadResult.storedFile.variants?.map((v) => `${v.name}:${v.format ?? '?'}`).join(', ') ??
243
+ '(none)'
244
+ console.log(
245
+ ` - ${doc.id} (${doc.path}) → ${uploadResult.storedFile.storagePath} [${variantSummary}]`
246
+ )
247
+ processed += 1
248
+ } catch (err) {
249
+ // The document transaction did not commit. Remove every fresh path we
250
+ // know about, but never delete a path that belonged to the old value — a
251
+ // deterministic beforeStore hook may already have overwritten it.
252
+ const rollbackPaths = new Set([...freshPaths].filter((storagePath) => !oldPaths.has(storagePath)))
253
+ const cleanupErrors = await deletePathsBestEffort(storage, rollbackPaths)
254
+ const failure = asDocumentError(doc.id, doc.path, err)
255
+ failures.push(
256
+ cleanupErrors.length === 0
257
+ ? failure
258
+ : new AggregateError(
259
+ [failure, ...cleanupErrors],
260
+ `regenerate-media: operation and rollback cleanup failed for '${doc.path}'`
261
+ )
262
+ )
263
+ console.error(` ! fail ${doc.id} (${doc.path}):`, err)
264
+ }
220
265
  }
221
266
 
222
267
  console.log(
223
- `regenerate-media: done. processed=${processed}, skipped=${skipped}, total=${allDocs.length}.`
268
+ `regenerate-media: done. processed=${processed}, skipped=${skipped}, ` +
269
+ `failed=${failures.length}, total=${allDocs.length}.`
224
270
  )
271
+ if (failures.length > 0) {
272
+ throw new AggregateError(failures, `regenerate-media: ${failures.length} document(s) failed`)
273
+ }
225
274
  }
226
275
 
227
- /**
228
- * Walk the workflow ladder forward from the new version's status (the
229
- * workflow's first slot, set by `updateDocument`) to `targetStatus`.
230
- *
231
- * `validateStatusTransition` allows ±1-step moves and reset-to-first, so
232
- * jumping straight from 'draft' to 'archived' is rejected — we step
233
- * through 'published' on the way. A target equal to the current status
234
- * is a no-op (transition validation accepts identical-status moves but
235
- * we skip the work).
236
- *
237
- * Throws if `targetStatus` is not declared on the collection's workflow,
238
- * since that signals a stale snapshot or a workflow definition that has
239
- * been edited since the docs were created.
240
- */
241
- async function restoreStatus(
242
- handle: CollectionHandle,
243
- definition: Parameters<typeof getWorkflowStatuses>[0],
244
- documentId: string,
245
- targetStatus: string
246
- ): Promise<void> {
247
- const statuses = getWorkflowStatuses(definition).map((s) => s.name)
248
- const targetIndex = statuses.indexOf(targetStatus)
249
- if (targetIndex === -1) {
250
- throw new Error(
251
- `restoreStatus: status '${targetStatus}' is not declared on collection ` +
252
- `'${definition.path}' (declared: ${statuses.join(', ')}).`
253
- )
254
- }
255
- // The new version is always at index 0 (workflow default). Anything at
256
- // index > 0 needs that many forward transitions.
257
- for (let i = 1; i <= targetIndex; i++) {
258
- await handle.changeStatus(documentId, statuses[i] as string)
276
+ async function deletePathsBestEffort(
277
+ storage: NonNullable<ReturnType<typeof getServerConfig>['storage']>,
278
+ storagePaths: Iterable<string>
279
+ ): Promise<Error[]> {
280
+ const errors: Error[] = []
281
+ for (const storagePath of storagePaths) {
282
+ try {
283
+ await storage.delete(storagePath)
284
+ } catch (err) {
285
+ const error = err instanceof Error ? err : new Error(String(err))
286
+ errors.push(error)
287
+ console.warn(` ! failed to delete ${storagePath}:`, error)
288
+ }
259
289
  }
290
+ return errors
291
+ }
292
+
293
+ function asDocumentError(documentId: string, documentPath: string, err: unknown): Error {
294
+ return new Error(`regenerate-media: '${documentPath}' (${documentId}) failed`, {
295
+ cause: err,
296
+ })
260
297
  }
261
298
 
262
299
  run()
@@ -28,8 +28,10 @@ import {
28
28
  import { migrate, postgresSearch } from '@byline/search-postgres'
29
29
  import { localStorageProvider } from '@byline/storage-local'
30
30
 
31
- import { getAdminBylineClient } from './client.server.js'
31
+ import { getAdminBylineClient } from '@byline/client/server'
32
+ import { registerTanstackStartHostBridge } from '@byline/host-tanstack-start/integrations/host-bridge'
32
33
  import { collections } from './collections/index.js'
34
+ import { serverHooks } from './collections/server-hooks.js'
33
35
  import { i18n } from './i18n.js'
34
36
  import { DEFAULT_SERVER_URL, routes } from './routes.js'
35
37
 
@@ -134,11 +136,18 @@ async function buildBylineCore(): Promise<BylineCore<AdminStore>> {
134
136
  signingSecret,
135
137
  })
136
138
 
139
+ // Register the TanStack Start `HostRequestBridge` before any
140
+ // request-bound client getter (`@byline/client/server`) can run —
141
+ // structural at server boot rather than dependent on which host
142
+ // module happens to load first.
143
+ registerTanstackStartHostBridge()
144
+
137
145
  const core = await initBylineCore<AdminStore>({
138
146
  serverURL,
139
147
  i18n,
140
148
  routes,
141
149
  collections,
150
+ hooks: serverHooks,
142
151
  db,
143
152
  adminStore,
144
153
  // Site-wide default storage provider — used by any upload collection
@@ -163,8 +172,7 @@ async function buildBylineCore(): Promise<BylineCore<AdminStore>> {
163
172
  }),
164
173
  // S3-compatible alternative (AWS S3 / Cloudflare R2 / MinIO). Replace
165
174
  // the `localStorageProvider` block above with the call below and add
166
- // the corresponding `BYLINE_STORAGE_S3_*` entries to your `.env.local`
167
- // (see `apps/webapp/.env.local.example`).
175
+ // the corresponding `BYLINE_STORAGE_S3_*` entries to your `.env.local`.
168
176
  //
169
177
  // On AWS with an IAM role / instance profile, omit `accessKeyId` and
170
178
  // `secretAccessKey` so the SDK resolves credentials via its default
@@ -204,11 +212,11 @@ async function buildBylineCore(): Promise<BylineCore<AdminStore>> {
204
212
  //
205
213
  // We pass `getAdminBylineClient` (not the public client) because
206
214
  // admin server fns are the only call sites that read documents in
207
- // the admin webapp today — the populate phase inherits whichever
215
+ // the admin host today — the populate phase inherits whichever
208
216
  // actor the request resolved. A future public-facing host would
209
217
  // register its own client whose `requestContext` factory resolves
210
- // an end-user actor instead. See `@byline/host-tanstack-start/
211
- // integrations/byline-client.ts` for how the admin client is built.
218
+ // an end-user actor instead. See `packages/client/src/server/
219
+ // clients.ts` (`@byline/client/server`) for how the admin client is built.
212
220
  //
213
221
  // Why a getter, not a value: `getAdminBylineClient()` reads the
214
222
  // server config singleton, which is only populated *after*
@@ -1,4 +1,5 @@
1
1
  import { devtools } from '@tanstack/devtools-vite'
2
+ import { bylineClientHookBoundary } from '@byline/host-tanstack-start/vite'
2
3
  import { tanstackStart } from '@tanstack/react-start/plugin/vite'
3
4
 
4
5
  import tailwindcss from '@tailwindcss/vite'
@@ -158,6 +159,7 @@ const config = defineConfig({
158
159
  },
159
160
  plugins: [
160
161
  browserAsyncHooksAlias(),
162
+ bylineClientHookBoundary(),
161
163
  devtools(),
162
164
  nitro({
163
165
  preset: 'node',
@@ -24,8 +24,7 @@
24
24
  * Both call `defineClientConfig` idempotently.
25
25
  *
26
26
  * If you also want to use the Byline UI components on your public site,
27
- * import the same stylesheets from your front-end's pathless layout
28
- * instead (e.g. `_front-end/route.tsx`).
27
+ * import the same stylesheets from your public layout route as well.
29
28
  */
30
29
 
31
30
  import { createLazyFileRoute, Outlet } from '@tanstack/react-router'
@@ -9,7 +9,7 @@
9
9
  /**
10
10
  * Pathless route definition for everything Byline-rendered (the admin
11
11
  * shell and the sign-in page). The `_byline` prefix doesn't contribute
12
- * to URL paths — `/_byline/admin/...` resolves to `/admin/...` and
12
+ * to URL paths — by default, `/_byline/admin/...` resolves to `/admin/...` and
13
13
  * `/_byline/sign-in` resolves to `/sign-in`.
14
14
  *
15
15
  * This file is intentionally light. The router needs the route definition
@@ -5,15 +5,14 @@ import type React from 'react'
5
5
  import { Container, FadeInLift } from '@byline/ui/react'
6
6
  import cx from 'classnames'
7
7
 
8
- import type { MediaFields, PhotoBlockData } from '~/generated/collection-types.js'
9
-
10
8
  import { ResponsiveImage } from '@/ui/byline/components/responsive-image'
11
9
  import { LexicalRichText } from '@/ui/byline/components/richtext-lexical'
10
+ import type { PopulatedPhotoBlockData } from '@/ui/byline/types/content'
12
11
  import type { Locale } from '@/ui/byline/types/i18n'
13
12
 
14
13
  interface Props {
15
14
  id: string
16
- block: PhotoBlockData
15
+ block: PopulatedPhotoBlockData
17
16
  lng: Locale
18
17
  constrainedLayout?: boolean
19
18
  className?: string
@@ -28,27 +27,26 @@ export function PhotoBlock({
28
27
  }: Props): React.JSX.Element | null {
29
28
  const { photo, alt, caption, display } = block
30
29
 
31
- // The `photo` relation must be populated (`populate: { photo: '*' }`) for
32
- // the front-end to render an image. When populated, the value is a
33
- // `PopulatedRelationValue` envelope — `{ ...rel, _resolved: true,
34
- // document: <MediaFields> }`. Bail out if the relation is missing or
35
- // unpopulated.
36
- if (photo == null || (photo as { _resolved?: boolean })._resolved !== true) {
37
- return null
38
- }
39
- const media = (photo as unknown as { document: MediaFields }).document
30
+ // Missing, unresolved, and cycle-suppressed relations have no document.
31
+ const media = photo?.document?.fields
32
+ if (media == null) return null
40
33
 
41
34
  const Comp = display === 'full_width' ? 'div' : Container
42
35
 
43
- // TODO: caption is a Lexical richText field — type properly once the
44
- // Lexical node shape is modelled in @byline/core.
45
- const captionDoc = caption as Record<string, any> | undefined
36
+ const captionRoot =
37
+ caption != null && !Array.isArray(caption) && typeof caption === 'object'
38
+ ? caption.root
39
+ : undefined
40
+ const captionChildren =
41
+ captionRoot != null && !Array.isArray(captionRoot) && typeof captionRoot === 'object'
42
+ ? captionRoot.children
43
+ : undefined
46
44
 
47
45
  return (
48
46
  <Comp
49
47
  id={id}
50
48
  className={cx(
51
- 'px-0 pt-4',
49
+ 'px-0',
52
50
  {
53
51
  'lg:max-w-[920px] xl:max-w-[920px] 2xl:max-w-[920px] mx-auto': display === 'default',
54
52
  },
@@ -64,12 +62,10 @@ export function PhotoBlock({
64
62
  className="photo-block"
65
63
  imgClassName="photo-block--photo"
66
64
  />
67
- {captionDoc != null && (
68
- <Container
69
- className={cx('bg-white dark:bg-canvas-900 py-2', { 'px-0': constrainedLayout })}
70
- >
71
- <div className="photo-block--caption">
72
- <LexicalRichText lng={lng} nodes={captionDoc?.root?.children} />
65
+ {captionChildren != null && (
66
+ <Container className={cx('py-2', { 'px-0': constrainedLayout })}>
67
+ <div className="photo-block--caption [&_p]:m-0 muted text-[1rem]">
68
+ <LexicalRichText lng={lng} nodes={captionChildren} />
73
69
  </div>
74
70
  </Container>
75
71
  )}
@@ -3,7 +3,7 @@
3
3
  import { Container } from '@byline/ui/react'
4
4
  import cx from 'classnames'
5
5
 
6
- import type { RichTextBlockData } from '~/generated/collection-types.js'
6
+ import type { RichTextBlockData } from '@byline/generated-types'
7
7
 
8
8
  import { LexicalRichText } from '@/ui/byline/components/richtext-lexical'
9
9
  import type { Locale } from '@/ui/byline/types/i18n'
@@ -1,4 +1,4 @@
1
- 'use clients'
1
+ 'use client'
2
2
 
3
3
  import type React from 'react'
4
4
 
@@ -28,13 +28,16 @@ export function AdmonitionSerializer({
28
28
  options: SerializeOptions
29
29
  }): React.JSX.Element {
30
30
  const intent = intentMap[node.admonitionType as keyof typeof intentMap]
31
+ // Admonitions are ElementNodes, so their body is stored directly in children.
31
32
  return (
32
- <Alert title={node.title} intent={intent} close={false} className="rounded-none">
33
- {node?.content?.editorState?.root?.children != null ? (
34
- serialize({ nodes: node?.content?.editorState?.root?.children, lng, options })
35
- ) : (
36
- <span>Content not found for admonition.</span>
37
- )}
38
- </Alert>
33
+ <div className="not-prose pt-6 pb-8">
34
+ <Alert title={node.title} intent={intent} close={false} className="not-prose my-0">
35
+ {node.children != null ? (
36
+ serialize({ nodes: node.children, lng, options })
37
+ ) : (
38
+ <span>Content not found for admonition.</span>
39
+ )}
40
+ </Alert>
41
+ </div>
39
42
  )
40
43
  }
@@ -10,14 +10,16 @@ interface CodeProps extends CodeIntrinsicProps {
10
10
  className?: string
11
11
  title?: string
12
12
  code: string
13
- language?: string
13
+ language?: string | null
14
14
  }
15
15
 
16
- export function Code({ code, className, language = 'jsx' }: CodeProps): React.JSX.Element {
16
+ export function Code({ code, className, language }: CodeProps): React.JSX.Element {
17
+ // Prism calls `.toLowerCase()` on this value, so null and empty legacy values need a fallback.
18
+ const resolvedLanguage = language != null && language.length > 0 ? language : 'typescript'
17
19
  return (
18
- <Highlight theme={themes.oneDark} code={code} language={language}>
20
+ <Highlight theme={themes.oneDark} code={code} language={resolvedLanguage}>
19
21
  {({ tokens, getLineProps, getTokenProps }) => (
20
- <div className="code scroller group overflow-y-auto rounded border border-theme-600 relative">
22
+ <div className="code scroller group overflow-y-auto rounded border border-theme-600 relative mb-4">
21
23
  <CopyButton
22
24
  variant="outlined"
23
25
  intent="primary"
@@ -1,17 +1,13 @@
1
1
  /**
2
2
  * Locale-aware Link STUB.
3
3
  *
4
- * The reference webapp ships a real `LangLink` that injects an optional
5
- * `{-$lng}` path parameter onto TanStack Router's `<Link>` so that the
6
- * current locale survives client-side navigation. This stub is a thin
7
- * pass-through — it accepts the `lng` prop but does NOT prefix URLs.
4
+ * This stub is a thin pass-through: it accepts the `lng` prop but does not
5
+ * prefix URLs. A localized app can replace the body with a wrapper that
6
+ * injects its locale path parameter into TanStack Router's `<Link>`.
8
7
  *
9
8
  * If your app is single-locale, this is fine as-is.
10
9
  *
11
- * If you have a multi-locale strategy, replace the body with your own
12
- * Link wrapper. The reference implementation lives at
13
- * apps/webapp/src/i18n/components/lang-link.tsx
14
- * in https://github.com/Byline-CMS/bylinecms.dev.
10
+ * If you have a multi-locale strategy, replace the body with your own Link wrapper.
15
11
  */
16
12
 
17
13
  import type React from 'react'
@@ -84,14 +84,15 @@ function getHref(args: LinkFieldAttributes): string {
84
84
  }
85
85
 
86
86
  const hrefIsLocal = ['tel:', 'mailto:', '/'].some((prefix) => href.startsWith(prefix))
87
- if (!hrefIsLocal) {
87
+ // Relative custom hrefs are valid but cannot be passed to URL without a base.
88
+ if (!hrefIsLocal && /^[a-z][a-z\d+.-]*:/i.test(href)) {
88
89
  try {
89
90
  const objectURL = new URL(href)
90
91
  if (objectURL.origin === publicWebsiteUrl) {
91
92
  href = objectURL.href.replace(publicWebsiteUrl, '')
92
93
  }
93
94
  } catch (e) {
94
- console.error(`Failed to format url: ${href}`, e) // eslint-disable-line no-console
95
+ console.error(`Failed to format url: ${href}`, e)
95
96
  }
96
97
  }
97
98
 
@@ -7,8 +7,6 @@ import cx from 'classnames'
7
7
  import { LangLink } from '@/ui/byline/components/link/lang-link'
8
8
  import type { Locale } from '@/ui/byline/types/i18n'
9
9
 
10
- // import { getPublicWebsiteUrl } from '@/utils/utils.framework.ts'
11
-
12
10
  interface BaseLinkAttributes {
13
11
  newTab?: boolean
14
12
  nofollow?: boolean
@@ -134,13 +132,16 @@ function getHref(args: LinkAttributes): string {
134
132
  }
135
133
 
136
134
  if (!isLocalHref(href)) {
137
- try {
138
- const objectURL = new URL(href)
139
- if (objectURL.origin === publicWebsiteUrl) {
140
- href = objectURL.href.replace(publicWebsiteUrl, '')
135
+ // Relative custom hrefs are valid but cannot be passed to URL without a base.
136
+ if (/^[a-z][a-z\d+.-]*:/i.test(href)) {
137
+ try {
138
+ const objectURL = new URL(href)
139
+ if (objectURL.origin === publicWebsiteUrl) {
140
+ href = objectURL.href.replace(publicWebsiteUrl, '')
141
+ }
142
+ } catch (e) {
143
+ console.error(`Failed to format url: ${href}`, e)
141
144
  }
142
- } catch (e) {
143
- console.error(`Failed to format url: ${href}`, e) // eslint-disable-line no-console
144
145
  }
145
146
  }
146
147