@byline/cli 3.21.0 → 4.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 (179) 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 +18 -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 +168 -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/clients.server.ts +77 -0
  125. package/dist/templates/byline/collection-types.contract.ts +25 -0
  126. package/dist/templates/byline/collections/server-hooks.ts +7 -0
  127. package/dist/templates/byline/routes.ts +7 -6
  128. package/dist/templates/byline/server.config.ts +3 -1
  129. package/dist/templates/byline-examples/admin.config.ts +7 -12
  130. package/dist/templates/byline-examples/blocks/photo-block.ts +3 -2
  131. package/dist/templates/byline-examples/clients.server.ts +77 -0
  132. package/dist/templates/byline-examples/collection-types.contract.ts +25 -0
  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/media/components/media-list-view.tsx +11 -6
  136. package/dist/templates/byline-examples/collections/media/schema.ts +3 -0
  137. package/dist/templates/byline-examples/collections/news/schema.ts +4 -3
  138. package/dist/templates/byline-examples/collections/server-hooks.ts +8 -0
  139. package/dist/templates/byline-examples/fields/ai-text.ts +1 -1
  140. package/dist/templates/byline-examples/fields/ai-textarea.ts +1 -1
  141. package/dist/templates/byline-examples/fields/lexical-richtext-ai.tsx +3 -3
  142. package/dist/templates/byline-examples/routes.ts +7 -6
  143. package/dist/templates/byline-examples/scripts/backfill-version-locales.ts +1 -1
  144. package/dist/templates/byline-examples/scripts/import-docs.ts +11 -12
  145. package/dist/templates/byline-examples/scripts/lib/mdast-to-lexical.test.node.ts +93 -233
  146. package/dist/templates/byline-examples/scripts/lib/mdast-to-lexical.ts +2 -2
  147. package/dist/templates/byline-examples/scripts/lib/strip-leading-h1.test.node.ts +0 -4
  148. package/dist/templates/byline-examples/scripts/re-anchor.ts +3 -3
  149. package/dist/templates/byline-examples/scripts/regenerate-media.ts +6 -5
  150. package/dist/templates/byline-examples/server.config.ts +5 -4
  151. package/dist/templates/host/vite.config.ts +2 -0
  152. package/dist/templates/routes/_byline/route.lazy.tsx +1 -2
  153. package/dist/templates/routes/_byline/route.tsx +1 -1
  154. package/dist/templates/ui-byline/blocks/photo-block/index.tsx +18 -22
  155. package/dist/templates/ui-byline/components/admonition/index.tsx +11 -8
  156. package/dist/templates/ui-byline/components/code/code.tsx +6 -4
  157. package/dist/templates/ui-byline/components/link/lang-link.tsx +4 -8
  158. package/dist/templates/ui-byline/components/link/link-field.tsx +3 -2
  159. package/dist/templates/ui-byline/components/link/link-lexical.tsx +9 -8
  160. package/dist/templates/ui-byline/render-blocks.tsx +38 -34
  161. package/dist/templates/ui-byline/types/content.ts +25 -0
  162. package/dist/templates/ui-byline/types/i18n.ts +4 -8
  163. package/dist/test-helpers.d.ts +5 -0
  164. package/dist/test-helpers.d.ts.map +1 -0
  165. package/dist/test-helpers.js +52 -0
  166. package/dist/test-helpers.js.map +1 -0
  167. package/dist/types.d.ts +21 -1
  168. package/dist/types.d.ts.map +1 -1
  169. package/dist/ui-template-contract.test.d.ts +2 -0
  170. package/dist/ui-template-contract.test.d.ts.map +1 -0
  171. package/dist/ui-template-contract.test.js +130 -0
  172. package/dist/ui-template-contract.test.js.map +1 -0
  173. package/dist/workspace-root.test.d.ts +2 -0
  174. package/dist/workspace-root.test.d.ts.map +1 -0
  175. package/dist/workspace-root.test.js +307 -0
  176. package/dist/workspace-root.test.js.map +1 -0
  177. package/package.json +9 -1
  178. package/dist/templates/byline/client.server.ts +0 -32
  179. package/dist/templates/byline-examples/client.server.ts +0 -32
@@ -12,11 +12,6 @@
12
12
  // pnpm tsx byline/scripts/import-docs.ts ../../docs/**/*.md --verbose
13
13
  // pnpm tsx byline/scripts/import-docs.ts '../../docs/*.md' --dry-run --verbose
14
14
  // pnpm tsx byline/scripts/import-docs.ts ../../docs/**/*.md
