@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.
- 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/docs.js +217 -0
- package/src/images.js +5 -3
- package/src/index.js +11 -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,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'
|