@rimelight/cms 0.0.1 → 0.0.3

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 (104) hide show
  1. package/LICENSE +21 -0
  2. package/package.json +16 -14
  3. package/src/admin/api/media-file.ts +39 -0
  4. package/src/admin/api/media.ts +199 -0
  5. package/src/admin/layouts/CMSDashboardLayout.astro +178 -0
  6. package/src/admin/layouts/DefaultParentLayout.astro +50 -0
  7. package/src/admin/pages/assets.astro +424 -0
  8. package/src/admin/pages/byline-schema.astro +62 -0
  9. package/src/admin/pages/bylines.astro +16 -0
  10. package/src/admin/pages/categories.astro +21 -0
  11. package/src/admin/pages/content-types.astro +55 -0
  12. package/src/admin/pages/dashboard.astro +61 -0
  13. package/src/admin/pages/pages-edit.astro +595 -0
  14. package/src/admin/pages/pages-index.astro +146 -0
  15. package/src/admin/pages/pages-new.astro +130 -0
  16. package/src/admin/pages/pages-preview.astro +111 -0
  17. package/src/admin/pages/pages-review.astro +236 -0
  18. package/src/admin/pages/settings.astro +203 -0
  19. package/src/admin/pages/tags.astro +21 -0
  20. package/src/admin/pages/templates-edit.astro +234 -0
  21. package/src/admin/pages/templates-index.astro +95 -0
  22. package/src/admin/pages/templates-new.astro +193 -0
  23. package/src/admin/pages/users.astro +74 -0
  24. package/src/admin/pages/versions.astro +74 -0
  25. package/src/astro/BlockRenderer.astro +44 -96
  26. package/src/astro/PageRenderer.astro +9 -2
  27. package/src/astro/blocks/CalloutBlock.astro +7 -7
  28. package/src/astro/blocks/CardBlock.astro +5 -5
  29. package/src/astro/blocks/CodeBlock.astro +2 -2
  30. package/src/astro/blocks/FileTreeBlock.astro +7 -7
  31. package/src/astro/blocks/ImageBlock.astro +2 -2
  32. package/src/astro/blocks/OrderedListBlock.astro +2 -2
  33. package/src/astro/blocks/ParagraphBlock.astro +3 -3
  34. package/src/astro/blocks/SectionBlock.astro +6 -6
  35. package/src/astro/blocks/StepItemBlock.astro +3 -3
  36. package/src/astro/blocks/StepsBlock.astro +1 -1
  37. package/src/astro/blocks/TableBlock.astro +6 -6
  38. package/src/astro/blocks/TabsBlock.astro +9 -9
  39. package/src/astro/blocks/UnorderedListBlock.astro +2 -2
  40. package/src/astro/blocks/index.ts +35 -0
  41. package/src/client/components/RimelightBlock.tsx +19 -19
  42. package/src/client/components/RimelightBlockPicker.tsx +9 -13
  43. package/src/client/components/RimelightBylinesManager.tsx +438 -0
  44. package/src/client/components/RimelightEditor.tsx +3 -3
  45. package/src/client/components/RimelightPreview.tsx +37 -45
  46. package/src/client/components/RimelightPropertiesPanel.tsx +368 -26
  47. package/src/client/components/RimelightTaxonomyManager.tsx +369 -0
  48. package/src/client/components/RimelightTaxonomyMasterDetail.tsx +412 -0
  49. package/src/client/components/RimelightTemplateEditor.tsx +6 -8
  50. package/src/client/components/editors/CalloutEditor.tsx +2 -4
  51. package/src/client/components/editors/CardsEditor.tsx +2 -4
  52. package/src/client/components/editors/CodeEditor.tsx +1 -3
  53. package/src/client/components/editors/FileTreeEditor.tsx +2 -4
  54. package/src/client/components/editors/ImageEditor.tsx +3 -3
  55. package/src/client/components/editors/ParagraphEditor.tsx +2 -2
  56. package/src/client/components/editors/SceneEditor.tsx +6 -12
  57. package/src/client/components/editors/ScriptEditor.tsx +8 -16
  58. package/src/client/components/editors/SectionEditor.tsx +4 -4
  59. package/src/client/components/editors/StepItemEditor.tsx +2 -2
  60. package/src/client/components/editors/StepsEditor.tsx +2 -4
  61. package/src/client/components/editors/TabItemEditor.tsx +3 -3
  62. package/src/client/components/editors/TableEditor.tsx +5 -5
  63. package/src/client/components/editors/TabsEditor.tsx +2 -4
  64. package/src/client/components/index.ts +3 -0
  65. package/src/client/loader.ts +3 -0
  66. package/src/core/diff.ts +145 -0
  67. package/src/core/excerpt.ts +159 -0
  68. package/src/core/preview.ts +87 -0
  69. package/src/core/registry.ts +0 -39
  70. package/src/core/types/blocks.ts +83 -6
  71. package/src/core/types/bylines.ts +14 -0
  72. package/src/core/types/media.ts +13 -0
  73. package/src/core/types/taxonomies.ts +20 -0
  74. package/src/cron/scheduled.ts +57 -0
  75. package/src/docs/agent.ts +116 -0
  76. package/src/docs/config.ts +25 -0
  77. package/src/docs/index.ts +5 -0
  78. package/src/docs/routing.ts +104 -0
  79. package/src/docs/sidebar.ts +251 -0
  80. package/src/docs/toc.ts +47 -0
  81. package/src/env.d.ts +13 -0
  82. package/src/index.ts +40 -6
  83. package/src/integration.ts +196 -0
  84. package/src/loader/live.ts +254 -0
  85. package/src/markdown/index.ts +0 -1
  86. package/src/mcp/index.ts +228 -0
  87. package/src/schema/bylines.ts +23 -0
  88. package/src/schema/index.ts +3 -0
  89. package/src/schema/page_versions.ts +2 -0
  90. package/src/schema/pages.ts +2 -0
  91. package/src/schema/site_settings.ts +41 -0
  92. package/src/schema/taxonomies.ts +49 -0
  93. package/src/services/bylines.ts +74 -0
  94. package/src/services/search-indexer.ts +61 -0
  95. package/src/services/site-settings.ts +108 -0
  96. package/src/services/taxonomies.ts +276 -0
  97. package/src/storage/index.ts +253 -0
  98. package/src/astro/blocks/DialogueBlock.astro +0 -23
  99. package/src/astro/blocks/SceneBlock.astro +0 -65
  100. package/src/astro/blocks/ScriptBlock.astro +0 -59
  101. package/src/core/template-sync.ts +0 -75
  102. package/src/markdown/parser.ts +0 -347
  103. package/src/migration.ts +0 -155
  104. package/src/search/sync.ts +0 -32