15
- //
16
- // Run tests...
17
- //
18
- // pnpm vitest run --mode=node byline/scripts/lib/
19
-
20
15
  /**
21
16
  * Import markdown files into the `docs` collection.
22
17
  *
@@ -59,7 +54,7 @@ import { glob } from 'node:fs/promises'
59
54
  import { basename, dirname, join, resolve } from 'node:path'
60
55
 
61
56
  import { createSuperAdminContext } from '@byline/auth'
62
- import { type CollectionHandle, createBylineClient } from '@byline/client'
57
+ import { type BylineClient, type CollectionHandle, createBylineClient } from '@byline/client'
63
58
  import { getCollectionDefinition, getServerConfig, slugify } from '@byline/core'
64
59
 
65
60
  import { type DocFrontmatter, parseDocFile } from './lib/frontmatter.js'
@@ -67,12 +62,16 @@ import { type MdastToLexicalWarning, mdastToLexical } from './lib/mdast-to-lexic
67
62
  import { parseBodyToMdast } from './lib/parse-markdown.js'
68
63
  import { type DocLinkRewriteWarning, rewriteDocLinks } from './lib/rewrite-doc-links.js'
69
64
  import { stripLeadingH1IfMatches } from './lib/strip-leading-h1.js'
65
+ import type { CollectionFieldsByPath, DocsFields } from '../generated/collection-types.js'
70
66
 
71
67
  const DOCS_COLLECTION = 'docs'
72
68
  const MEDIA_COLLECTION = 'media'
73
69
  const DOCS_URL_PREFIX = '/docs'
74
70
  const DEFAULT_IMPORT_STATUS = 'published'
75
71
 
72
+ type AppBylineClient = BylineClient<CollectionFieldsByPath>
73
+ type DocsHandle = CollectionHandle<DocsFields>
74
+
76
75
  interface Flags {
77
76
  dryRun: boolean
78
77
  verbose: boolean
@@ -135,7 +134,7 @@ interface ResolvedFeatureImage {
135
134
  }
136
135
 
137
136
  async function resolveFeatureImage(
138
- client: ReturnType<typeof createBylineClient>,
137
+ client: AppBylineClient,
139
138
  path: string
140
139
  ): Promise<ResolvedFeatureImage | null> {
141
140
  const mediaCollectionId = await client.resolveCollectionId(MEDIA_COLLECTION)
@@ -186,7 +185,7 @@ function derivePath(frontmatter: DocFrontmatter, locale: string): string {
186
185
  * workflow doesn't include the target or when already at/past it.
187
186
  */
