@uniweb/build 0.15.5 → 0.15.7

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniweb/build",
3
- "version": "0.15.5",
3
+ "version": "0.15.7",
4
4
  "description": "Build tooling for the Uniweb Component Web Platform",
5
5
  "type": "module",
6
6
  "exports": {
@@ -60,11 +60,12 @@
60
60
  "sharp": "^0.33.2",
61
61
  "yaml": "^2.5.0",
62
62
  "@uniweb/theming": "0.1.14",
63
- "@uniweb/content-writer": "0.2.7"
63
+ "@uniweb/projections": "0.1.2",
64
+ "@uniweb/content-writer": "0.2.9"
64
65
  },
65
66
  "optionalDependencies": {
66
- "@uniweb/content-reader": "1.1.15",
67
- "@uniweb/runtime": "0.8.36",
67
+ "@uniweb/runtime": "0.8.38",
68
+ "@uniweb/content-reader": "1.1.17",
68
69
  "@uniweb/schemas": "0.2.4"
69
70
  },
70
71
  "peerDependencies": {
@@ -74,7 +75,7 @@
74
75
  "@tailwindcss/vite": "^4.0.0",
75
76
  "@vitejs/plugin-react": "^4.0.0 || ^5.0.0",
76
77
  "vite-plugin-svgr": "^4.0.0",
77
- "@uniweb/core": "0.7.29"
78
+ "@uniweb/core": "0.7.31"
78
79
  },
