@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
@@ -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
  */
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Maintenance helpers for `regenerate-media.ts`.
3
+ *
4
+ * Kept separate from the executable so the safety-critical behaviour can be
5
+ * unit tested without loading the application server config or touching real
6
+ * storage.
7
+ */
8
+
9
+ import type { CollectionHandle } from '@byline/client'
10
+ import {
11
+ getDefaultStatus,
12
+ getWorkflowStatuses,
13
+ type CollectionDefinition,
14
+ type IDbAdapter,
15
+ type StoredFileValue,
16
+ } from '@byline/core'
17
+
18
+ export function storedFilePaths(value: StoredFileValue): Set<string> {
19
+ return new Set([
20
+ value.storagePath,
21
+ ...(value.variants ?? [])
22
+ .map((variant) => variant.storagePath)
23
+ .filter((storagePath): storagePath is string => Boolean(storagePath)),
24
+ ])
25
+ }
26
+
27
+ /**
28
+ * Reject a partial image-processor result before it replaces a complete
29
+ * persisted value. Core uploads deliberately tolerate an individual variant
30
+ * failure for interactive requests; this maintenance operation must be
31
+ * stricter because it is about bringing every stored asset up to the schema.
32
+ */
33
+ export function assertCompleteVariantSet(
34
+ storedFile: StoredFileValue,
35
+ expectedVariantNames: readonly string[]
36
+ ): void {
37
+ const expected = [...expectedVariantNames].sort()
38
+ const actual = (storedFile.variants ?? []).map((variant) => variant.name).sort()
39
+ if (
40
+ expected.length !== actual.length ||
41
+ expected.some((variantName, index) => variantName !== actual[index])
42
+ ) {
43
+ throw new Error(
44
+ `regenerate-media: incomplete variant set for '${storedFile.storagePath}' ` +
45
+ `(expected: ${expected.join(', ') || 'none'}; generated: ${actual.join(', ') || 'none'}).`
46
+ )
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Write the regenerated fields and restore the captured workflow status as
52
+ * one transaction.
53
+ *
54
+ * `CollectionHandle.update()` creates a new immutable version at the
55
+ * collection's default status. For this maintenance operation, changing that
56
+ * newly-created row to the captured status inside the same transaction is not
57
+ * an editorial transition: no caller can observe the intermediate draft, and
58
+ * an archived item never passes through published. The final version row and
59
+ * its normal `document.updated` activity entry retain the resulting status;
60
+ * the explicit status mutation is additionally audited as a system action.
61
+ *
62
+ * Direct adapter commands are intentional here and should not be copied into
63
+ * ordinary application writes. Scripts are the supported escape hatch for
64
+ * migrations that must preserve metadata while still using the lifecycle for
65
+ * the content update itself.
66
+ */
67
+ export async function replaceMediaVersionPreservingStatus(params: {
68
+ db: IDbAdapter
69
+ definition: CollectionDefinition
70
+ collectionId: string
71
+ handle: Pick<CollectionHandle, 'update'>
72
+ documentId: string
73
+ fields: Record<string, any>
74
+ targetStatus: string
75
+ }): Promise<{ documentVersionId: string }> {
76
+ const statuses = getWorkflowStatuses(params.definition).map((status) => status.name)
77
+ if (!statuses.includes(params.targetStatus)) {
78
+ throw new Error(
79
+ `regenerate-media: status '${params.targetStatus}' is not declared on collection ` +
80
+ `'${params.definition.path}' (declared: ${statuses.join(', ')}).`
81
+ )
82
+ }
83
+
84
+ const defaultStatus = getDefaultStatus(params.definition)
85
+ return params.db.withTransaction(async () => {
86
+ const result = await params.handle.update(params.documentId, params.fields)
87
+ if (params.targetStatus === defaultStatus) return result
88
+
89
+ await params.db.commands.documents.setDocumentStatus({
90
+ document_version_id: result.documentVersionId,
91
+ status: params.targetStatus,
92
+ })
93
+ if (params.targetStatus === 'published') {
94
+ await params.db.commands.documents.archivePublishedVersions({
95
+ document_id: params.documentId,
96
+ excludeVersionId: result.documentVersionId,
97
+ })
98
+ }
99
+ await params.db.commands.audit.append({
100
+ documentId: params.documentId,
101
+ collectionId: params.collectionId,
102
+ actorId: null,
103
+ actorRealm: 'system',
104
+ action: 'document.status.changed',
105
+ field: 'status',
106
+ before: defaultStatus,
107
+ after: params.targetStatus,
108
+ })
109
+
110
+ return result
111
+ })
112
+ }