@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.
@@ -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'