@byline/cli 3.20.4 → 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 (198) hide show
  1. package/README.md +42 -2
  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 +4 -1
  97. package/dist/phases/scaffold.d.ts.map +1 -1
  98. package/dist/phases/scaffold.js +275 -72
  99. package/dist/phases/scaffold.js.map +1 -1
  100. package/dist/phases/scaffold.test.d.ts +2 -0
  101. package/dist/phases/scaffold.test.d.ts.map +1 -0
  102. package/dist/phases/scaffold.test.js +178 -0
  103. package/dist/phases/scaffold.test.js.map +1 -0
  104. package/dist/phases/ui.d.ts +4 -1
  105. package/dist/phases/ui.d.ts.map +1 -1
  106. package/dist/phases/ui.js +51 -59
  107. package/dist/phases/ui.js.map +1 -1
  108. package/dist/phases/wire/index.d.ts.map +1 -1
  109. package/dist/phases/wire/index.js +10 -6
  110. package/dist/phases/wire/index.js.map +1 -1
  111. package/dist/phases/wire/shared.d.ts +4 -1
  112. package/dist/phases/wire/shared.d.ts.map +1 -1
  113. package/dist/phases/wire/vite-config.d.ts.map +1 -1
  114. package/dist/phases/wire/vite-config.js +69 -20
  115. package/dist/phases/wire/vite-config.js.map +1 -1
  116. package/dist/phases/wire/vite-config.test.d.ts +2 -0
  117. package/dist/phases/wire/vite-config.test.d.ts.map +1 -0
  118. package/dist/phases/wire/vite-config.test.js +97 -0
  119. package/dist/phases/wire/vite-config.test.js.map +1 -0
  120. package/dist/prompts.d.ts +1 -0
  121. package/dist/prompts.d.ts.map +1 -1
  122. package/dist/prompts.js +7 -2
  123. package/dist/prompts.js.map +1 -1
  124. package/dist/runner.js +1 -1
  125. package/dist/runner.js.map +1 -1
  126. package/dist/templates/byline/admin.config.ts +8 -7
  127. package/dist/templates/byline/clients.server.ts +77 -0
  128. package/dist/templates/byline/collection-types.contract.ts +67 -0
  129. package/dist/templates/byline/collections/index.ts +17 -0
  130. package/dist/templates/byline/collections/server-hooks.ts +7 -0
  131. package/dist/templates/byline/generated/collection-types.ts +11 -0
  132. package/dist/templates/byline/i18n.ts +3 -3
  133. package/dist/templates/byline/locales.ts +9 -9
  134. package/dist/templates/byline/public.ts +12 -0
  135. package/dist/templates/byline/routes.ts +7 -6
  136. package/dist/templates/byline/scripts/generate-types.ts +81 -0
  137. package/dist/templates/byline/server.config.ts +5 -5
  138. package/dist/templates/byline-examples/admin.config.ts +18 -21
  139. package/dist/templates/byline-examples/blocks/photo-block.ts +3 -2
  140. package/dist/templates/byline-examples/clients.server.ts +77 -0
  141. package/dist/templates/byline-examples/collection-types.contract.ts +67 -0
  142. package/dist/templates/byline-examples/collections/docs/hooks.ts +30 -20
  143. package/dist/templates/byline-examples/collections/docs/schema.ts +18 -18
  144. package/dist/templates/byline-examples/collections/index.ts +22 -0
  145. package/dist/templates/byline-examples/collections/media/components/media-list-view.tsx +11 -6
  146. package/dist/templates/byline-examples/collections/media/schema.ts +16 -0
  147. package/dist/templates/byline-examples/collections/news/schema.ts +17 -3
  148. package/dist/templates/byline-examples/collections/news-categories/schema.ts +13 -0
  149. package/dist/templates/byline-examples/collections/pages/schema.ts +13 -0
  150. package/dist/templates/byline-examples/collections/server-hooks.ts +8 -0
  151. package/dist/templates/byline-examples/fields/ai-text.ts +1 -1
  152. package/dist/templates/byline-examples/fields/ai-textarea.ts +1 -1
  153. package/dist/templates/byline-examples/fields/lexical-richtext-ai.tsx +3 -3
  154. package/dist/templates/byline-examples/generated/collection-types.ts +139 -0
  155. package/dist/templates/byline-examples/i18n.ts +3 -3
  156. package/dist/templates/byline-examples/locales.ts +9 -9
  157. package/dist/templates/byline-examples/public.ts +12 -0
  158. package/dist/templates/byline-examples/routes.ts +7 -6
  159. package/dist/templates/byline-examples/scripts/backfill-version-locales.ts +1 -1
  160. package/dist/templates/byline-examples/scripts/generate-types.ts +81 -0
  161. package/dist/templates/byline-examples/scripts/import-docs.ts +11 -12
  162. package/dist/templates/byline-examples/scripts/lib/mdast-to-lexical.test.node.ts +93 -233
  163. package/dist/templates/byline-examples/scripts/lib/mdast-to-lexical.ts +2 -2
  164. package/dist/templates/byline-examples/scripts/lib/strip-leading-h1.test.node.ts +0 -4
  165. package/dist/templates/byline-examples/scripts/re-anchor.ts +3 -3
  166. package/dist/templates/byline-examples/scripts/regenerate-media.ts +6 -5
  167. package/dist/templates/byline-examples/server.config.ts +6 -15
  168. package/dist/templates/host/vite.config.ts +2 -0
  169. package/dist/templates/routes/_byline/admin/collections/$collection/$id/api.tsx +1 -6
  170. package/dist/templates/routes/_byline/admin/collections/$collection/$id/history.tsx +1 -7
  171. package/dist/templates/routes/_byline/admin/collections/$collection/$id/index.tsx +1 -6
  172. package/dist/templates/routes/_byline/route.lazy.tsx +1 -2
  173. package/dist/templates/routes/_byline/route.tsx +1 -1
  174. package/dist/templates/ui-byline/blocks/photo-block/index.tsx +18 -23
  175. package/dist/templates/ui-byline/blocks/richtext-block/index.tsx +1 -1
  176. package/dist/templates/ui-byline/components/admonition/index.tsx +11 -8
  177. package/dist/templates/ui-byline/components/code/code.tsx +6 -4
  178. package/dist/templates/ui-byline/components/link/lang-link.tsx +4 -8
  179. package/dist/templates/ui-byline/components/link/link-field.tsx +3 -2
  180. package/dist/templates/ui-byline/components/link/link-lexical.tsx +9 -8
  181. package/dist/templates/ui-byline/render-blocks.tsx +38 -38
  182. package/dist/templates/ui-byline/types/content.ts +25 -0
  183. package/dist/templates/ui-byline/types/i18n.ts +4 -8
  184. package/dist/test-helpers.d.ts +5 -0
  185. package/dist/test-helpers.d.ts.map +1 -0
  186. package/dist/test-helpers.js +52 -0
  187. package/dist/test-helpers.js.map +1 -0
  188. package/dist/types.d.ts +21 -1
  189. package/dist/types.d.ts.map +1 -1
  190. package/dist/ui-template-contract.test.d.ts +2 -0
  191. package/dist/ui-template-contract.test.d.ts.map +1 -0
  192. package/dist/ui-template-contract.test.js +130 -0
  193. package/dist/ui-template-contract.test.js.map +1 -0
  194. package/dist/workspace-root.test.d.ts +2 -0
  195. package/dist/workspace-root.test.d.ts.map +1 -0
  196. package/dist/workspace-root.test.js +307 -0
  197. package/dist/workspace-root.test.js.map +1 -0
  198. package/package.json +9 -1