79
80
  "peerDependenciesMeta": {
80
81
  "vite": {
@@ -1,54 +1,2 @@
1
- /**
2
- * Generate search index for a file-based collection.
3
- *
4
- * Collection cascade files (`data/{name}.json`) contain all non-deferred fields.
5
- * If `search.fetchDetail: true` is set, the caller is responsible for merging
6
- * per-record detail files into each item before calling this function.
7
- */
8
-
9
- /**
10
- * @param {string} name - Collection name (e.g. "articles")
11
- * @param {Object} config - Collection config from site.yml (config.collections[name])
12
- * @param {Object} collectionData - Parsed cascade JSON (`data/{name}.json`)
13
- * @param {string} locale - Locale code (e.g. "en")
14
- * @returns {Object} Collection search index
15
- */
16
- export function generateCollectionIndex(name, config, collectionData, locale) {
17
- const fields = config.search?.fields || ['title']
18
- const weight = config.search?.weight ?? 0.7
19
- const items = collectionData?.items || []
20
-
21
- const entries = items.map(item => {
22
- const content = fields.map(f => item[f] || '').filter(Boolean).join(' ')
23
- const slug = item.slug || item.id || String(item.title || '').toLowerCase().replace(/\s+/g, '-')
24
- return {
25
- id: `collection:${name}:${slug}`,
26
- type: 'collection',
27
- collection: name,
28
- route: `${config.route}/${slug}`,
29
- title: item.title || item.name || slug,
30
- content,
31
- excerpt: content.length > 160
32
- ? content.slice(0, 160).trim() + '…'
33
- : content,
34
- weight,
35
- item: pickDisplayFields(item),
36
- }
37
- })
38
-
39
- return {
40
- type: 'collection',
41
- collection: name,
42
- locale,
43
- generated: new Date().toISOString(),
44
- entries,
45
- }
46
- }
47
-
48
- function pickDisplayFields(item) {
49
- const { slug, title, name, date, image, author, excerpt, role } = item
50
- return Object.fromEntries(
51
- Object.entries({ slug, title, name, date, image, author, excerpt, role })
52
- .filter(([, v]) => v != null)
53
- )
54
- }
1
+ /** @deprecated Import from `@uniweb/projections` — kept so existing paths resolve. */
2
+ export { generateCollectionIndex } from '@uniweb/projections/search'
@@ -1,335 +1,2 @@
1
- /**
2
- * Extract searchable content from site-content.json
3
- *
4
- * Walks through all pages and sections, extracting text content
5
- * for search indexing. Reuses patterns from i18n extraction.
6
- */
7
-
8
- /**
9
- * Extract all searchable content from site
10
- * @param {Object} siteContent - Parsed site-content.json
11
- * @param {Object} options - Extraction options
12
- * @param {boolean} [options.pages=true] - Include page metadata
13
- * @param {boolean} [options.sections=true] - Include section content
14
- * @param {boolean} [options.headings=true] - Include headings
15
- * @param {boolean} [options.paragraphs=true] - Include paragraphs
16
- * @param {boolean} [options.links=true] - Include link labels
17
- * @param {boolean} [options.lists=true] - Include list items
18
- * @param {Array<string>} [options.excludeRoutes=[]] - Routes to exclude
19
- * @param {Array<string>} [options.excludeComponents=[]] - Components to exclude
20
- * @returns {Array<Object>} Array of search entries
21
- */
22
- export function extractSearchContent(siteContent, options = {}) {
23
- const {
24
- pages: includePagesFlag = true,
25
- sections: includeSections = true,
26
- headings: includeHeadings = true,
27
- paragraphs: includeParagraphs = true,
28
- links: includeLinks = true,
29
- lists: includeLists = true,
30
- excludeRoutes = [],
31
- excludeComponents = []
32
- } = options
33
-
34
- const entries = []
35
-
36
- for (const page of siteContent.pages || []) {
37
- const pageRoute = page.route || '/'
38
-
39
- // Skip excluded routes
40
- if (excludeRoutes.some(r => pageRoute.startsWith(r))) {
41
- continue
42
- }
43
-
44
- // Skip pages marked as noindex
45
- if (page.seo?.noindex) {
46
- continue
47
- }
48
-
49
- // Extract page-level entry
50
- if (includePagesFlag) {
51
- const pageEntry = extractFromPage(page)
52
- if (pageEntry) {
53
- entries.push(pageEntry)
54
- }
55
- }
56
-
57
- // Extract section-level entries
58
- if (includeSections) {
59
- for (const section of page.sections || []) {
60
- const sectionEntries = extractFromSection(section, page, {
61
- includeHeadings,
62
- includeParagraphs,
63
- includeLinks,
64
- includeLists,
65
- excludeComponents
66
- })
67
- entries.push(...sectionEntries)
68
- }
69
- }
70
- }
71
-
72
- return entries
73
- }
74
-
75
- /**
76
- * Extract search entry from page metadata
77
- * @param {Object} page - Page data
78
- * @returns {Object|null} Search entry or null
79
- */
80
- function extractFromPage(page) {
81
- const route = page.route || '/'
82
- const title = page.title || ''
83
- const description = page.description || ''
84
- const keywords = page.keywords || page.seo?.keywords || []
85
-
86
- // Skip pages with no meaningful content
87
- if (!title && !description) {
88
- return null
89
- }
90
-
91
- return {
92
- id: `page:${route}`,
93
- type: 'page',
94
- route,
95
- title,
96
- description,
97
- keywords: Array.isArray(keywords) ? keywords : [keywords].filter(Boolean),
98
- content: [title, description].filter(Boolean).join(' '),
99
- // Boost factor for search ranking (pages are more important)
100
- weight: route === '/' ? 1.0 : 0.8
101
- }
102
- }
103
-
104
- /**
105
- * Extract search entries from a section (and subsections)
106
- * @param {Object} section - Section data
107
- * @param {Object} page - Parent page
108
- * @param {Object} options - Extraction options
109
- * @returns {Array<Object>} Array of search entries
110
- */
111
- function extractFromSection(section, page, options) {
112
- const entries = []
113
- const {
114
- includeHeadings,
115
- includeParagraphs,
116
- includeLinks,
117
- includeLists,
118
- excludeComponents
119
- } = options
120
-
121
- const sectionId = section.id || 'unknown'
122
- const component = section.component || section.type || 'unknown'
123
-
124
- // Skip excluded components
125
- if (excludeComponents.includes(component)) {
126
- return entries
127
- }
128
-
129
- // Extract text from ProseMirror content
130
- const textParts = []
131
- let sectionTitle = ''
132
-
133
- if (section.content?.type === 'doc') {
134
- const extracted = extractFromProseMirrorDoc(section.content, {
135
- includeHeadings,
136
- includeParagraphs,
137
- includeLinks,
138
- includeLists
139
- })
140
-
141
- sectionTitle = extracted.title || ''
142
- textParts.push(...extracted.textParts)
143
- }
144
-
145
- // Also check params for title (from YAML frontmatter)
146
- if (!sectionTitle && section.params?.title) {
147
- sectionTitle = section.params.title
148
- }
149
-
150
- // Build content string
151
- const content = textParts.join(' ').trim()
152
-
153
- // Create entry if there's meaningful content
154
- if (sectionTitle || content) {
155
- entries.push({
156
- id: `section:${page.route}:${sectionId}`,
157
- type: 'section',
158
- route: page.route,
159
- sectionId,
160
- anchor: `Section${sectionId}`,
161
- component,
162
- title: sectionTitle,
163
- pageTitle: page.title || '',
164
- content,
165
- // Generate excerpt (first ~160 chars)
166
- excerpt: generateExcerpt(content, 160),
167
- // Section weight is lower than page
168
- weight: 0.6
169
- })
170
- }
171
-
172
- // Recursively process subsections
173
- for (const subsection of section.subsections || []) {
174
- const subEntries = extractFromSection(subsection, page, options)
175
- entries.push(...subEntries)
176
- }
177
-
178
- return entries
179
- }
180
-
181
- /**
182
- * Extract text from ProseMirror document
183
- * @param {Object} doc - ProseMirror document
184
- * @param {Object} options - Extraction options
185
- * @returns {Object} Extracted content { title, textParts }
186
- */
187
- function extractFromProseMirrorDoc(doc, options) {
188
- const { includeHeadings, includeParagraphs, includeLinks, includeLists } = options
189
- const textParts = []
190
- let title = ''
191
- let foundFirstHeading = false
192
-
193
- if (!doc.content) {
194
- return { title, textParts }
195
- }
196
-
197
- for (const node of doc.content) {
198
- if (node.type === 'heading') {
199
- const text = extractTextFromNode(node)
200
- if (!text) continue
201
-
202
- // First H1 becomes the title
203
- if (!foundFirstHeading && node.attrs?.level === 1) {
204
- title = text
205
- foundFirstHeading = true
206
- }
207
-
208
- if (includeHeadings) {
209
- textParts.push(text)
210
- }
211
- } else if (node.type === 'paragraph') {
212
- if (includeParagraphs) {
213
- const text = extractTextFromNode(node)
214
- if (text) {
215
- textParts.push(text)
216
- }
217
- }
218
-
219
- // Extract link labels separately if requested
220
- if (includeLinks) {
221
- const links = extractLinksFromNode(node)
222
- for (const link of links) {
223
- if (link.label) {
224
- textParts.push(link.label)
225
- }
226
- }
227
- }
228
- } else if ((node.type === 'bulletList' || node.type === 'orderedList') && includeLists) {
229
- const listTexts = extractFromList(node)
230
- textParts.push(...listTexts)
231
- }
232
- }
233
-
234
- return { title, textParts }
235
- }
236
-
237
- /**
238
- * Extract all text content from a node
239
- * @param {Object} node - ProseMirror node
240
- * @returns {string} Text content
241
- */
242
- function extractTextFromNode(node) {
243
- if (!node.content) return ''
244
-
245
- const texts = []
246
-
247
- for (const child of node.content) {
248
- if (child.type === 'text') {
249
- texts.push(child.text || '')
250
- } else if (child.content) {
251
- // Recurse for nested nodes
252
- texts.push(extractTextFromNode(child))
253
- }
254
- }
255
-
256
- return texts.join('').trim()
257
- }
258
-
259
- /**
260
- * Extract links from a paragraph node
261
- * @param {Object} node - Paragraph node
262
- * @returns {Array<{label: string, href: string}>} Links
263
- */
264
- function extractLinksFromNode(node) {
265
- const links = []
266
-
267
- if (!node.content) return links
268
-
269
- for (const child of node.content) {
270
- if (child.type === 'text' && child.marks) {
271
- const linkMark = child.marks.find(m => m.type === 'link')
272
- if (linkMark) {
273
- links.push({
274
- label: child.text || '',
275
- href: linkMark.attrs?.href || ''
276
- })
277
- }
278
- }
279
- }
280
-
281
- return links
282
- }
283
-
284
- /**
285
- * Extract text from list items
286
- * @param {Object} listNode - List node
287
- * @returns {Array<string>} List item texts
288
- */
289
- function extractFromList(listNode) {
290
- const texts = []
291
-
292
- if (!listNode.content) return texts
293
-
294
- for (const listItem of listNode.content) {
295
- if (listItem.type === 'listItem' && listItem.content) {
296
- for (const child of listItem.content) {
297
- if (child.type === 'paragraph') {
298
- const text = extractTextFromNode(child)
299
- if (text) {
300
- texts.push(text)
301
- }
302
- }
303
- }
304
- }
305
- }
306
-
307
- return texts
308
- }
309
-
310
- /**
311
- * Generate excerpt from content
312
- * @param {string} content - Full content
313
- * @param {number} maxLength - Maximum length
314
- * @returns {string} Excerpt
315
- */
316
- function generateExcerpt(content, maxLength = 160) {
317
- if (!content) return ''
318
-
319
- // Normalize whitespace
320
- const normalized = content.replace(/\s+/g, ' ').trim()
321
-
322
- if (normalized.length <= maxLength) {
323
- return normalized
324
- }
325
-
326
- // Find a good break point (word boundary)
327
- let breakPoint = normalized.lastIndexOf(' ', maxLength)
328
- if (breakPoint < maxLength * 0.5) {
329
- breakPoint = maxLength
330
- }
331
-
332
- return normalized.slice(0, breakPoint).trim() + '…'
333
- }
334
-
335
- export default extractSearchContent
1
+ /** @deprecated Import from `@uniweb/projections` — kept so existing paths resolve. */
2
+ export { extractSearchContent, extractSearchContent as default } from '@uniweb/projections/search'
@@ -1,108 +1,8 @@
1
- /**
2
- * Generate search index from site content
3
- *
4
- * Creates a JSON search index file that can be loaded at runtime
5
- * for client-side search functionality.
6
- */
7
-
8
- import { resolveDefaultLocale } from '@uniweb/core'
9
- import { extractSearchContent } from './extract.js'
10
-
11
- /**
12
- * Generate search index for a site
13
- * @param {Object} siteContent - Parsed site-content.json
14
- * @param {Object} options - Generation options
15
- * @param {string} [options.locale] - Locale code for this index
16
- * @param {Object} [options.extract] - Options passed to extractSearchContent
17
- * @param {Object} [options.search] - Search configuration from site.yml
18
- * @returns {Object} Search index object
19
- */
20
- export function generateSearchIndex(siteContent, options = {}) {
21
- const {
22
- locale = siteContent.config?.activeLocale || resolveDefaultLocale(siteContent.config),
23
- extract: extractOptions = {},
24
- search: searchConfig = {}
25
- } = options
26
-
27
- // Merge search config with extract options
28
- const mergedExtractOptions = {
29
- ...extractOptions,
30
- excludeRoutes: searchConfig.exclude?.routes || extractOptions.excludeRoutes || [],
31
- excludeComponents: searchConfig.exclude?.components || extractOptions.excludeComponents || []
32
- }
33
-
34
- // Check what to include from config
35
- if (searchConfig.include) {
36
- mergedExtractOptions.pages = searchConfig.include.pages !== false
37
- mergedExtractOptions.sections = searchConfig.include.sections !== false
38
- mergedExtractOptions.headings = searchConfig.include.headings !== false
39
- mergedExtractOptions.paragraphs = searchConfig.include.paragraphs !== false
40
- mergedExtractOptions.links = searchConfig.include.links !== false
41
- mergedExtractOptions.lists = searchConfig.include.lists !== false
42
- }
43
-
44
- // Extract searchable content
45
- const entries = extractSearchContent(siteContent, mergedExtractOptions)
46
-
47
- // Build the index
48
- const index = {
49
- version: '1.0',
50
- locale,
51
- generated: new Date().toISOString(),
52
- count: entries.length,
53
- entries
54
- }
55
-
56
- return index
57
- }
58
-
59
- /**
60
- * Check if search is enabled for a site
61
- * @param {Object} siteContent - Parsed site-content.json
62
- * @returns {boolean}
63
- */
64
- export function isSearchEnabled(siteContent) {
65
- // Search is enabled by default unless explicitly disabled
66
- return siteContent.config?.search?.enabled !== false
67
- }
68
-
69
- /**
70
- * Get search configuration from site content
71
- * @param {Object} siteContent - Parsed site-content.json
72
- * @returns {Object} Search configuration
73
- */
74
- export function getSearchConfig(siteContent) {
75
- const config = siteContent.config?.search || {}
76
-
77
- return {
78
- enabled: config.enabled !== false,
79
- include: {
80
- pages: config.include?.pages !== false,
81
- sections: config.include?.sections !== false,
82
- headings: config.include?.headings !== false,
83
- paragraphs: config.include?.paragraphs !== false,
84
- links: config.include?.links !== false,
85
- lists: config.include?.lists !== false
86
- },
87
- exclude: {
88
- routes: config.exclude?.routes || [],
89
- components: config.exclude?.components || []
90
- }
91
- }
92
- }
93
-
94
- /**
95
- * Get the search index filename for a locale
96
- * @param {string} locale - Locale code
97
- * @param {string} defaultLocale - Default locale code
98
- * @returns {string} Filename
99
- */
100
- export function getSearchIndexFilename(locale, defaultLocale) {
101
- // Default locale uses root filename, others use locale prefix path
102
- if (locale === defaultLocale) {
103
- return 'search-index.json'
104
- }
105
- return `${locale}/search-index.json`
106
- }
107
-
108
- export default generateSearchIndex
1
+ /** @deprecated Import from `@uniweb/projections` — kept so existing paths resolve. */
2
+ export {
3
+ generateSearchIndex,
4
+ generateSearchIndex as default,
5
+ isSearchEnabled,
6
+ getSearchConfig,
7
+ getSearchIndexFilename
8
+ } from '@uniweb/projections/search'
@@ -1,34 +1,20 @@
1
1
  /**
2
2
  * Search Index Generation Module
3
3
  *
4
- * Generates search indexes for Uniweb sites at build time.
5
- * The generated indexes can be loaded at runtime for client-side search.
4
+ * Re-export of `@uniweb/projections/search`. The implementation moved there so
5
+ * one copy serves every publisher of a site the CLI and the app both import
6
+ * it, and the backend stores the result rather than generating its own. This
7
+ * shim keeps `@uniweb/build/search` and every existing call site working.
6
8
  *
7
9
  * @module @uniweb/build/search
8
- *
9
- * @example
10
- * import { generateSearchIndex, isSearchEnabled } from '@uniweb/build/search'
11
- *
12
- * // Check if search is enabled
13
- * if (isSearchEnabled(siteContent)) {
14
- * // Generate index for current locale
15
- * const index = generateSearchIndex(siteContent, {
16
- * locale: 'en'
17
- * })
18
- *
19
- * // Write to file
20
- * writeFileSync('dist/search-index.json', JSON.stringify(index))
21
- * }
22
10
  */
23
11
 
24
12
  export {
25
13
  extractSearchContent,
26
- extractSearchContent as default
27
- } from './extract.js'
28
-
29
- export {
14
+ extractSearchContent as default,
30
15
  generateSearchIndex,
31
16
  isSearchEnabled,
32
17
  getSearchConfig,
33
- getSearchIndexFilename
34
- } from './generate.js'
18
+ getSearchIndexFilename,
19
+ generateCollectionIndex
20
+ } from '@uniweb/projections/search'
@@ -19,15 +19,25 @@
19
19
 
20
20
  import { writeFile, readFile, mkdir, cp } from 'node:fs/promises'
21
21
  import { existsSync } from 'node:fs'
22
- import { join, resolve } from 'node:path'
22
+ import { join, resolve, dirname } from 'node:path'
23
23
 
24
24
  import { resolveDefaultLocale } from '@uniweb/core'
25
25
  import { collectSiteContent } from './content-collector.js'
26
26
  import { processCollections, writeCollectionFiles } from './collection-processor.js'
27
27
  import { processAssets, rewriteSiteContentPaths } from './asset-processor.js'
28
28
  import { processAdvancedAssets } from './advanced-processors.js'
29
- import { generateSearchIndex } from '../search/generate.js'
30
- import { generateCollectionIndex } from '../search/collections.js'
29
+ import {
30
+ generateSearchIndex,
31
+ generateCollectionIndex,
32
+ mergeSearchIndexes,
33
+ getSearchIndexFilename,
34
+ renderSiteIndex,
35
+ renderPageMarkdown,
36
+ resolveAgentsConfig,
37
+ selectIndexablePages,
38
+ pageMarkdownFilename,
39
+ INDEX_FILENAME
40
+ } from '@uniweb/projections'
31
41
 
32
42
  /**
33
43
  * Build the site data outputs needed by `uniweb deploy` (link-mode).
@@ -215,6 +225,7 @@ export async function buildSiteData({
215
225
 
216
226
  // Collection indexes — one per routed + search-configured collection
217
227
  const collections = finalContent.config?.collections || {}
228
+ const collectionIndexes = []
218
229
  for (const [collName, collConfig] of Object.entries(collections)) {
219
230
  if (!collConfig.search?.enabled || !collConfig.route) continue
220
231
  const cascadeFile = join(resolvedDistDir, 'data', `${collName}.json`)
@@ -226,9 +237,75 @@ export async function buildSiteData({
226
237
  continue
227
238
  }
228
239
  const collIndex = generateCollectionIndex(collName, collConfig, collectionData, defaultLocale)
240
+ collectionIndexes.push(collIndex)
229
241
  await writeFile(join(searchDir, `${collName}.json`), JSON.stringify(collIndex))
230
242
  }
243
+
244
+ // The single-file form, for the BROWSER lane.
245
+ //
246
+ // The split files above serve a server that loads only the parts a query
247
+ // needs. Kit's client-side `index` provider needs all of it, and asks for
248
+ // `search-index.json` — so without this, a site published through this lane
249
+ // 404s on its own search index and degrades to no results. Emitting both
250
+ // from the same entries is one extra serialization and means the client
251
+ // works identically on every lane, with no host configuration describing
252
+ // where the index lives.
253
+ await writeFile(
254
+ join(resolvedDistDir, getSearchIndexFilename(defaultLocale, defaultLocale)),
255
+ JSON.stringify(mergeSearchIndexes(pagesIndex, collectionIndexes))
256
+ )
231
257
  }
232
258
 
259
+ // 6. Agent projections — `llms.txt` and one `.md` per page.
260
+ //
261
+ // Emitted here as well as in the vite plugin because both lanes publish
262
+ // a site, and an artifact derived from site content has to exist
263
+ // whichever lane produced it. A projection present after one publish and
264
+ // absent after another is worse than none: agents are told to rely on it.
265
+ //
266
+ // Ungated by `features:` — projections are free, so they carry no
267
+ // billing intent and no entitlement to check downstream.
268
+ await writeProjections(finalContent, resolvedDistDir)
269
+
233
270
  return { siteContent: finalContent, distDir: resolvedDistDir }
234
271
  }
272
+
273
+ /**
274
+ * Write the agent projections into `distDir`.
275
+ *
276
+ * Mirrors the vite plugin's `emitProjections`, differing only in how bytes
277
+ * reach disk (`writeFile` vs. Rollup's `emitFile`) — the generators, options
278
+ * and filenames come from `@uniweb/projections`, so the two lanes cannot
279
+ * disagree about what they produce.
280
+ *
281
+ * @param {Object} siteContent - Final site content for the default locale
282
+ * @param {string} distDir - Resolved output directory
283
+ * @returns {Promise<void>}
284
+ */
285
+ async function writeProjections(siteContent, distDir) {
286
+ const agents = resolveAgentsConfig(siteContent?.config)
287
+ if (!agents.index && !agents.markdown) return
288
+ if (!siteContent?.pages?.length) return
289
+
290
+ const defaultLocale = resolveDefaultLocale(siteContent.config)
291
+ const options = {
292
+ baseUrl: siteContent.config?.seo?.baseUrl || '',
293
+ locale: siteContent.config?.activeLocale || defaultLocale,
294
+ defaultLocale
295
+ }
296
+
297
+ if (agents.index) {
298
+ const index = renderSiteIndex(siteContent, { ...options, exclude: agents.exclude })
299
+ await writeFile(join(distDir, INDEX_FILENAME), index)
300
+ }
301
+
302
+ if (!agents.markdown) return
303
+
304
+ for (const page of selectIndexablePages(siteContent.pages, { exclude: agents.exclude })) {
305
+ const markdown = renderPageMarkdown(page)
306
+ if (!markdown) continue
307
+ const target = join(distDir, pageMarkdownFilename(page.route))
308
+ await mkdir(dirname(target), { recursive: true })
309
+ await writeFile(target, markdown)
310
+ }
311
+ }