@uniweb/build 0.1.2 → 0.1.5

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,247 @@
1
+ /**
2
+ * Asset Resolution Utilities
3
+ *
4
+ * Resolves asset paths in content to file system locations.
5
+ * Supports both relative paths (./image.png) and absolute paths (/images/hero.png).
6
+ *
7
+ * In content-driven sites, markdown is the "code" - local asset references
8
+ * act as implicit imports and should be processed/optimized during build.
9
+ */
10
+
11
+ import { join, dirname, isAbsolute, normalize } from 'node:path'
12
+ import { existsSync } from 'node:fs'
13
+
14
+ // Image extensions we should process
15
+ const IMAGE_EXTENSIONS = ['.png', '.jpg', '.jpeg', '.webp', '.gif', '.svg', '.avif']
16
+
17
+ // Video extensions we can extract posters from
18
+ const VIDEO_EXTENSIONS = ['.mp4', '.webm', '.mov', '.avi', '.mkv']
19
+
20
+ // PDF extension
21
+ const PDF_EXTENSION = '.pdf'
22
+
23
+ /**
24
+ * Check if a path is an external URL
25
+ */
26
+ function isExternalUrl(src) {
27
+ return /^(https?:)?\/\//.test(src) || src.startsWith('data:')
28
+ }
29
+
30
+ /**
31
+ * Check if a path is a processable image
32
+ */
33
+ function isImagePath(src) {
34
+ const ext = src.split('.').pop()?.toLowerCase()
35
+ return IMAGE_EXTENSIONS.some(e => e.slice(1) === ext)
36
+ }
37
+
38
+ /**
39
+ * Check if a path is a video file
40
+ */
41
+ function isVideoPath(src) {
42
+ const ext = '.' + (src.split('.').pop()?.toLowerCase() || '')
43
+ return VIDEO_EXTENSIONS.includes(ext)
44
+ }
45
+
46
+ /**
47
+ * Check if a path is a PDF file
48
+ */
49
+ function isPdfPath(src) {
50
+ return src.toLowerCase().endsWith(PDF_EXTENSION)
51
+ }
52
+
53
+ /**
54
+ * Resolve an asset path to absolute file system path
55
+ *
56
+ * @param {string} src - Original source path from content
57
+ * @param {string} contextPath - Path of the file containing the reference
58
+ * @param {string} siteRoot - Site root directory
59
+ * @returns {Object} Resolution result
60
+ */
61
+ export function resolveAssetPath(src, contextPath, siteRoot) {
62
+ // External URLs - don't process
63
+ if (isExternalUrl(src)) {
64
+ return { src, resolved: null, external: true }
65
+ }
66
+
67
+ // Already absolute path on filesystem
68
+ if (isAbsolute(src)) {
69
+ return { src, resolved: src, external: false }
70
+ }
71
+
72
+ let resolved
73
+
74
+ // Relative paths: ./image.png or ../image.png or just image.png
75
+ if (src.startsWith('./') || src.startsWith('../') || !src.startsWith('/')) {
76
+ const contextDir = dirname(contextPath)
77
+ resolved = normalize(join(contextDir, src))
78
+ }
79
+ // Absolute site paths: /images/hero.png
80
+ else if (src.startsWith('/')) {
81
+ // Check public folder first, then assets folder
82
+ const publicPath = join(siteRoot, 'public', src)
83
+ const assetsPath = join(siteRoot, 'assets', src)
84
+
85
+ if (existsSync(publicPath)) {
86
+ resolved = publicPath
87
+ } else if (existsSync(assetsPath)) {
88
+ resolved = assetsPath
89
+ } else {
90
+ // Default to public folder path even if it doesn't exist yet
91
+ resolved = publicPath
92
+ }
93
+ }
94
+
95
+ return {
96
+ src,
97
+ resolved,
98
+ external: false,
99
+ isImage: isImagePath(src),
100
+ isVideo: isVideoPath(src),
101
+ isPdf: isPdfPath(src)
102
+ }
103
+ }
104
+
105
+ /**
106
+ * Walk a ProseMirror document and collect all asset references
107
+ *
108
+ * @param {Object} doc - ProseMirror document
109
+ * @param {Function} visitor - Callback for each asset: (node, path, attrName) => void
110
+ * attrName is 'src', 'poster', or 'preview'
111
+ * @param {string} [path=''] - Current path in document (for debugging)
112
+ */
113
+ export function walkContentAssets(doc, visitor, path = '') {
114
+ if (!doc) return
115
+
116
+ // Check for image nodes
117
+ if (doc.type === 'image' && doc.attrs?.src) {
118
+ visitor(doc, path, 'src')
119
+
120
+ // Also collect explicit poster/preview attributes as assets
121
+ if (doc.attrs.poster && !isExternalUrl(doc.attrs.poster)) {
122
+ visitor({ type: 'image', attrs: { src: doc.attrs.poster } }, path, 'poster')
123
+ }
124
+ if (doc.attrs.preview && !isExternalUrl(doc.attrs.preview)) {
125
+ visitor({ type: 'image', attrs: { src: doc.attrs.preview } }, path, 'preview')
126
+ }
127
+ }
128
+
129
+ // Recurse into content
130
+ if (doc.content && Array.isArray(doc.content)) {
131
+ doc.content.forEach((child, index) => {
132
+ walkContentAssets(child, visitor, `${path}/content[${index}]`)
133
+ })
134
+ }
135
+
136
+ // Handle marks (links can have images)
137
+ if (doc.marks && Array.isArray(doc.marks)) {
138
+ doc.marks.forEach((mark, index) => {
139
+ if (mark.attrs?.src) {
140
+ visitor(mark, `${path}/marks[${index}]`, 'src')
141
+ }
142
+ })
143
+ }
144
+ }
145
+
146
+ /**
147
+ * Process all assets in a section's content and frontmatter
148
+ *
149
+ * @param {Object} section - Section object with content and params
150
+ * @param {string} markdownPath - Path to the markdown file
151
+ * @param {string} siteRoot - Site root directory
152
+ * @returns {Object} Asset collection result
153
+ * - assets: Asset manifest mapping original paths to resolved info
154
+ * - hasExplicitPoster: Set of video src paths that have explicit poster attributes
155
+ * - hasExplicitPreview: Set of PDF src paths that have explicit preview attributes
156
+ */
157
+ export function collectSectionAssets(section, markdownPath, siteRoot) {
158
+ const assets = {}
159
+ const hasExplicitPoster = new Set()
160
+ const hasExplicitPreview = new Set()
161
+
162
+ // Track current image node's src when we encounter poster/preview
163
+ let currentImageSrc = null
164
+
165
+ // Collect from ProseMirror content
166
+ if (section.content) {
167
+ walkContentAssets(section.content, (node, path, attrName) => {
168
+ const result = resolveAssetPath(node.attrs.src, markdownPath, siteRoot)
169
+
170
+ if (attrName === 'src') {
171
+ // Main src attribute - track it for potential poster/preview
172
+ currentImageSrc = node.attrs.src
173
+
174
+ // Check if this image has explicit poster/preview
175
+ if (node.attrs.poster) {
176
+ hasExplicitPoster.add(node.attrs.src)
177
+ }
178
+ if (node.attrs.preview) {
179
+ hasExplicitPreview.add(node.attrs.src)
180
+ }
181
+ }
182
+
183
+ if (!result.external && result.resolved) {
184
+ assets[node.attrs.src] = {
185
+ original: node.attrs.src,
186
+ resolved: result.resolved,
187
+ isImage: result.isImage,
188
+ isVideo: result.isVideo,
189
+ isPdf: result.isPdf
190
+ }
191
+ }
192
+ })
193
+ }
194
+
195
+ // Collect from frontmatter params (common media fields)
196
+ const mediaFields = [
197
+ 'image', 'background', 'backgroundImage', 'thumbnail',
198
+ 'poster', 'avatar', 'logo', 'icon',
199
+ 'video', 'videoSrc', 'media', 'file', 'pdf', 'document'
200
+ ]
201
+
202
+ for (const field of mediaFields) {
203
+ const value = section.params?.[field]
204
+ if (typeof value === 'string' && value) {
205
+ const result = resolveAssetPath(value, markdownPath, siteRoot)
206
+ if (!result.external && result.resolved) {
207
+ assets[value] = {
208
+ original: value,
209
+ resolved: result.resolved,
210
+ isImage: result.isImage,
211
+ isVideo: result.isVideo,
212
+ isPdf: result.isPdf
213
+ }
214
+ }
215
+ }
216
+ }
217
+
218
+ return { assets, hasExplicitPoster, hasExplicitPreview }
219
+ }
220
+
221
+ /**
222
+ * Merge multiple asset collection results
223
+ *
224
+ * @param {...Object} collections - Asset collection results from collectSectionAssets
225
+ * @returns {Object} Merged collection with combined assets and sets
226
+ */
227
+ export function mergeAssetCollections(...collections) {
228
+ const merged = {
229
+ assets: {},
230
+ hasExplicitPoster: new Set(),
231
+ hasExplicitPreview: new Set()
232
+ }
233
+
234
+ for (const collection of collections) {
235
+ // Handle both old format (plain object) and new format (with sets)
236
+ if (collection.assets) {
237
+ Object.assign(merged.assets, collection.assets)
238
+ collection.hasExplicitPoster?.forEach(p => merged.hasExplicitPoster.add(p))
239
+ collection.hasExplicitPreview?.forEach(p => merged.hasExplicitPreview.add(p))
240
+ } else {
241
+ // Legacy: plain asset manifest
242
+ Object.assign(merged.assets, collection)
243
+ }
244
+ }
245
+
246
+ return merged
247
+ }
@@ -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'