@@ -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
@@ -19,7 +19,6 @@ import { JwtSessionProvider } from '@byline/admin/auth'
19
19
  import { type BylineCore, initBylineCore } from '@byline/core'
20
20
  import { pgAdapter } from '@byline/db-postgres'
21
21
  import { createAdminStore } from '@byline/db-postgres/admin'
22
- import { getAdminBylineClient } from '@byline/host-tanstack-start/integrations/byline-client'
23
22
  import {
24
23
  lexicalEditorEmbedServer,
25
24
  lexicalEditorPopulateServer,
@@ -29,22 +28,14 @@ import {
29
28
  import { migrate, postgresSearch } from '@byline/search-postgres'
30
29
  import { localStorageProvider } from '@byline/storage-local'
31
30
 
32
- // Import collection definitions directly from schema files — NOT the full
33
- // admin config or index barrels. The admin config / index files pull in
34
- // admin UI configs (React components, CSS modules) that are not loadable
35
- // outside Vite (e.g. when running seeds via tsx).
36
- import { Docs } from './collections/docs/schema.js'
37
- import { Media } from './collections/media/schema.js'
38
- import { News } from './collections/news/schema.js'
39
- import { NewsCategories } from './collections/news-categories/schema.js'
40
- import { Pages } from './collections/pages/schema.js'
31
+ import { getAdminBylineClient } from './clients.server.js'
32
+ import { collections } from './collections/index.js'
33
+ import { serverHooks } from './collections/server-hooks.js'
41
34
  import { i18n } from './i18n.js'
42
35
  import { DEFAULT_SERVER_URL, routes } from './routes.js'
43
36
 
44
37
  const serverURL = process.env.VITE_SERVER_URL || DEFAULT_SERVER_URL
45
38
 
46
- const collections = [Docs, News, Pages, Media, NewsCategories]
47
-
48
39
  // HMR-safe singleton. Vite's program reload re-evaluates this module
49
40
  // without disposing the previous module's resources — every reload
50
41
  // would otherwise allocate a fresh pg `Pool` (max: 20) inside
@@ -149,6 +140,7 @@ async function buildBylineCore(): Promise<BylineCore<AdminStore>> {
149
140
  i18n,
150
141
  routes,
151
142
  collections,
143
+ hooks: serverHooks,
152
144
  db,
153
145
  adminStore,
154
146
  // Site-wide default storage provider — used by any upload collection
@@ -173,8 +165,7 @@ async function buildBylineCore(): Promise<BylineCore<AdminStore>> {
173
165
  }),
174
166
  // S3-compatible alternative (AWS S3 / Cloudflare R2 / MinIO). Replace
175
167
  // the `localStorageProvider` block above with the call below and add
176
- // the corresponding `BYLINE_STORAGE_S3_*` entries to your `.env.local`
177
- // (see `apps/webapp/.env.local.example`).
168
+ // the corresponding `BYLINE_STORAGE_S3_*` entries to your `.env.local`.
178
169
  //
179
170
  // On AWS with an IAM role / instance profile, omit `accessKeyId` and
180
171
  // `secretAccessKey` so the SDK resolves credentials via its default
@@ -214,7 +205,7 @@ async function buildBylineCore(): Promise<BylineCore<AdminStore>> {
214
205
  //
215
206
  // We pass `getAdminBylineClient` (not the public client) because
216
207
  // admin server fns are the only call sites that read documents in
217
- // the admin webapp today — the populate phase inherits whichever
208
+ // the admin host today — the populate phase inherits whichever
218
209
  // actor the request resolved. A future public-facing host would
219
210
  // register its own client whose `requestContext` factory resolves
220
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',
@@ -8,9 +8,4 @@
8
8
 
9
9
  import { createCollectionApiRoute } from '@byline/host-tanstack-start/routes'
10
10
 
11
- import { contentLocales, i18n } from '~/i18n'
12
-
13
- export const Route = createCollectionApiRoute('/_byline/admin/collections/$collection/$id/api', {
14
- contentLocales,
15
- defaultContentLocale: i18n.content.defaultLocale,
16
- })
11
+ export const Route = createCollectionApiRoute('/_byline/admin/collections/$collection/$id/api')
@@ -8,12 +8,6 @@
8
8
 
9
9
  import { createCollectionHistoryRoute } from '@byline/host-tanstack-start/routes'
10
10
 
11
- import { contentLocales, i18n } from '~/i18n'
12
-
13
11
  export const Route = createCollectionHistoryRoute(
14
- '/_byline/admin/collections/$collection/$id/history',
15
- {
16
- contentLocales,
17
- defaultContentLocale: i18n.content.defaultLocale,
18
- }
12
+ '/_byline/admin/collections/$collection/$id/history'
19
13
  )
@@ -8,9 +8,4 @@
8
8
 
9
9
  import { createCollectionEditRoute } from '@byline/host-tanstack-start/routes'
10
10
 
11
- import { contentLocales, i18n } from '~/i18n'
12
-
13
- export const Route = createCollectionEditRoute('/_byline/admin/collections/$collection/$id/', {
14
- contentLocales,
15
- defaultContentLocale: i18n.content.defaultLocale,
16
- })
11
+ export const Route = createCollectionEditRoute('/_byline/admin/collections/$collection/$id/')
@@ -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