@@ -1,347 +0,0 @@
1
- import type { BaseBlock, HeadingLevel } from "../core/types/blocks"
2
- import type { InlineNode } from "../core/types/inline"
3
-
4
- function genId(): string {
5
- return typeof crypto !== "undefined" && crypto.randomUUID
6
- ? crypto.randomUUID()
7
- : Math.random().toString(36).slice(2, 11)
8
- }
9
-
10
- export function parseInlines(text: string): InlineNode[] {
11
- if (!text) return []
12
-
13
- const inlines: InlineNode[] = []
14
- // Regex to match markdown links: [text](url)
15
- const linkRegex = /\[([^\]]+)\]\(([^)]+)\)/g
16
- let lastIndex = 0
17
- let match: RegExpExecArray | null
18
-
19
- while ((match = linkRegex.exec(text)) !== null) {
20
- const beforeText = text.substring(lastIndex, match.index)
21
- if (beforeText) {
22
- inlines.push(...parseFormattedText(beforeText))
23
- }
24
-
25
- const linkText = match[1] || ""
26
- const linkUrl = match[2] || ""
27
-
28
- inlines.push({
29
- type: "link",
30
- url: linkUrl,
31
- children: [{ type: "text", text: linkText }]
32
- })
33
-
34
- lastIndex = linkRegex.lastIndex
35
- }
36
-
37
- const remainingText = text.substring(lastIndex)
38
- if (remainingText) {
39
- inlines.push(...parseFormattedText(remainingText))
40
- }
41
-
42
- return inlines.length > 0 ? inlines : [{ type: "text", text }]
43
- }
44
-
45
- function parseFormattedText(str: string): InlineNode[] {
46
- if (!str) return []
47
-
48
- // Tokenize bold, italic, code marks
49
- // Format: `code`, **bold**, *italic*, ~~strikethrough~~
50
- const result: InlineNode[] = []
51
- const tokenRegex = /(`[^`]+`|\*\*[^*]+\*\*|\*[^*]+\*|~~[^~]+~~)/g
52
- let lastIdx = 0
53
- let match: RegExpExecArray | null
54
-
55
- while ((match = tokenRegex.exec(str)) !== null) {
56
- const rawBefore = str.substring(lastIdx, match.index)
57
- if (rawBefore) {
58
- result.push({ type: "text", text: rawBefore })
59
- }
60
-
61
- const token = match[1] || ""
62
- if (token.startsWith("`") && token.endsWith("`")) {
63
- result.push({
64
- type: "text",
65
- text: token.slice(1, -1),
66
- marks: ["code"]
67
- })
68
- } else if (token.startsWith("**") && token.endsWith("**")) {
69
- result.push({
70
- type: "text",
71
- text: token.slice(2, -2),
72
- marks: ["bold"]
73
- })
74
- } else if (token.startsWith("*") && token.endsWith("*")) {
75
- result.push({
76
- type: "text",
77
- text: token.slice(1, -1),
78
- marks: ["italic"]
79
- })
80
- } else if (token.startsWith("~~") && token.endsWith("~~")) {
81
- result.push({
82
- type: "text",
83
- text: token.slice(2, -2),
84
- marks: ["strikethrough"]
85
- })
86
- }
87
-
88
- lastIdx = tokenRegex.lastIndex
89
- }
90
-
91
- const remaining = str.substring(lastIdx)
92
- if (remaining) {
93
- result.push({ type: "text", text: remaining })
94
- }
95
-
96
- return result
97
- }
98
-
99
- export function parseMarkdownTable(tableText: string): BaseBlock | null {
100
- const lines = tableText
101
- .trim()
102
- .split("\n")
103
- .map((l) => l.trim())
104
- .filter(Boolean)
105
- if (lines.length < 2) return null
106
-
107
- const parseRow = (line: string) =>
108
- line
109
- .replace(/^\|/, "")
110
- .replace(/\|$/, "")
111
- .split("|")
112
- .map((c) => c.trim())
113
-
114
- const headerCells = parseRow(lines[0] || "")
115
- const alignCells = parseRow(lines[1] || "")
116
-
117
- // Verify second line is alignment separator (e.g. ---, :---:, ---:)
118
- if (!alignCells.every((c) => /^[:-]+$/.test(c))) {
119
- return null
120
- }
121
-
122
- const columns = headerCells.map((header, idx) => {
123
- const alignStr = alignCells[idx] || ""
124
- let align: "left" | "center" | "right" = "left"
125
- if (alignStr.startsWith(":") && alignStr.endsWith(":")) {
126
- align = "center"
127
- } else if (alignStr.endsWith(":")) {
128
- align = "right"
129
- }
130
- return {
131
- key: `col_${idx}`,
132
- header,
133
- align
134
- }
135
- })
136
-
137
- const rows: Record<string, string>[] = []
138
- for (let i = 2; i < lines.length; i++) {
139
- const cells = parseRow(lines[i] || "")
140
- const rowObj: Record<string, string> = {}
141
- columns.forEach((col, idx) => {
142
- rowObj[col.key] = cells[idx] || ""
143
- })
144
- rows.push(rowObj)
145
- }
146
-
147
- return {
148
- id: genId(),
149
- type: "TableBlock",
150
- props: {
151
- columns,
152
- rows,
153
- caption: ""
154
- }
155
- }
156
- }
157
-
158
- export function markdownToBlocks(markdown: string): BaseBlock[] {
159
- if (!markdown) return []
160
-
161
- // Strip frontmatter if present
162
- let cleanMd = markdown.trim()
163
- if (cleanMd.startsWith("---")) {
164
- const endFm = cleanMd.indexOf("---", 3)
165
- if (endFm !== -1) {
166
- cleanMd = cleanMd.substring(endFm + 3).trim()
167
- }
168
- }
169
-
170
- const rootBlocks: BaseBlock[] = []
171
- const sectionStack: { level: number; block: BaseBlock }[] = []
172
-
173
- const appendToCurrentScope = (block: BaseBlock) => {
174
- if (sectionStack.length > 0) {
175
- const topSection = sectionStack[sectionStack.length - 1]!.block
176
- if (!topSection.children) topSection.children = []
177
- if (!topSection.props.children) topSection.props.children = []
178
- topSection.children.push(block)
179
- topSection.props.children.push(block)
180
- } else {
181
- rootBlocks.push(block)
182
- }
183
- }
184
-
185
- const lines = cleanMd.split("\n")
186
- let i = 0
187
-
188
- while (i < lines.length) {
189
- const line = lines[i] || ""
190
- const trimmed = line.trim()
191
-
192
- if (!trimmed) {
193
- i++
194
- continue
195
- }
196
-
197
- // 1. Code Block Fence (```)
198
- if (trimmed.startsWith("```")) {
199
- const lang = trimmed.slice(3).trim()
200
- const codeLines: string[] = []
201
- i++
202
- while (i < lines.length && !(lines[i] || "").trim().startsWith("```")) {
203
- codeLines.push(lines[i] || "")
204
- i++
205
- }
206
- i++ // Skip closing ```
207
- appendToCurrentScope({
208
- id: genId(),
209
- type: "CodeBlock",
210
- props: {
211
- language: lang || "text",
212
- code: codeLines.join("\n"),
213
- caption: ""
214
- }
215
- })
216
- continue
217
- }
218
-
219
- // 2. Headings (# to ######)
220
- const headingMatch = line.match(/^(#{1,6})\s(.*)$/)
221
- if (headingMatch) {
222
- const hashes = headingMatch[1] || ""
223
- const title = (headingMatch[2] || "").trim()
224
- const level = Math.min(6, Math.max(2, hashes.length)) as HeadingLevel
225
-
226
- const newSection: BaseBlock = {
227
- id: genId(),
228
- type: "SectionBlock",
229
- children: [],
230
- props: {
231
- title,
232
- level,
233
- description: "",
234
- children: []
235
- }
236
- }
237
-
238
- // Pop sections with higher or equal level
239
- while (sectionStack.length > 0 && sectionStack[sectionStack.length - 1]!.level >= level) {
240
- sectionStack.pop()
241
- }
242
-
243
- appendToCurrentScope(newSection)
244
- sectionStack.push({ level, block: newSection })
245
- i++
246
- continue
247
- }
248
-
249
- // 3. Callouts / Blockquotes (> [!NOTE], > [!WARNING], > etc.)
250
- if (trimmed.startsWith(">")) {
251
- const calloutLines: string[] = []
252
- while (i < lines.length && (lines[i] || "").trim().startsWith(">")) {
253
- calloutLines.push((lines[i] || "").trim().replace(/^>\s?/, ""))
254
- i++
255
- }
256
- const firstLine = calloutLines[0] || ""
257
- const variantMatch = firstLine.match(/^\[!([A-Z]+)\]/i)
258
- let variant = "info"
259
- let contentLines = calloutLines
260
-
261
- if (variantMatch) {
262
- variant = (variantMatch[1] || "info").toLowerCase()
263
- contentLines = calloutLines.slice(1)
264
- }
265
-
266
- const childMd = contentLines.join("\n").trim()
267
- const calloutChildren = childMd ? markdownToBlocks(childMd) : []
268
-
269
- appendToCurrentScope({
270
- id: genId(),
271
- type: "CalloutBlock",
272
- children: calloutChildren,
273
- props: {
274
- variant,
275
- children: calloutChildren
276
- }
277
- })
278
- continue
279
- }
280
-
281
- // 4. Markdown Table (| Header | Header |)
282
- if (trimmed.startsWith("|") && trimmed.endsWith("|")) {
283
- const tableLines: string[] = []
284
- while (
285
- i < lines.length &&
286
- (lines[i] || "").trim().startsWith("|") &&
287
- (lines[i] || "").trim().endsWith("|")
288
- ) {
289
- tableLines.push(lines[i] || "")
290
- i++
291
- }
292
- const tableBlock = parseMarkdownTable(tableLines.join("\n"))
293
- if (tableBlock) {
294
- appendToCurrentScope(tableBlock)
295
- continue
296
- }
297
- }
298
-
299
- // 5. Image (![alt](src))
300
- const imageMatch = trimmed.match(/^!\[([^\]]*)\]\(([^)]+)\)$/)
301
- if (imageMatch) {
302
- appendToCurrentScope({
303
- id: genId(),
304
- type: "ImageBlock",
305
- props: {
306
- alt: imageMatch[1] || "",
307
- src: imageMatch[2] || "",
308
- caption: ""
309
- }
310
- })
311
- i++
312
- continue
313
- }
314
-
315
- // 6. Regular Paragraph (gather lines until blank line or block start)
316
- const paragraphLines: string[] = []
317
- while (
318
- i < lines.length &&
319
- (lines[i] || "").trim() &&
320
- !(lines[i] || "").trim().startsWith("```") &&
321
- !(lines[i] || "").trim().startsWith("#") &&
322
- !(lines[i] || "").trim().startsWith(">") &&
323
- !((lines[i] || "").trim().startsWith("|") && (lines[i] || "").trim().endsWith("|")) &&
324
- !/^[0-9]+\.\s/.test((lines[i] || "").trim())
325
- ) {
326
- paragraphLines.push(lines[i] || "")
327
- i++
328
- }
329
-
330
- if (paragraphLines.length > 0) {
331
- const paragraphText = paragraphLines.join(" ").trim()
332
- const inlines = parseInlines(paragraphText)
333
- appendToCurrentScope({
334
- id: genId(),
335
- type: "ParagraphBlock",
336
- props: {
337
- text: inlines
338
- }
339
- })
340
- } else {
341
- // Advance if no block consumed
342
- i++
343
- }
344
- }
345
-
346
- return rootBlocks
347
- }
package/src/migration.ts DELETED
@@ -1,155 +0,0 @@
1
- import { sql } from "drizzle-orm"
2
- import { markdownToBlocks } from "./markdown/parser.ts"
3
- import type { BaseBlock } from "./core/types/blocks"
4
-
5
- export interface DocMigrationInput {
6
- slug: string
7
- title: string | Record<string, string>
8
- description?: string | Record<string, string>
9
- markdown: string
10
- properties?: Record<string, any>
11
- postedAt?: Date | null
12
- authorIds?: string[]
13
- }
14
-
15
- export function parseMarkdownWithFrontmatter(raw: string): {
16
- frontmatter: Record<string, any>
17
- body: string
18
- } {
19
- const frontmatter: Record<string, any> = {}
20
- const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/)
21
-
22
- if (!match) {
23
- return { frontmatter, body: raw.trim() }
24
- }
25
-
26
- const yamlBlock = match[1] || ""
27
- const body = (match[2] || "").trim()
28
-
29
- const lines = yamlBlock.split(/\r?\n/)
30
- for (const line of lines) {
31
- const trimmed = line.trim()
32
- if (!trimmed || trimmed.startsWith("#")) continue
33
- const colonIndex = trimmed.indexOf(":")
34
- if (colonIndex !== -1) {
35
- const key = trimmed.slice(0, colonIndex).trim()
36
- let value: any = trimmed.slice(colonIndex + 1).trim()
37
-
38
- if (value === "true") value = true
39
- else if (value === "false") value = false
40
- else if (value === "null") value = null
41
- else if (!isNaN(Number(value)) && value !== "") value = Number(value)
42
- else if (
43
- (value.startsWith('"') && value.endsWith('"')) ||
44
- (value.startsWith("'") && value.endsWith("'"))
45
- ) {
46
- value = value.slice(1, -1)
47
- }
48
-
49
- frontmatter[key] = value
50
- }
51
- }
52
-
53
- return { frontmatter, body }
54
- }
55
-
56
- export function convertMarkdownDocToPageRecord(input: DocMigrationInput) {
57
- const blocks: BaseBlock[] = markdownToBlocks(input.markdown)
58
- const titleObj =
59
- typeof input.title === "string" ? { en: input.title } : input.title || { en: input.slug }
60
- const descObj =
61
- typeof input.description === "string"
62
- ? { en: input.description }
63
- : input.description || undefined
64
-
65
- return {
66
- id: crypto.randomUUID(),
67
- slug: input.slug,
68
- type: "doc" as const,
69
- title: titleObj,
70
- description: descObj,
71
- tags: [],
72
- authorIds: input.authorIds || [],
73
- postedAt: input.postedAt !== undefined ? input.postedAt : new Date(),
74
- createdAt: new Date(),
75
- updatedAt: new Date(),
76
- content: {
77
- blocks,
78
- properties: input.properties || {}
79
- }
80
- }
81
- }
82
-
83
- export async function migrateLegacyPagesToCMSv2(db: any) {
84
- console.log("[Migration] Applying DDL schema updates...")
85
-
86
- await db.execute(sql`
87
- ALTER TABLE pages ADD COLUMN IF NOT EXISTS template_id UUID;
88
- ALTER TABLE pages ADD COLUMN IF NOT EXISTS template_version INTEGER DEFAULT 1 NOT NULL;
89
- ALTER TABLE page_versions ADD COLUMN IF NOT EXISTS version_number INTEGER DEFAULT 1 NOT NULL;
90
-
91
- CREATE UNIQUE INDEX IF NOT EXISTS page_versions_page_id_version_idx
92
- ON page_versions (page_id, version_number);
93
- `)
94
-
95
- console.log("[Migration] Transforming legacy page content in chunked batches...")
96
-
97
- const BATCH_SIZE = 100
98
- let offset = 0
99
-
100
- async function fetchBatch(currentOffset: number) {
101
- const batch = await db.execute(sql`
102
- SELECT id, content FROM pages ORDER BY id LIMIT ${BATCH_SIZE} OFFSET ${currentOffset};
103
- `)
104
-
105
- if (!batch || !batch.rows || batch.rows.length === 0) {
106
- return null
107
- }
108
-
109
- const rows: Array<{ id: string; content: unknown }> = batch.rows
110
- return rows
111
- }
112
-
113
- async function processBatch(pagesList: Array<{ id: string; content: unknown }>) {
114
- const updatePromises = pagesList.map((page) => {
115
- const transformed = transformLegacyContent(page.content)
116
- return db.execute(sql`
117
- UPDATE pages SET content = ${JSON.stringify(transformed)}::jsonb WHERE id = ${page.id}::uuid;
118
- `)
119
- })
120
-
121
- await Promise.all(updatePromises)
122
- }
123
-
124
- async function processAllBatches(currentOffset: number): Promise<void> {
125
- const pagesList = await fetchBatch(currentOffset)
126
- if (!pagesList) return
127
- await processBatch(pagesList)
128
- console.log(`[Migration] Migrated ${currentOffset + BATCH_SIZE} records...`)
129
- return processAllBatches(currentOffset + BATCH_SIZE)
130
- }
131
-
132
- await processAllBatches(offset)
133
-
134
- console.log("[Migration] Migration completed successfully.")
135
- }
136
-
137
- export function transformLegacyContent(raw: unknown) {
138
- if (typeof raw === "object" && raw !== null && "blocks" in raw) return raw
139
- let textContent = ""
140
- try {
141
- textContent = typeof raw === "string" ? raw : JSON.stringify(raw ?? "")
142
- } catch {
143
- textContent = ""
144
- }
145
- return {
146
- blocks: [
147
- {
148
- id: crypto.randomUUID(),
149
- type: "ParagraphBlock",
150
- props: { text: { en: textContent } }
151
- }
152
- ],
153
- properties: {}
154
- }
155
- }
@@ -1,32 +0,0 @@
1
- import type { BaseBlock } from "../core/types/blocks"
2
-
3
- export function extractPlainTextFromBlocks(blocks: BaseBlock[], locale = "en"): string {
4
- const parts: string[] = []
5
-
6
- function walk(nodes: BaseBlock[]) {
7
- for (const node of nodes) {
8
- if (node.props) {
9
- if (typeof node.props.text === "object" && node.props.text !== null) {
10
- const locText = node.props.text[locale] || node.props.text.en
11
- if (locText) parts.push(String(locText))
12
- } else if (typeof node.props.text === "string") {
13
- parts.push(node.props.text)
14
- }
15
-
16
- if (typeof node.props.title === "object" && node.props.title !== null) {
17
- const locTitle = node.props.title[locale] || node.props.title.en
18
- if (locTitle) parts.push(String(locTitle))
19
- } else if (typeof node.props.title === "string") {
20
- parts.push(node.props.title)
21
- }
22
- }
23
-
24
- if (node.children?.length) {
25
- walk(node.children)
26
- }
27
- }
28
- }
29
-
30
- walk(blocks)
31
- return parts.join(" ")
32
- }