188
187
  async function walkToStatus(
189
- handle: CollectionHandle,
188
+ handle: DocsHandle,
190
189
  documentId: string,
191
190
  workflowStatuses: readonly { name: string }[],
192
191
  currentStatus: string,
@@ -230,8 +229,8 @@ function buildSourcePathMap(files: string[], defaultLocale: string): Map<string,
230
229
 
231
230
  async function processFile(
232
231
  filePath: string,
233
- client: ReturnType<typeof createBylineClient>,
234
- handle: CollectionHandle,
232
+ client: AppBylineClient,
233
+ handle: DocsHandle,
235
234
  flags: Flags,
236
235
  pathMap: Map<string, string>
237
236
  ): Promise<ProcessResult> {
@@ -340,7 +339,7 @@ function parentIndexFile(filePath: string): string {
340
339
  * processed in sorted order, so prefix order carries straight through.
341
340
  */
342
341
  async function placeTreeFromDirectories(
343
- handle: CollectionHandle,
342
+ handle: DocsHandle,
344
343
  results: ProcessResult[]
345
344
  ): Promise<void> {
346
345
  const placeable = results.filter(
@@ -392,7 +391,7 @@ async function run(): Promise<void> {
392
391
 
393
392
  const config = getServerConfig()
394
393
  const requestContext = createSuperAdminContext({ id: 'import-docs-script' })
395
- const client = createBylineClient({ config, requestContext })
394
+ const client = createBylineClient<CollectionFieldsByPath>({ config, requestContext })
396
395
  const handle = client.collection(DOCS_COLLECTION)
397
396
 
398
397
  // Pre-pass: map each source file to the path its imported doc will
@@ -24,312 +24,172 @@ describe('mdastToLexical', () => {
24
24
  expect(state.root.children[0]).toMatchObject({ type: 'paragraph', children: [] })
25
25
  })
26
26
 
27
- test('paragraph with plain text', () => {
28
- const { state } = convert('hello world')
29
- expect(state.root.children[0]).toMatchObject({
27
+ test('maps plain paragraphs and every heading depth', () => {
28
+ const paragraph = convert('hello world').state.root.children[0]
29
+ expect(paragraph).toMatchObject({
30
30
  type: 'paragraph',
31
31
  children: [{ type: 'text', text: 'hello world', format: 0 }],
32
32
  })
33
+ const headings = convert('# h1\n\n## h2\n\n###### h6').state.root.children
34
+ expect(headings.map((node) => (node as { tag: string }).tag)).toEqual(['h1', 'h2', 'h6'])
33
35
  })
34
36
 
35
- test('heading depth maps to tag h1-h6', () => {
36
- const { state } = convert('# h1\n\n## h2\n\n###### h6')
37
- expect(state.root.children).toHaveLength(3)
37
+ test('maps headings and composed inline formatting', () => {
38
+ const { state } = convert('# heading\n\n***both*** and `code`')
38
39
  expect(state.root.children[0]).toMatchObject({ type: 'heading', tag: 'h1' })
39
- expect(state.root.children[1]).toMatchObject({ type: 'heading', tag: 'h2' })
40
- expect(state.root.children[2]).toMatchObject({ type: 'heading', tag: 'h6' })
41
- })
42
-
43
- test('bold + italic compose into a single text node with bitmask format=3', () => {
44
- const { state } = convert('***both***')
45
- const paragraph = state.root.children[0] as unknown as {
40
+ const paragraph = state.root.children[1] as unknown as {
46
41
  children: Array<{ format: number; text: string }>
47
42
  }
48
- expect(paragraph.children).toHaveLength(1)
49
- expect(paragraph.children[0]).toMatchObject({ text: 'both', format: 1 | 2 })
50
- })
51
-
52
- test('inline code carries the IS_CODE bit', () => {
53
- const { state } = convert('a `b` c')
54
- const paragraph = state.root.children[0] as unknown as {
55
- children: Array<{ format: number; text: string }>
56
- }
57
- // 'a ', code 'b', ' c'
58
- expect(paragraph.children.map((c) => ({ text: c.text, format: c.format }))).toEqual([
59
- { text: 'a ', format: 0 },
60
- { text: 'b', format: 1 << 4 },
61
- { text: ' c', format: 0 },
62
- ])
43
+ expect(paragraph.children).toContainEqual(expect.objectContaining({ text: 'both', format: 3 }))
44
+ expect(paragraph.children).toContainEqual(
45
+ expect.objectContaining({ text: 'code', format: 1 << 4 })
46
+ )
63
47
  })
64
48
 
65
- test('strikethrough (GFM) carries the strikethrough bit', () => {
49
+ test('preserves GFM strikethrough', () => {
66
50
  const { state } = convert('a ~~b~~ c')
67
51
  const paragraph = state.root.children[0] as unknown as {
68
52
  children: Array<{ format: number; text: string }>
69
53
  }
70
- const struck = paragraph.children.find((c) => c.text === 'b')
71
- expect(struck?.format).toBe(1 << 2)
54
+ expect(paragraph.children.find((child) => child.text === 'b')?.format).toBe(1 << 2)
72
55
  })
73
56
 
74
- test('unordered list emits listType=bullet, tag=ul, listitems with peeled paragraph children', () => {
75
- const { state } = convert('- one\n- two')
76
- const list = state.root.children[0] as unknown as {
77
- type: string
57
+ test('maps unordered, ordered, and nested lists', () => {
58
+ const unordered = convert('- a\n - a1\n- b').state.root.children[0] as unknown as {
78
59
  listType: string
79
60
  tag: string
80
- children: Array<{ type: string; children: Array<{ type: string; text: string }> }>
61
+ children: Array<{ children: Array<{ type: string }> }>
81
62
  }
82
- expect(list).toMatchObject({ type: 'list', listType: 'bullet', tag: 'ul' })
83
- expect(list.children).toHaveLength(2)
84
- expect(list.children[0]).toMatchObject({
85
- type: 'listitem',
86
- children: [{ type: 'text', text: 'one' }],
87
- })
88
- })
63
+ expect(unordered).toMatchObject({ listType: 'bullet', tag: 'ul' })
64
+ expect(unordered.children[0].children.some((child) => child.type === 'list')).toBe(true)
89
65
 
90
- test('ordered list propagates start and numbers values', () => {
91
- const { state } = convert('3. a\n4. b')
92
- const list = state.root.children[0] as unknown as {
66
+ const ordered = convert('3. a\n4. b').state.root.children[0] as unknown as {
93
67
  listType: string
94
68
  tag: string
95
69
  start: number
96
70
  children: Array<{ value: number }>
97
71
  }
98
- expect(list).toMatchObject({ listType: 'number', tag: 'ol', start: 3 })
99
- expect(list.children[0].value).toBe(3)
100
- expect(list.children[1].value).toBe(4)
72
+ expect(ordered).toMatchObject({ listType: 'number', tag: 'ol', start: 3 })
73
+ expect(ordered.children.map((child) => child.value)).toEqual([3, 4])
101
74
  })
102
75
 
103
- test('nested list lives inside the parent listitem', () => {
104
- const { state } = convert('- a\n - a1\n- b')
105
- const list = state.root.children[0] as unknown as {
106
- children: Array<{ children: Array<{ type: string }> }>
76
+ test('maps links and fenced code language aliases', () => {
77
+ const linkParagraph = convert('[label](https://example.com)').state.root
78
+ .children[0] as unknown as {
79
+ children: Array<{ attributes: { url: string; newTab: boolean } }>
107
80
  }
108
- const firstItemChildren = list.children[0].children
109
- expect(firstItemChildren.some((c) => c.type === 'list')).toBe(true)
110
- })
111
-
112
- test('link emits link node with custom attributes envelope', () => {
113
- const { state } = convert('[label](https://example.com)')
114
- const paragraph = state.root.children[0] as unknown as {
115
- children: Array<{
116
- type: string
117
- attributes?: { linkType?: string; url?: string; newTab?: boolean }
118
- children?: Array<{ text: string }>
119
- }>
120
- }
121
- const link = paragraph.children[0]
122
- expect(link.type).toBe('link')
123
- expect(link.attributes).toMatchObject({
124
- linkType: 'custom',
81
+ expect(linkParagraph.children[0].attributes).toMatchObject({
125
82
  url: 'https://example.com',
126
83
  newTab: true,
127
84
  })
128
- expect(link.children?.[0]).toMatchObject({ text: 'label' })
129
- })
130
85
 
131
- test('fenced code with language stores `language` and emits code-highlight + linebreak children', () => {
132
- const { state } = convert('```ts\nconst x = 1\nconst y = 2\n```')
133
- const code = state.root.children[0] as unknown as {
134
- type: string
86
+ const code = convert('```ts\nconst x = 1\n```').state.root.children[0] as unknown as {
135
87
  language: string
136
88
  children: Array<{ type: string; text?: string }>
137
89
  }
138
- expect(code).toMatchObject({ type: 'code', language: 'typescript' })
90
+ expect(code.language).toBe('typescript')
139
91
  expect(code.children[0]).toMatchObject({ type: 'code-highlight', text: 'const x = 1' })
140
- expect(code.children[1]).toMatchObject({ type: 'linebreak' })
141
- expect(code.children[2]).toMatchObject({ type: 'code-highlight', text: 'const y = 2' })
142
- })
143
-
144
- test('thematic break emits horizontalrule', () => {
145
- const { state } = convert('---')
146
- expect(state.root.children[0]).toMatchObject({ type: 'horizontalrule' })
147
- })
148
-
149
- test('blockquote flattens single paragraph into inline children', () => {
150
- const { state } = convert('> hello')
151
- const quote = state.root.children[0] as unknown as {
152
- type: string
153
- children: Array<{ type: string; text?: string }>
154
- }
155
- expect(quote.type).toBe('quote')
156
- expect(quote.children).toEqual([expect.objectContaining({ type: 'text', text: 'hello' })])
157
92
  })
158
93
 
159
- test('image at block level is dropped with a warning', () => {
160
- const { warnings, state } = convert('![alt](https://example.com/x.png)')
161
- expect(warnings.some((w) => w.kind === 'dropped-image')).toBe(true)
162
- // Paragraph survives as empty (image was its only child)
163
- expect(state.root.children[0]).toMatchObject({ type: 'paragraph' })
94
+ test('maps blockquotes, rules, and image warnings', () => {
95
+ expect(convert('> hello').state.root.children[0]).toMatchObject({ type: 'quote' })
96
+ expect(convert('---').state.root.children[0]).toMatchObject({ type: 'horizontalrule' })
97
+ expect(convert('![alt](https://example.com/x.png)').warnings).toContainEqual(
98
+ expect.objectContaining({ kind: 'dropped-image' })
99
+ )
164
100
  })
165
101
 
166
- test('GFM table maps to table/tablerow/tablecell with first-row headerState', () => {
167
- const md = '| a | b |\n| - | - |\n| 1 | 2 |\n'
168
- const { state, warnings } = convert(md)
169
- expect(warnings).toEqual([])
170
-
171
- const table = state.root.children[0] as unknown as {
172
- type: string
102
+ test('maps GFM tables with header state and inline formatting', () => {
103
+ const table = convert('| col |\n| - |\n| **bold** |').state.root
104
+ .children[0] as unknown as {
173
105
  children: Array<{
174
- type: string
175
106
  children: Array<{
176
- type: string
177
107
  headerState: number
178
- children: Array<{ type: string; children: Array<{ text: string }> }>
108
+ children: Array<{ children: Array<{ text: string; format: number }> }>
179
109
  }>
180
110
  }>
181
111
  }
182
- expect(table.type).toBe('table')
183
- expect(table.children).toHaveLength(2)
184
-
185
- const [headerRow, bodyRow] = table.children
186
- expect(headerRow.type).toBe('tablerow')
187
- expect(bodyRow.type).toBe('tablerow')
188
- expect(headerRow.children[0]).toMatchObject({ type: 'tablecell', headerState: 1 })
189
- expect(headerRow.children[1]).toMatchObject({ type: 'tablecell', headerState: 1 })
190
- expect(bodyRow.children[0]).toMatchObject({ type: 'tablecell', headerState: 0 })
191
- expect(bodyRow.children[1]).toMatchObject({ type: 'tablecell', headerState: 0 })
192
-
193
- // Inline cell content is wrapped in a paragraph.
194
- const firstHeaderCell = headerRow.children[0]
195
- expect(firstHeaderCell.children[0].type).toBe('paragraph')
196
- expect(firstHeaderCell.children[0].children[0]).toMatchObject({ text: 'a' })
197
- expect(bodyRow.children[1].children[0].children[0]).toMatchObject({ text: '2' })
112
+ expect(table.children[0].children[0].headerState).toBe(1)
113
+ expect(table.children[1].children[0].headerState).toBe(0)
114
+ expect(table.children[1].children[0].children[0].children[0]).toMatchObject({
115
+ text: 'bold',
116
+ format: 1,
117
+ })
198
118
  })
199
119
 
200
- test('empty cell emits a paragraph with no children', () => {
201
- const md = '| a | b |\n| - | - |\n| | 2 |\n'
202
- const { state } = convert(md)
203
- const table = state.root.children[0] as unknown as {
120
+ test('gives empty table cells a structurally valid empty paragraph', () => {
121
+ const table = convert('| a | b |\n| - | - |\n| | 2 |').state.root
122
+ .children[0] as unknown as {
204
123
  children: Array<{
205
124
  children: Array<{ children: Array<{ type: string; children: unknown[] }> }>
206
125
  }>
207
126
  }
208
- const emptyCellParagraph = table.children[1].children[0].children[0]
209
- expect(emptyCellParagraph).toMatchObject({ type: 'paragraph', children: [] })
210
- })
211
-
212
- test('inline formatting inside cells is preserved', () => {
213
- const md = '| col |\n| - |\n| **bold** |\n'
214
- const { state } = convert(md)
215
- const table = state.root.children[0] as unknown as {
216
- children: Array<{
217
- children: Array<{
218
- children: Array<{ children: Array<{ text: string; format: number }> }>
219
- }>
220
- }>
221
- }
222
- const bodyCellInline = table.children[1].children[0].children[0].children[0]
223
- expect(bodyCellInline).toMatchObject({ text: 'bold', format: 1 })
127
+ expect(table.children[1].children[0].children[0]).toMatchObject({
128
+ type: 'paragraph',
129
+ children: [],
130
+ })
224
131
  })
225
132
 
226
- test('code fence language is normalized to prism-known ids', () => {
133
+ test('normalizes common code fence aliases and preserves known names', () => {
227
134
  const cases: Array<[string, string]> = [
228
135
  ['ts', 'typescript'],
229
136
  ['js', 'javascript'],
230
137
  ['sh', 'bash'],
231
138
  ['yml', 'yaml'],
232
- ['tsx', 'tsx'], // already prism-known, passes through
139
+ ['tsx', 'tsx'],
233
140
  ]
234
141
  for (const [input, expected] of cases) {
235
- const { state } = convert(`\`\`\`${input}\nx\n\`\`\``)
236
- const code = state.root.children[0] as unknown as { language: string }
142
+ const code = convert(`\`\`\`${input}\nx\n\`\`\``).state.root.children[0] as unknown as {
143
+ language: string
144
+ }
237
145
  expect(code.language).toBe(expected)
238
146
  }
239
147
  })
240
148
 
241
- test('hard-wrapped paragraph collapses internal newlines to spaces', () => {
242
- const md =
243
- 'In a world where AI produces content fast,\ninto dozens of languages,\nwhy does a CMS matter?'
244
- const { state } = convert(md)
245
- const paragraph = state.root.children[0] as unknown as {
149
+ test('collapses hard-wrapped paragraph newlines', () => {
150
+ const paragraph = convert('first line,\nsecond line.').state.root.children[0] as unknown as {
246
151
  children: Array<{ text: string }>
247
152
  }
248
- // Single text node with newlines collapsed to single spaces — no
249
- // <br> nodes and no embedded \n.
250
- expect(paragraph.children).toHaveLength(1)
251
- expect(paragraph.children[0].text).toBe(
252
- 'In a world where AI produces content fast, into dozens of languages, why does a CMS matter?'
253
- )
153
+ expect(paragraph.children[0].text).toBe('first line, second line.')
254
154
  })
255
155
 
256
- describe('admonitions', () => {
257
- test('titled note becomes an admonition node with body paragraphs', () => {
258
- const md = [':::note[Heads up]', 'First line.', '', 'Second line.', ':::'].join('\n')
259
- const { state, warnings } = convert(md)
260
- expect(warnings).toEqual([])
261
- expect(state.root.children).toHaveLength(1)
262
- const adm = state.root.children[0] as unknown as {
263
- type: string
264
- admonitionType: string
265
- title: string
266
- children: Array<{ type: string; children: Array<{ text: string }> }>
267
- }
268
- expect(adm).toMatchObject({ type: 'admonition', admonitionType: 'note', title: 'Heads up' })
269
- expect(adm.children).toHaveLength(2)
270
- expect(adm.children[0]).toMatchObject({ type: 'paragraph' })
271
- expect(adm.children[0].children[0].text).toBe('First line.')
272
- expect(adm.children[1].children[0].text).toBe('Second line.')
273
- })
274
-
275
- test('inline formatting + links survive inside an admonition body', () => {
276
- const md = [
277
- ':::warning[Careful]',
278
- 'This is **bold** and a [link](https://x.io).',
279
- ':::',
280
- ].join('\n')
281
- const { state } = convert(md)
282
- const adm = state.root.children[0] as unknown as {
283
- admonitionType: string
284
- children: Array<{ children: Array<{ type: string; text?: string; format?: number }> }>
285
- }
286
- expect(adm.admonitionType).toBe('warning')
287
- const inline = adm.children[0].children
288
- expect(inline).toContainEqual(expect.objectContaining({ text: 'bold', format: 1 }))
289
- expect(inline).toContainEqual(expect.objectContaining({ type: 'link' }))
290
- })
156
+ test('maps admonitions and ignores colon-bearing prose', () => {
157
+ const admonition = convert(':::warning[Careful]\nThis is **bold**.\n:::').state.root
158
+ .children[0] as unknown as {
159
+ admonitionType: string
160
+ title: string
161
+ children: Array<{ children: Array<{ text?: string; format?: number }> }>
162
+ }
163
+ expect(admonition).toMatchObject({ admonitionType: 'warning', title: 'Careful' })
164
+ expect(admonition.children[0].children).toContainEqual(
165
+ expect.objectContaining({ text: 'bold', format: 1 })
166
+ )
291
167
 
292
- test('untitled admonition carries an empty title', () => {
293
- const { state } = convert([':::tip', 'A quick tip.', ':::'].join('\n'))
294
- const adm = state.root.children[0] as unknown as { admonitionType: string; title: string }
295
- expect(adm).toMatchObject({ admonitionType: 'tip', title: '' })
296
- })
168
+ const prose = convert('Run at 9:30 with a 1:2:3 ratio and note:foo.')
169
+ expect(prose.warnings).toEqual([])
170
+ expect(prose.state.root.children[0]).toMatchObject({ type: 'paragraph' })
171
+ })
297
172
 
298
- test('an empty admonition body is seeded with one empty paragraph', () => {
299
- const { state } = convert([':::danger[Stop]', ':::'].join('\n'))
300
- const adm = state.root.children[0] as unknown as {
301
- admonitionType: string
302
- children: Array<{ type: string; children: unknown[] }>
303
- }
304
- expect(adm.admonitionType).toBe('danger')
305
- expect(adm.children).toEqual([expect.objectContaining({ type: 'paragraph', children: [] })])
306
- })
173
+ test('supports untitled, empty, and source-ordered admonitions', () => {
174
+ const untitled = convert(':::tip\nA quick tip.\n:::').state.root.children[0]
175
+ expect(untitled).toMatchObject({ admonitionType: 'tip', title: '' })
307
176
 
308
- test('admonitions sit alongside ordinary blocks in source order', () => {
309
- const md = ['# Title', '', ':::note[Note]', 'Body.', ':::', '', 'After.'].join('\n')
310
- const { state } = convert(md)
311
- expect(state.root.children.map((c) => (c as { type: string }).type)).toEqual([
312
- 'heading',
313
- 'admonition',
314
- 'paragraph',
315
- ])
316
- })
177
+ const empty = convert(':::danger[Stop]\n:::').state.root.children[0] as unknown as {
178
+ children: Array<{ type: string; children: unknown[] }>
179
+ }
180
+ expect(empty.children).toEqual([expect.objectContaining({ type: 'paragraph', children: [] })])
317
181
 
318
- test('colon-bearing prose is never mistaken for a directive', () => {
319
- // The line scanner only matches the four container fences, so inline
320
- // colons (`9:30`, `1:2:3`, `note:foo`) pass through untouched.
321
- const { state, warnings } = convert('Run at 9:30 with a 1:2:3 ratio and note:foo.')
322
- expect(warnings).toEqual([])
323
- const paragraph = state.root.children[0] as unknown as { children: Array<{ text: string }> }
324
- expect(paragraph.children).toHaveLength(1)
325
- expect(paragraph.children[0].text).toBe('Run at 9:30 with a 1:2:3 ratio and note:foo.')
326
- })
182
+ const ordered = convert('# Title\n\n:::note[Note]\nBody.\n:::\n\nAfter.').state.root.children
183
+ expect(ordered.map((node) => (node as { type: string }).type)).toEqual([
184
+ 'heading',
185
+ 'admonition',
186
+ 'paragraph',
187
+ ])
188
+ })
327
189
 
328
- test('a `:::` fence inside a code block is treated as literal code', () => {
329
- const md = ['```', ':::note[Not an admonition]', 'still code', ':::', '```'].join('\n')
330
- const { state } = convert(md)
331
- expect(state.root.children).toHaveLength(1)
332
- expect(state.root.children[0]).toMatchObject({ type: 'code' })
333
- })
190
+ test('keeps admonition-looking fences inside code blocks as code', () => {
191
+ const state = convert('```\n:::note[Not an admonition]\nstill code\n:::\n```').state
192
+ expect(state.root.children).toHaveLength(1)
193
+ expect(state.root.children[0]).toMatchObject({ type: 'code' })
334
194
  })
335
195
  })
@@ -11,7 +11,7 @@
11
11
  *
12
12
  * Pure, headless-editor-free conversion. The output JSON is shaped to
13
13
  * match what the runtime serializer in
14
- * `apps/webapp/src/ui/byline/components/richtext-lexical/serialize`
14
+ * `src/ui/byline/components/richtext-lexical/serialize`
15
15
  * expects to render (heading.tag, list.tag + list.listType, code
16
16
  * children of type 'code-highlight' interspersed with 'linebreak',
17
17
  * link.attributes, etc.).
@@ -47,7 +47,7 @@ import type {
47
47
  import type { AdmonitionDirective } from './parse-markdown.js'
48
48
 
49
49
  // Lexical inline format bits — kept in sync with
50
- // apps/webapp/src/ui/byline/components/richtext-lexical/serialize/richtext-node-formats.ts
50
+ // src/ui/byline/components/richtext-lexical/serialize/richtext-node-formats.ts
51
51
  const IS_BOLD = 1
52
52
  const IS_ITALIC = 1 << 1
53
53
  const IS_STRIKETHROUGH = 1 << 2
@@ -33,10 +33,6 @@ describe('stripLeadingH1IfMatches', () => {
33
33
  })
34
34
 
35
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
36
  const root = parse('# Client SDK (`@byline/client`)\n\nbody')
41
37
  const out = stripLeadingH1IfMatches(root, 'Client SDK (@byline/client)')
42
38
  expect(out.children).toHaveLength(1)
@@ -16,9 +16,9 @@
16
16
  * listed — that list is the outstanding-translation backlog. Re-run as
17
17
  * translation progresses; it is idempotent (each doc is its own transaction).
18
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
19
+ * pnpm tsx byline/scripts/re-anchor.ts --to fr
20
+ * pnpm tsx byline/scripts/re-anchor.ts --to fr --collection pages
21
+ * pnpm tsx byline/scripts/re-anchor.ts --to fr --dry-run
22
22
  *
23
23
  * See docs/07-internationalization/index.md.
24
24
  */
@@ -59,11 +59,12 @@ import {
59
59
  getCollectionDefinition,
60
60
  getServerConfig,
61
61
  getWorkflowStatuses,
62
- type StoredFileValue,
63
62
  } from '@byline/core'
64
63
  import { extractImageMeta, generateImageVariants, isBypassMimeType } from '@byline/core/image'
65
64
  import { uploadField as coreUploadField } from '@byline/core/services'
66
65
 
66
+ import type { CollectionFieldsByPath, MediaFields } from '../generated/collection-types.js'
67
+
67
68
  const COLLECTION_PATH = 'media'
68
69
  const FIELD_NAME = 'image'
69
70
 
@@ -92,7 +93,7 @@ async function run(): Promise<void> {
92
93
  }
93
94
 
94
95
  const requestContext = createSuperAdminContext({ id: 'regenerate-media-script' })
95
- const client = createBylineClient({ config, requestContext })
96
+ const client = createBylineClient<CollectionFieldsByPath>({ config, requestContext })
96
97
 
97
98
  const { id: collectionId, version: collectionVersion } =
98
99
  await client.resolveCollectionRecord(COLLECTION_PATH)
@@ -108,7 +109,7 @@ async function run(): Promise<void> {
108
109
  id: string
109
110
  path: string
110
111
  status: string
111
- fields: Record<string, any>
112
+ fields: MediaFields
112
113
  }[] = []
113
114
  const pageSize = 100
114
115
  for (let page = 1; ; page++) {
@@ -123,7 +124,7 @@ async function run(): Promise<void> {
123
124
  id: d.id,
124
125
  path: d.path,
125
126
  status: d.status,
126
- fields: d.fields as Record<string, any>,
127
+ fields: d.fields,
127
128
  })
128
129
  }
129
130
  if (result.docs.length < pageSize) break
@@ -154,7 +155,7 @@ async function run(): Promise<void> {
154
155
  let skipped = 0
155
156
 
156
157
  for (const doc of allDocs) {
157
- const image = doc.fields[FIELD_NAME] as StoredFileValue | undefined | null
158
+ const image = doc.fields[FIELD_NAME]
158
159
  if (image == null || !image.storagePath) {
159
160
  console.log(` - skip ${doc.id} (${doc.path}) — no image value`)
160
161
  skipped += 1
@@ -28,8 +28,9 @@ 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 './clients.server.js'
32
32
  import { collections } from './collections/index.js'
33
+ import { serverHooks } from './collections/server-hooks.js'
33
34
  import { i18n } from './i18n.js'
34
35
  import { DEFAULT_SERVER_URL, routes } from './routes.js'
35
36
 
@@ -139,6 +140,7 @@ async function buildBylineCore(): Promise<BylineCore<AdminStore>> {
139
140
  i18n,
140
141
  routes,
141
142
  collections,
143
+ hooks: serverHooks,
142
144
  db,
143
145
  adminStore,
144
146
  // Site-wide default storage provider — used by any upload collection
@@ -163,8 +165,7 @@ async function buildBylineCore(): Promise<BylineCore<AdminStore>> {
163
165
  }),
164
166
  // S3-compatible alternative (AWS S3 / Cloudflare R2 / MinIO). Replace
165
167
  // 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`).
168
+ // the corresponding `BYLINE_STORAGE_S3_*` entries to your `.env.local`.
168
169
  //
169
170
  // On AWS with an IAM role / instance profile, omit `accessKeyId` and
170
171
  // `secretAccessKey` so the SDK resolves credentials via its default
@@ -204,7 +205,7 @@ async function buildBylineCore(): Promise<BylineCore<AdminStore>> {
204
205
  //
205
206
  // We pass `getAdminBylineClient` (not the public client) because
206
207
  // admin server fns are the only call sites that read documents in
207
- // the admin webapp today — the populate phase inherits whichever
208
+ // the admin host today — the populate phase inherits whichever
208
209
  // actor the request resolved. A future public-facing host would
209
210
  // register its own client whose `requestContext` factory resolves
210
211
  // an end-user actor instead. See `@byline/host-tanstack-start/
@@ -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',