@uniweb/build 0.1.2 → 0.1.4
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/README.md +189 -11
- package/package.json +24 -4
- package/src/dev/index.js +9 -0
- package/src/dev/plugin.js +206 -0
- package/src/images.js +5 -3
- package/src/index.js +5 -0
- package/src/prerender.js +310 -0
- package/src/site/advanced-processors.js +393 -0
- package/src/site/asset-processor.js +281 -0
- package/src/site/assets.js +247 -0
- package/src/site/content-collector.js +344 -0
- package/src/site/index.js +32 -0
- package/src/site/plugin.js +497 -0
- package/src/vite-foundation-plugin.js +7 -3
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Content Collector
|
|
3
|
+
*
|
|
4
|
+
* Collects site content from a pages/ directory structure:
|
|
5
|
+
* - site.yml: Site configuration
|
|
6
|
+
* - pages/: Directory of page folders
|
|
7
|
+
* - page.yml: Page metadata
|
|
8
|
+
* - *.md: Section content with YAML frontmatter
|
|
9
|
+
*
|
|
10
|
+
* Section frontmatter reserved properties:
|
|
11
|
+
* - type: Component type (e.g., "Hero", "Features")
|
|
12
|
+
* - preset: Preset configuration name
|
|
13
|
+
* - input: Input field mapping
|
|
14
|
+
* - props: Additional component props (merged with other params)
|
|
15
|
+
*
|
|
16
|
+
* Note: `component` is supported as an alias for `type` (deprecated)
|
|
17
|
+
*
|
|
18
|
+
* Uses @uniweb/content-reader for markdown → ProseMirror conversion
|
|
19
|
+
* when available, otherwise uses a simplified parser.
|
|
20
|
+
*
|
|
21
|
+
* @module @uniweb/build/site
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { readFile, readdir, stat } from 'node:fs/promises'
|
|
25
|
+
import { join, parse } from 'node:path'
|
|
26
|
+
import { existsSync } from 'node:fs'
|
|
27
|
+
import yaml from 'js-yaml'
|
|
28
|
+
import { collectSectionAssets, mergeAssetCollections } from './assets.js'
|
|
29
|
+
|
|
30
|
+
// Try to import content-reader, fall back to simplified parser
|
|
31
|
+
let markdownToProseMirror
|
|
32
|
+
try {
|
|
33
|
+
const contentReader = await import('@uniweb/content-reader')
|
|
34
|
+
markdownToProseMirror = contentReader.markdownToProseMirror
|
|
35
|
+
} catch {
|
|
36
|
+
// Simplified fallback - just wraps content as text
|
|
37
|
+
markdownToProseMirror = (markdown) => ({
|
|
38
|
+
type: 'doc',
|
|
39
|
+
content: [
|
|
40
|
+
{
|
|
41
|
+
type: 'paragraph',
|
|
42
|
+
content: [{ type: 'text', text: markdown.trim() }]
|
|
43
|
+
}
|
|
44
|
+
]
|
|
45
|
+
})
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Parse YAML string using js-yaml
|
|
50
|
+
*/
|
|
51
|
+
function parseYaml(yamlString) {
|
|
52
|
+
try {
|
|
53
|
+
return yaml.load(yamlString) || {}
|
|
54
|
+
} catch (err) {
|
|
55
|
+
console.warn('[content-collector] YAML parse error:', err.message)
|
|
56
|
+
return {}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Read and parse a YAML file
|
|
62
|
+
*/
|
|
63
|
+
async function readYamlFile(filePath) {
|
|
64
|
+
try {
|
|
65
|
+
const content = await readFile(filePath, 'utf8')
|
|
66
|
+
return parseYaml(content)
|
|
67
|
+
} catch (err) {
|
|
68
|
+
if (err.code === 'ENOENT') return {}
|
|
69
|
+
throw err
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Check if a file is a markdown file
|
|
75
|
+
*/
|
|
76
|
+
function isMarkdownFile(filename) {
|
|
77
|
+
return filename.endsWith('.md') && !filename.startsWith('_')
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Parse numeric prefix from filename (e.g., "1-hero.md" → { prefix: "1", name: "hero" })
|
|
82
|
+
*/
|
|
83
|
+
function parseNumericPrefix(filename) {
|
|
84
|
+
const match = filename.match(/^(\d+(?:\.\d+)*)-?(.*)$/)
|
|
85
|
+
if (match) {
|
|
86
|
+
return { prefix: match[1], name: match[2] || match[1] }
|
|
87
|
+
}
|
|
88
|
+
return { prefix: null, name: filename }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Compare filenames for sorting by numeric prefix
|
|
93
|
+
*/
|
|
94
|
+
function compareFilenames(a, b) {
|
|
95
|
+
const { prefix: prefixA } = parseNumericPrefix(parse(a).name)
|
|
96
|
+
const { prefix: prefixB } = parseNumericPrefix(parse(b).name)
|
|
97
|
+
|
|
98
|
+
if (!prefixA && !prefixB) return a.localeCompare(b)
|
|
99
|
+
if (!prefixA) return 1
|
|
100
|
+
if (!prefixB) return -1
|
|
101
|
+
|
|
102
|
+
const partsA = prefixA.split('.').map(Number)
|
|
103
|
+
const partsB = prefixB.split('.').map(Number)
|
|
104
|
+
|
|
105
|
+
for (let i = 0; i < Math.max(partsA.length, partsB.length); i++) {
|
|
106
|
+
const numA = partsA[i] ?? 0
|
|
107
|
+
const numB = partsB[i] ?? 0
|
|
108
|
+
if (numA !== numB) return numA - numB
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return 0
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Process a markdown file into a section
|
|
116
|
+
*
|
|
117
|
+
* @param {string} filePath - Path to markdown file
|
|
118
|
+
* @param {string} id - Section ID
|
|
119
|
+
* @param {string} siteRoot - Site root directory for asset resolution
|
|
120
|
+
* @returns {Object} Section data with assets manifest
|
|
121
|
+
*/
|
|
122
|
+
async function processMarkdownFile(filePath, id, siteRoot) {
|
|
123
|
+
const content = await readFile(filePath, 'utf8')
|
|
124
|
+
let frontMatter = {}
|
|
125
|
+
let markdown = content
|
|
126
|
+
|
|
127
|
+
// Extract frontmatter
|
|
128
|
+
if (content.trim().startsWith('---')) {
|
|
129
|
+
const parts = content.split('---\n')
|
|
130
|
+
if (parts.length >= 3) {
|
|
131
|
+
frontMatter = parseYaml(parts[1])
|
|
132
|
+
markdown = parts.slice(2).join('---\n')
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const { type, component, preset, input, props, ...params } = frontMatter
|
|
137
|
+
|
|
138
|
+
// Convert markdown to ProseMirror
|
|
139
|
+
const proseMirrorContent = markdownToProseMirror(markdown)
|
|
140
|
+
|
|
141
|
+
const section = {
|
|
142
|
+
id,
|
|
143
|
+
component: type || component || 'Section',
|
|
144
|
+
preset,
|
|
145
|
+
input,
|
|
146
|
+
params: { ...params, ...props },
|
|
147
|
+
content: proseMirrorContent,
|
|
148
|
+
subsections: []
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Collect assets referenced in this section
|
|
152
|
+
const assetCollection = collectSectionAssets(section, filePath, siteRoot)
|
|
153
|
+
|
|
154
|
+
return { section, assetCollection }
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Build section hierarchy from flat list
|
|
159
|
+
*/
|
|
160
|
+
function buildSectionHierarchy(sections) {
|
|
161
|
+
const sectionMap = new Map()
|
|
162
|
+
const topLevel = []
|
|
163
|
+
|
|
164
|
+
// First pass: create map
|
|
165
|
+
for (const section of sections) {
|
|
166
|
+
sectionMap.set(section.id, section)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Second pass: build hierarchy
|
|
170
|
+
for (const section of sections) {
|
|
171
|
+
if (!section.id.includes('.')) {
|
|
172
|
+
topLevel.push(section)
|
|
173
|
+
continue
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const parts = section.id.split('.')
|
|
177
|
+
const parentId = parts.slice(0, -1).join('.')
|
|
178
|
+
const parent = sectionMap.get(parentId)
|
|
179
|
+
|
|
180
|
+
if (parent) {
|
|
181
|
+
parent.subsections.push(section)
|
|
182
|
+
} else {
|
|
183
|
+
// Orphan subsection - add to top level
|
|
184
|
+
topLevel.push(section)
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return topLevel
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Process a page directory
|
|
193
|
+
*
|
|
194
|
+
* @param {string} pagePath - Path to page directory
|
|
195
|
+
* @param {string} pageName - Name of the page
|
|
196
|
+
* @param {string} siteRoot - Site root directory for asset resolution
|
|
197
|
+
* @returns {Object} Page data with assets manifest
|
|
198
|
+
*/
|
|
199
|
+
async function processPage(pagePath, pageName, siteRoot) {
|
|
200
|
+
const pageConfig = await readYamlFile(join(pagePath, 'page.yml'))
|
|
201
|
+
|
|
202
|
+
if (pageConfig.hidden) return null
|
|
203
|
+
|
|
204
|
+
// Get markdown files
|
|
205
|
+
const files = await readdir(pagePath)
|
|
206
|
+
const mdFiles = files.filter(isMarkdownFile).sort(compareFilenames)
|
|
207
|
+
|
|
208
|
+
// Process sections and collect assets
|
|
209
|
+
const sections = []
|
|
210
|
+
let pageAssetCollection = {
|
|
211
|
+
assets: {},
|
|
212
|
+
hasExplicitPoster: new Set(),
|
|
213
|
+
hasExplicitPreview: new Set()
|
|
214
|
+
}
|
|
215
|
+
let lastModified = null
|
|
216
|
+
|
|
217
|
+
for (const file of mdFiles) {
|
|
218
|
+
const { name } = parse(file)
|
|
219
|
+
const { prefix } = parseNumericPrefix(name)
|
|
220
|
+
const id = prefix || name
|
|
221
|
+
|
|
222
|
+
const { section, assetCollection } = await processMarkdownFile(join(pagePath, file), id, siteRoot)
|
|
223
|
+
sections.push(section)
|
|
224
|
+
pageAssetCollection = mergeAssetCollections(pageAssetCollection, assetCollection)
|
|
225
|
+
|
|
226
|
+
// Track last modified time for sitemap
|
|
227
|
+
const fileStat = await stat(join(pagePath, file))
|
|
228
|
+
if (!lastModified || fileStat.mtime > lastModified) {
|
|
229
|
+
lastModified = fileStat.mtime
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Build hierarchy
|
|
234
|
+
const hierarchicalSections = buildSectionHierarchy(sections)
|
|
235
|
+
|
|
236
|
+
// Determine route
|
|
237
|
+
let route = '/' + pageName
|
|
238
|
+
if (pageName === 'home' || pageName === 'index') {
|
|
239
|
+
route = '/'
|
|
240
|
+
} else if (pageName.startsWith('@')) {
|
|
241
|
+
route = '/' + pageName
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Extract SEO config from page
|
|
245
|
+
const { seo = {}, ...restConfig } = pageConfig
|
|
246
|
+
|
|
247
|
+
return {
|
|
248
|
+
page: {
|
|
249
|
+
route,
|
|
250
|
+
title: pageConfig.title || pageName,
|
|
251
|
+
description: pageConfig.description || '',
|
|
252
|
+
order: pageConfig.order,
|
|
253
|
+
lastModified: lastModified?.toISOString(),
|
|
254
|
+
seo: {
|
|
255
|
+
noindex: seo.noindex || false,
|
|
256
|
+
image: seo.image || null,
|
|
257
|
+
changefreq: seo.changefreq || null,
|
|
258
|
+
priority: seo.priority || null
|
|
259
|
+
},
|
|
260
|
+
sections: hierarchicalSections
|
|
261
|
+
},
|
|
262
|
+
assetCollection: pageAssetCollection
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Collect all site content
|
|
268
|
+
*
|
|
269
|
+
* @param {string} sitePath - Path to site directory
|
|
270
|
+
* @returns {Promise<Object>} Site content object with assets manifest
|
|
271
|
+
*/
|
|
272
|
+
export async function collectSiteContent(sitePath) {
|
|
273
|
+
const pagesPath = join(sitePath, 'pages')
|
|
274
|
+
|
|
275
|
+
// Read site config
|
|
276
|
+
const siteConfig = await readYamlFile(join(sitePath, 'site.yml'))
|
|
277
|
+
const themeConfig = await readYamlFile(join(sitePath, 'theme.yml'))
|
|
278
|
+
|
|
279
|
+
// Check if pages directory exists
|
|
280
|
+
if (!existsSync(pagesPath)) {
|
|
281
|
+
return {
|
|
282
|
+
config: siteConfig,
|
|
283
|
+
theme: themeConfig,
|
|
284
|
+
pages: [],
|
|
285
|
+
assets: {}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Get page directories
|
|
290
|
+
const entries = await readdir(pagesPath)
|
|
291
|
+
const pages = []
|
|
292
|
+
let siteAssetCollection = {
|
|
293
|
+
assets: {},
|
|
294
|
+
hasExplicitPoster: new Set(),
|
|
295
|
+
hasExplicitPreview: new Set()
|
|
296
|
+
}
|
|
297
|
+
let header = null
|
|
298
|
+
let footer = null
|
|
299
|
+
|
|
300
|
+
for (const entry of entries) {
|
|
301
|
+
const entryPath = join(pagesPath, entry)
|
|
302
|
+
const stats = await stat(entryPath)
|
|
303
|
+
|
|
304
|
+
if (!stats.isDirectory()) continue
|
|
305
|
+
|
|
306
|
+
const result = await processPage(entryPath, entry, sitePath)
|
|
307
|
+
if (!result) continue
|
|
308
|
+
|
|
309
|
+
const { page, assetCollection } = result
|
|
310
|
+
siteAssetCollection = mergeAssetCollections(siteAssetCollection, assetCollection)
|
|
311
|
+
|
|
312
|
+
// Handle special pages
|
|
313
|
+
if (entry === '@header' || page.route === '/@header') {
|
|
314
|
+
header = page
|
|
315
|
+
} else if (entry === '@footer' || page.route === '/@footer') {
|
|
316
|
+
footer = page
|
|
317
|
+
} else {
|
|
318
|
+
pages.push(page)
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// Sort pages by order
|
|
323
|
+
pages.sort((a, b) => (a.order ?? 999) - (b.order ?? 999))
|
|
324
|
+
|
|
325
|
+
// Log asset summary
|
|
326
|
+
const assetCount = Object.keys(siteAssetCollection.assets).length
|
|
327
|
+
const explicitCount = siteAssetCollection.hasExplicitPoster.size + siteAssetCollection.hasExplicitPreview.size
|
|
328
|
+
if (assetCount > 0) {
|
|
329
|
+
console.log(`[content-collector] Found ${assetCount} asset references${explicitCount > 0 ? ` (${explicitCount} with explicit poster/preview)` : ''}`)
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
return {
|
|
333
|
+
config: siteConfig,
|
|
334
|
+
theme: themeConfig,
|
|
335
|
+
pages,
|
|
336
|
+
header,
|
|
337
|
+
footer,
|
|
338
|
+
assets: siteAssetCollection.assets,
|
|
339
|
+
hasExplicitPoster: siteAssetCollection.hasExplicitPoster,
|
|
340
|
+
hasExplicitPreview: siteAssetCollection.hasExplicitPreview
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
export default collectSiteContent
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Site Build Tools
|
|
3
|
+
*
|
|
4
|
+
* Vite plugins and utilities for building Uniweb sites.
|
|
5
|
+
*
|
|
6
|
+
* @module @uniweb/build/site
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export { siteContentPlugin, default } from './plugin.js'
|
|
10
|
+
export { collectSiteContent } from './content-collector.js'
|
|
11
|
+
export {
|
|
12
|
+
resolveAssetPath,
|
|
13
|
+
walkContentAssets,
|
|
14
|
+
collectSectionAssets,
|
|
15
|
+
mergeAssetCollections
|
|
16
|
+
} from './assets.js'
|
|
17
|
+
export {
|
|
18
|
+
processAsset,
|
|
19
|
+
processAssets,
|
|
20
|
+
rewriteContentPaths,
|
|
21
|
+
rewriteParamPaths,
|
|
22
|
+
rewriteSiteContentPaths
|
|
23
|
+
} from './asset-processor.js'
|
|
24
|
+
export {
|
|
25
|
+
extractVideoPoster,
|
|
26
|
+
generatePdfThumbnail,
|
|
27
|
+
processAdvancedAsset,
|
|
28
|
+
processAdvancedAssets,
|
|
29
|
+
checkFfmpeg,
|
|
30
|
+
isVideoFile,
|
|
31
|
+
isPdfFile
|
|
32
|
+
} from './advanced-processors.js'
|