@uniweb/build 0.1.33 → 0.2.0
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 +3 -3
- package/src/docs.js +2 -2
- package/src/prerender.js +3 -1
- package/src/site/content-collector.js +198 -17
- package/src/theme/shade-generator.js +276 -16
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniweb/build",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Build tooling for the Uniweb Component Web Platform",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -51,7 +51,7 @@
|
|
|
51
51
|
},
|
|
52
52
|
"optionalDependencies": {
|
|
53
53
|
"@uniweb/content-reader": "1.0.4",
|
|
54
|
-
"@uniweb/runtime": "0.2.
|
|
54
|
+
"@uniweb/runtime": "0.2.20"
|
|
55
55
|
},
|
|
56
56
|
"peerDependencies": {
|
|
57
57
|
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0",
|
|
@@ -60,7 +60,7 @@
|
|
|
60
60
|
"@tailwindcss/vite": "^4.0.0",
|
|
61
61
|
"@vitejs/plugin-react": "^4.0.0 || ^5.0.0",
|
|
62
62
|
"vite-plugin-svgr": "^4.0.0",
|
|
63
|
-
"@uniweb/core": "0.
|
|
63
|
+
"@uniweb/core": "0.2.0"
|
|
64
64
|
},
|
|
65
65
|
"peerDependenciesMeta": {
|
|
66
66
|
"vite": {
|
package/src/docs.js
CHANGED
|
@@ -165,8 +165,8 @@ export async function generateDocs(foundationDir, options = {}) {
|
|
|
165
165
|
|
|
166
166
|
let schema
|
|
167
167
|
|
|
168
|
-
// Try to load schema.json from dist
|
|
169
|
-
const schemaPath = join(foundationDir, 'dist', 'schema.json')
|
|
168
|
+
// Try to load schema.json from dist/meta (where foundation build outputs it)
|
|
169
|
+
const schemaPath = join(foundationDir, 'dist', 'meta', 'schema.json')
|
|
170
170
|
|
|
171
171
|
if (!fromSource && existsSync(schemaPath)) {
|
|
172
172
|
// Load from existing schema.json
|
package/src/prerender.js
CHANGED
|
@@ -306,9 +306,11 @@ function renderBlock(block) {
|
|
|
306
306
|
}
|
|
307
307
|
|
|
308
308
|
// Wrapper props
|
|
309
|
+
// Use stableId for DOM ID if available (stable across reordering)
|
|
309
310
|
const theme = block.themeName
|
|
311
|
+
const sectionId = block.stableId || block.id
|
|
310
312
|
const wrapperProps = {
|
|
311
|
-
id: `
|
|
313
|
+
id: `section-${sectionId}`,
|
|
312
314
|
className: theme || ''
|
|
313
315
|
}
|
|
314
316
|
|
|
@@ -67,6 +67,90 @@ function extractRouteParam(folderName) {
|
|
|
67
67
|
return match ? match[1] : null
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
+
// ─────────────────────────────────────────────────────────────────
|
|
71
|
+
// Version Detection
|
|
72
|
+
// ─────────────────────────────────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Check if a folder name represents a version (e.g., v1, v2, v1.0, v2.1)
|
|
76
|
+
* @param {string} folderName - The folder name to check
|
|
77
|
+
* @returns {boolean}
|
|
78
|
+
*/
|
|
79
|
+
function isVersionFolder(folderName) {
|
|
80
|
+
return /^v\d+(\.\d+)?$/.test(folderName)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Parse version info from folder name
|
|
85
|
+
* @param {string} folderName - The folder name (e.g., "v1", "v2.1")
|
|
86
|
+
* @returns {Object} Version info { id, major, minor, sortKey }
|
|
87
|
+
*/
|
|
88
|
+
function parseVersionInfo(folderName) {
|
|
89
|
+
const match = folderName.match(/^v(\d+)(?:\.(\d+))?$/)
|
|
90
|
+
if (!match) return null
|
|
91
|
+
|
|
92
|
+
const major = parseInt(match[1], 10)
|
|
93
|
+
const minor = match[2] ? parseInt(match[2], 10) : 0
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
id: folderName,
|
|
97
|
+
major,
|
|
98
|
+
minor,
|
|
99
|
+
sortKey: major * 1000 + minor // For sorting: v2.1 > v2.0 > v1.9
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Detect if a set of folders contains version folders
|
|
105
|
+
* @param {Array<string>} folderNames - List of folder names
|
|
106
|
+
* @returns {Array<Object>|null} Sorted version infos (highest first) or null if not versioned
|
|
107
|
+
*/
|
|
108
|
+
function detectVersions(folderNames) {
|
|
109
|
+
const versions = folderNames
|
|
110
|
+
.filter(isVersionFolder)
|
|
111
|
+
.map(parseVersionInfo)
|
|
112
|
+
.filter(Boolean)
|
|
113
|
+
|
|
114
|
+
if (versions.length === 0) return null
|
|
115
|
+
|
|
116
|
+
// Sort by version (highest first)
|
|
117
|
+
versions.sort((a, b) => b.sortKey - a.sortKey)
|
|
118
|
+
|
|
119
|
+
return versions
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Build version metadata from detected versions and page.yml config
|
|
124
|
+
* @param {Array<Object>} detectedVersions - Detected version infos
|
|
125
|
+
* @param {Object} pageConfig - page.yml configuration
|
|
126
|
+
* @returns {Object} Version metadata { versions, latestId, scope }
|
|
127
|
+
*/
|
|
128
|
+
function buildVersionMetadata(detectedVersions, pageConfig = {}) {
|
|
129
|
+
const configVersions = pageConfig.versions || {}
|
|
130
|
+
|
|
131
|
+
// Build version list with metadata
|
|
132
|
+
const versions = detectedVersions.map((v, index) => {
|
|
133
|
+
const config = configVersions[v.id] || {}
|
|
134
|
+
const isLatest = config.latest === true || (index === 0 && !Object.values(configVersions).some(c => c.latest))
|
|
135
|
+
|
|
136
|
+
return {
|
|
137
|
+
id: v.id,
|
|
138
|
+
label: config.label || v.id,
|
|
139
|
+
latest: isLatest,
|
|
140
|
+
deprecated: config.deprecated || false,
|
|
141
|
+
sortKey: v.sortKey
|
|
142
|
+
}
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
// Find the latest version
|
|
146
|
+
const latestVersion = versions.find(v => v.latest) || versions[0]
|
|
147
|
+
|
|
148
|
+
return {
|
|
149
|
+
versions,
|
|
150
|
+
latestId: latestVersion?.id || null
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
70
154
|
/**
|
|
71
155
|
* Parse YAML string using js-yaml
|
|
72
156
|
*/
|
|
@@ -144,11 +228,12 @@ function compareFilenames(a, b) {
|
|
|
144
228
|
* Process a markdown file into a section
|
|
145
229
|
*
|
|
146
230
|
* @param {string} filePath - Path to markdown file
|
|
147
|
-
* @param {string} id - Section ID
|
|
231
|
+
* @param {string} id - Section ID (numeric/positional)
|
|
148
232
|
* @param {string} siteRoot - Site root directory for asset resolution
|
|
233
|
+
* @param {string} defaultStableId - Default stable ID from filename (can be overridden in frontmatter)
|
|
149
234
|
* @returns {Object} Section data with assets manifest
|
|
150
235
|
*/
|
|
151
|
-
async function processMarkdownFile(filePath, id, siteRoot) {
|
|
236
|
+
async function processMarkdownFile(filePath, id, siteRoot, defaultStableId = null) {
|
|
152
237
|
const content = await readFile(filePath, 'utf8')
|
|
153
238
|
let frontMatter = {}
|
|
154
239
|
let markdown = content
|
|
@@ -162,7 +247,7 @@ async function processMarkdownFile(filePath, id, siteRoot) {
|
|
|
162
247
|
}
|
|
163
248
|
}
|
|
164
249
|
|
|
165
|
-
const { type, component, preset, input, props, fetch, data, ...params } = frontMatter
|
|
250
|
+
const { type, component, preset, input, props, fetch, data, id: frontmatterId, ...params } = frontMatter
|
|
166
251
|
|
|
167
252
|
// Convert markdown to ProseMirror
|
|
168
253
|
const proseMirrorContent = markdownToProseMirror(markdown)
|
|
@@ -176,8 +261,13 @@ async function processMarkdownFile(filePath, id, siteRoot) {
|
|
|
176
261
|
resolvedFetch = { collection: collectionName }
|
|
177
262
|
}
|
|
178
263
|
|
|
264
|
+
// Stable ID for scroll targeting: frontmatter id > filename-derived > null
|
|
265
|
+
// This ID is stable across reordering (unlike the positional id)
|
|
266
|
+
const stableId = frontmatterId || defaultStableId || null
|
|
267
|
+
|
|
179
268
|
const section = {
|
|
180
269
|
id,
|
|
270
|
+
stableId,
|
|
181
271
|
component: type || component || 'Section',
|
|
182
272
|
preset,
|
|
183
273
|
input,
|
|
@@ -290,7 +380,8 @@ async function processExplicitSections(sectionsConfig, pagePath, siteRoot, paren
|
|
|
290
380
|
}
|
|
291
381
|
|
|
292
382
|
// Process the section
|
|
293
|
-
|
|
383
|
+
// Use sectionName as stable ID for scroll targeting (e.g., "hero", "features")
|
|
384
|
+
const { section, assetCollection: sectionAssets } = await processMarkdownFile(filePath, id, siteRoot, sectionName)
|
|
294
385
|
assetCollection = mergeAssetCollections(assetCollection, sectionAssets)
|
|
295
386
|
|
|
296
387
|
// Track last modified
|
|
@@ -326,9 +417,10 @@ async function processExplicitSections(sectionsConfig, pagePath, siteRoot, paren
|
|
|
326
417
|
* @param {boolean} options.isIndex - Whether this page is the index for its parent route
|
|
327
418
|
* @param {string} options.parentRoute - The parent route (e.g., '/' or '/docs')
|
|
328
419
|
* @param {Object} options.parentFetch - Parent page's fetch config (for dynamic routes)
|
|
420
|
+
* @param {Object} options.versionContext - Version context from parent { version, versionMeta, scope }
|
|
329
421
|
* @returns {Object} Page data with assets manifest
|
|
330
422
|
*/
|
|
331
|
-
async function processPage(pagePath, pageName, siteRoot, { isIndex = false, parentRoute = '/', parentFetch = null } = {}) {
|
|
423
|
+
async function processPage(pagePath, pageName, siteRoot, { isIndex = false, parentRoute = '/', parentFetch = null, versionContext = null } = {}) {
|
|
332
424
|
const pageConfig = await readYamlFile(join(pagePath, 'page.yml'))
|
|
333
425
|
|
|
334
426
|
// Note: We no longer skip hidden pages here - they still exist as valid pages,
|
|
@@ -354,10 +446,13 @@ async function processPage(pagePath, pageName, siteRoot, { isIndex = false, pare
|
|
|
354
446
|
const sections = []
|
|
355
447
|
for (const file of mdFiles) {
|
|
356
448
|
const { name } = parse(file)
|
|
357
|
-
const { prefix } = parseNumericPrefix(name)
|
|
449
|
+
const { prefix, name: stableName } = parseNumericPrefix(name)
|
|
358
450
|
const id = prefix || name
|
|
451
|
+
// Use the name part (after prefix) as stable ID for scroll targeting
|
|
452
|
+
// e.g., "1-intro.md" → stableId: "intro", "2-features.md" → stableId: "features"
|
|
453
|
+
const stableId = stableName || name
|
|
359
454
|
|
|
360
|
-
const { section, assetCollection } = await processMarkdownFile(join(pagePath, file), id, siteRoot)
|
|
455
|
+
const { section, assetCollection } = await processMarkdownFile(join(pagePath, file), id, siteRoot, stableId)
|
|
361
456
|
sections.push(section)
|
|
362
457
|
pageAssetCollection = mergeAssetCollections(pageAssetCollection, assetCollection)
|
|
363
458
|
|
|
@@ -415,6 +510,7 @@ async function processPage(pagePath, pageName, siteRoot, { isIndex = false, pare
|
|
|
415
510
|
return {
|
|
416
511
|
page: {
|
|
417
512
|
route,
|
|
513
|
+
id: pageConfig.id || null, // Stable page ID for page: links (survives reorganization)
|
|
418
514
|
isIndex, // Marks this page as the index for its parent route (accessible at parentRoute)
|
|
419
515
|
title: pageConfig.title || pageName,
|
|
420
516
|
description: pageConfig.description || '',
|
|
@@ -427,6 +523,11 @@ async function processPage(pagePath, pageName, siteRoot, { isIndex = false, pare
|
|
|
427
523
|
paramName, // e.g., "slug" from [slug]
|
|
428
524
|
parentSchema, // e.g., "articles" - the data array to iterate over
|
|
429
525
|
|
|
526
|
+
// Version metadata (if within a versioned section)
|
|
527
|
+
version: versionContext?.version || null,
|
|
528
|
+
versionMeta: versionContext?.versionMeta || null,
|
|
529
|
+
versionScope: versionContext?.scope || null,
|
|
530
|
+
|
|
430
531
|
// Navigation options
|
|
431
532
|
hidden: pageConfig.hidden || false, // Hide from all navigation
|
|
432
533
|
hideInHeader: pageConfig.hideInHeader || false, // Hide from header nav
|
|
@@ -504,9 +605,10 @@ function determineIndexPage(orderConfig, availableFolders) {
|
|
|
504
605
|
* @param {string} siteRoot - Site root directory for asset resolution
|
|
505
606
|
* @param {Object} orderConfig - { pages: [...], index: 'name' } from parent's config
|
|
506
607
|
* @param {Object} parentFetch - Parent page's fetch config (for dynamic child routes)
|
|
507
|
-
* @
|
|
608
|
+
* @param {Object} versionContext - Version context from parent { version, versionMeta }
|
|
609
|
+
* @returns {Promise<Object>} { pages, assetCollection, header, footer, left, right, notFound, versionedScopes }
|
|
508
610
|
*/
|
|
509
|
-
async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig = {}, parentFetch = null) {
|
|
611
|
+
async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig = {}, parentFetch = null, versionContext = null) {
|
|
510
612
|
const entries = await readdir(dirPath)
|
|
511
613
|
const pages = []
|
|
512
614
|
let assetCollection = {
|
|
@@ -519,6 +621,7 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
|
|
|
519
621
|
let left = null
|
|
520
622
|
let right = null
|
|
521
623
|
let notFound = null
|
|
624
|
+
const versionedScopes = new Map() // scope route → versionMeta
|
|
522
625
|
|
|
523
626
|
// First pass: discover all page folders and read their order values
|
|
524
627
|
const pageFolders = []
|
|
@@ -533,6 +636,7 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
|
|
|
533
636
|
name: entry,
|
|
534
637
|
path: entryPath,
|
|
535
638
|
order: pageConfig.order,
|
|
639
|
+
pageConfig,
|
|
536
640
|
childOrderConfig: {
|
|
537
641
|
pages: pageConfig.pages,
|
|
538
642
|
index: pageConfig.index
|
|
@@ -540,6 +644,72 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
|
|
|
540
644
|
})
|
|
541
645
|
}
|
|
542
646
|
|
|
647
|
+
// Check if this directory contains version folders (versioned section)
|
|
648
|
+
const folderNames = pageFolders.map(f => f.name)
|
|
649
|
+
const detectedVersions = detectVersions(folderNames)
|
|
650
|
+
|
|
651
|
+
// If versioned section, handle version folders specially
|
|
652
|
+
if (detectedVersions && !versionContext) {
|
|
653
|
+
// Read parent page.yml for version metadata
|
|
654
|
+
const parentConfig = await readYamlFile(join(dirPath, 'page.yml'))
|
|
655
|
+
const versionMeta = buildVersionMetadata(detectedVersions, parentConfig)
|
|
656
|
+
|
|
657
|
+
// Record this versioned scope
|
|
658
|
+
versionedScopes.set(parentRoute, versionMeta)
|
|
659
|
+
|
|
660
|
+
// Process version folders
|
|
661
|
+
for (const folder of pageFolders) {
|
|
662
|
+
const { name: entry, path: entryPath, childOrderConfig, pageConfig } = folder
|
|
663
|
+
|
|
664
|
+
if (isVersionFolder(entry)) {
|
|
665
|
+
// This is a version folder
|
|
666
|
+
const versionInfo = versionMeta.versions.find(v => v.id === entry)
|
|
667
|
+
const isLatest = versionInfo?.latest || false
|
|
668
|
+
|
|
669
|
+
// For latest version, use parent route directly
|
|
670
|
+
// For other versions, add version prefix to route
|
|
671
|
+
const versionRoute = isLatest ? parentRoute : `${parentRoute}/${entry}`
|
|
672
|
+
|
|
673
|
+
// Recurse into version folder with version context
|
|
674
|
+
const subResult = await collectPagesRecursive(
|
|
675
|
+
entryPath,
|
|
676
|
+
versionRoute,
|
|
677
|
+
siteRoot,
|
|
678
|
+
childOrderConfig,
|
|
679
|
+
parentFetch,
|
|
680
|
+
{
|
|
681
|
+
version: versionInfo,
|
|
682
|
+
versionMeta,
|
|
683
|
+
scope: parentRoute // The route where versioning is scoped
|
|
684
|
+
}
|
|
685
|
+
)
|
|
686
|
+
|
|
687
|
+
pages.push(...subResult.pages)
|
|
688
|
+
assetCollection = mergeAssetCollections(assetCollection, subResult.assetCollection)
|
|
689
|
+
// Merge any nested versioned scopes (shouldn't happen often, but possible)
|
|
690
|
+
for (const [scope, meta] of subResult.versionedScopes) {
|
|
691
|
+
versionedScopes.set(scope, meta)
|
|
692
|
+
}
|
|
693
|
+
} else if (!entry.startsWith('@')) {
|
|
694
|
+
// Non-version, non-special folders in a versioned section
|
|
695
|
+
// These could be shared across versions - process normally
|
|
696
|
+
const result = await processPage(entryPath, entry, siteRoot, {
|
|
697
|
+
isIndex: false,
|
|
698
|
+
parentRoute,
|
|
699
|
+
parentFetch
|
|
700
|
+
})
|
|
701
|
+
|
|
702
|
+
if (result) {
|
|
703
|
+
pages.push(result.page)
|
|
704
|
+
assetCollection = mergeAssetCollections(assetCollection, result.assetCollection)
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
// Return early - we've handled all children
|
|
710
|
+
return { pages, assetCollection, header, footer, left, right, notFound, versionedScopes }
|
|
711
|
+
}
|
|
712
|
+
|
|
543
713
|
// Determine which page is the index for this level
|
|
544
714
|
const regularFolders = pageFolders.filter(f => !f.name.startsWith('@'))
|
|
545
715
|
const indexPageName = determineIndexPage(orderConfig, regularFolders)
|
|
@@ -555,7 +725,8 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
|
|
|
555
725
|
const result = await processPage(entryPath, entry, siteRoot, {
|
|
556
726
|
isIndex: isIndex && !isSpecial,
|
|
557
727
|
parentRoute,
|
|
558
|
-
parentFetch
|
|
728
|
+
parentFetch,
|
|
729
|
+
versionContext
|
|
559
730
|
})
|
|
560
731
|
|
|
561
732
|
if (result) {
|
|
@@ -587,14 +758,19 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
|
|
|
587
758
|
const childParentRoute = isIndex ? parentRoute : page.route
|
|
588
759
|
// Pass this page's fetch config to children (for dynamic routes that inherit parent data)
|
|
589
760
|
const childFetch = page.fetch || parentFetch
|
|
590
|
-
|
|
761
|
+
// Pass version context to children (maintains version scope)
|
|
762
|
+
const subResult = await collectPagesRecursive(entryPath, childParentRoute, siteRoot, childOrderConfig, childFetch, versionContext)
|
|
591
763
|
pages.push(...subResult.pages)
|
|
592
764
|
assetCollection = mergeAssetCollections(assetCollection, subResult.assetCollection)
|
|
765
|
+
// Merge any versioned scopes from children
|
|
766
|
+
for (const [scope, meta] of subResult.versionedScopes) {
|
|
767
|
+
versionedScopes.set(scope, meta)
|
|
768
|
+
}
|
|
593
769
|
}
|
|
594
770
|
}
|
|
595
771
|
}
|
|
596
772
|
|
|
597
|
-
return { pages, assetCollection, header, footer, left, right, notFound }
|
|
773
|
+
return { pages, assetCollection, header, footer, left, right, notFound, versionedScopes }
|
|
598
774
|
}
|
|
599
775
|
|
|
600
776
|
/**
|
|
@@ -606,11 +782,11 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
|
|
|
606
782
|
async function loadFoundationVars(foundationPath) {
|
|
607
783
|
if (!foundationPath) return {}
|
|
608
784
|
|
|
609
|
-
// Try dist/schema.json first (built foundation), then
|
|
610
|
-
const distSchemaPath = join(foundationPath, 'dist', 'schema.json')
|
|
611
|
-
const
|
|
785
|
+
// Try dist/meta/schema.json first (built foundation), then root schema.json
|
|
786
|
+
const distSchemaPath = join(foundationPath, 'dist', 'meta', 'schema.json')
|
|
787
|
+
const rootSchemaPath = join(foundationPath, 'schema.json')
|
|
612
788
|
|
|
613
|
-
const schemaPath = existsSync(distSchemaPath) ? distSchemaPath :
|
|
789
|
+
const schemaPath = existsSync(distSchemaPath) ? distSchemaPath : rootSchemaPath
|
|
614
790
|
|
|
615
791
|
if (!existsSync(schemaPath)) {
|
|
616
792
|
return {}
|
|
@@ -672,7 +848,7 @@ export async function collectSiteContent(sitePath, options = {}) {
|
|
|
672
848
|
}
|
|
673
849
|
|
|
674
850
|
// Recursively collect all pages
|
|
675
|
-
const { pages, assetCollection, header, footer, left, right, notFound } =
|
|
851
|
+
const { pages, assetCollection, header, footer, left, right, notFound, versionedScopes } =
|
|
676
852
|
await collectPagesRecursive(pagesPath, '/', sitePath, siteOrderConfig)
|
|
677
853
|
|
|
678
854
|
// Sort pages by order
|
|
@@ -685,6 +861,9 @@ export async function collectSiteContent(sitePath, options = {}) {
|
|
|
685
861
|
console.log(`[content-collector] Found ${assetCount} asset references${explicitCount > 0 ? ` (${explicitCount} with explicit poster/preview)` : ''}`)
|
|
686
862
|
}
|
|
687
863
|
|
|
864
|
+
// Convert versionedScopes Map to plain object for JSON serialization
|
|
865
|
+
const versionedScopesObj = Object.fromEntries(versionedScopes)
|
|
866
|
+
|
|
688
867
|
return {
|
|
689
868
|
config: {
|
|
690
869
|
...siteConfig,
|
|
@@ -700,6 +879,8 @@ export async function collectSiteContent(sitePath, options = {}) {
|
|
|
700
879
|
left,
|
|
701
880
|
right,
|
|
702
881
|
notFound,
|
|
882
|
+
// Versioned scopes: route → { versions, latestId }
|
|
883
|
+
versionedScopes: versionedScopesObj,
|
|
703
884
|
assets: assetCollection.assets,
|
|
704
885
|
hasExplicitPoster: assetCollection.hasExplicitPoster,
|
|
705
886
|
hasExplicitPreview: assetCollection.hasExplicitPreview
|
|
@@ -4,6 +4,11 @@
|
|
|
4
4
|
* Generates 11 color shades (50-950) from a single base color using
|
|
5
5
|
* the OKLCH color space for perceptually uniform results.
|
|
6
6
|
*
|
|
7
|
+
* Supports multiple generation modes:
|
|
8
|
+
* - 'fixed' (default): Predictable lightness values, constant hue
|
|
9
|
+
* - 'natural': Temperature-aware hue shifts, curved chroma
|
|
10
|
+
* - 'vivid': Higher saturation, more dramatic chroma curve
|
|
11
|
+
*
|
|
7
12
|
* @module @uniweb/build/theme/shade-generator
|
|
8
13
|
*/
|
|
9
14
|
|
|
@@ -42,6 +47,31 @@ const CHROMA_SCALE = {
|
|
|
42
47
|
950: 0.45, // Reduced chroma at dark end
|
|
43
48
|
}
|
|
44
49
|
|
|
50
|
+
// Mode-specific configurations
|
|
51
|
+
const MODE_CONFIG = {
|
|
52
|
+
// Fixed mode: predictable, consistent (current default behavior)
|
|
53
|
+
fixed: {
|
|
54
|
+
hueShift: { light: 0, dark: 0 },
|
|
55
|
+
chromaBoost: 1.0,
|
|
56
|
+
lightEndChroma: 0.15,
|
|
57
|
+
darkEndChroma: 0.45,
|
|
58
|
+
},
|
|
59
|
+
// Natural mode: temperature-aware hue shifts, organic feel
|
|
60
|
+
natural: {
|
|
61
|
+
hueShift: { light: 5, dark: -15 }, // For warm colors (inverted for cool)
|
|
62
|
+
chromaBoost: 1.1,
|
|
63
|
+
lightEndChroma: 0.20,
|
|
64
|
+
darkEndChroma: 0.40,
|
|
65
|
+
},
|
|
66
|
+
// Vivid mode: higher saturation, more dramatic
|
|
67
|
+
vivid: {
|
|
68
|
+
hueShift: { light: 3, dark: -10 },
|
|
69
|
+
chromaBoost: 1.4,
|
|
70
|
+
lightEndChroma: 0.35,
|
|
71
|
+
darkEndChroma: 0.55,
|
|
72
|
+
},
|
|
73
|
+
}
|
|
74
|
+
|
|
45
75
|
/**
|
|
46
76
|
* Parse a color string into OKLCH components
|
|
47
77
|
* Supports: hex (#fff, #ffffff), rgb(), hsl(), oklch()
|
|
@@ -281,6 +311,80 @@ function oklchToRgb(l, c, h) {
|
|
|
281
311
|
return { r, g, b }
|
|
282
312
|
}
|
|
283
313
|
|
|
314
|
+
/**
|
|
315
|
+
* Check if RGB values are within sRGB gamut
|
|
316
|
+
*/
|
|
317
|
+
function inGamut(r, g, b) {
|
|
318
|
+
return r >= -0.5 && r <= 255.5 && g >= -0.5 && g <= 255.5 && b >= -0.5 && b <= 255.5
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Find maximum chroma that fits within sRGB gamut using binary search
|
|
323
|
+
*
|
|
324
|
+
* @param {number} l - Lightness
|
|
325
|
+
* @param {number} h - Hue
|
|
326
|
+
* @param {number} idealC - Desired chroma (upper bound)
|
|
327
|
+
* @returns {number} Maximum valid chroma
|
|
328
|
+
*/
|
|
329
|
+
function findMaxChroma(l, h, idealC) {
|
|
330
|
+
let minC = 0
|
|
331
|
+
let maxC = idealC
|
|
332
|
+
let bestC = 0
|
|
333
|
+
|
|
334
|
+
// Binary search with 8 iterations for precision
|
|
335
|
+
for (let i = 0; i < 8; i++) {
|
|
336
|
+
const midC = (minC + maxC) / 2
|
|
337
|
+
const rgb = oklchToRgb(l, midC, h)
|
|
338
|
+
if (inGamut(rgb.r, rgb.g, rgb.b)) {
|
|
339
|
+
bestC = midC
|
|
340
|
+
minC = midC
|
|
341
|
+
} else {
|
|
342
|
+
maxC = midC
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
return bestC
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Quadratic Bézier interpolation for smooth chroma curves
|
|
351
|
+
*
|
|
352
|
+
* @param {number} a - Start value
|
|
353
|
+
* @param {number} control - Control point
|
|
354
|
+
* @param {number} b - End value
|
|
355
|
+
* @param {number} t - Interpolation factor (0-1)
|
|
356
|
+
* @returns {number} Interpolated value
|
|
357
|
+
*/
|
|
358
|
+
function quadBezier(a, control, b, t) {
|
|
359
|
+
const mt = 1 - t
|
|
360
|
+
return mt * mt * a + 2 * mt * t * control + t * t * b
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Linear interpolation
|
|
365
|
+
*/
|
|
366
|
+
function lerp(a, b, t) {
|
|
367
|
+
return a + (b - a) * t
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Normalize hue to 0-360 range
|
|
372
|
+
*/
|
|
373
|
+
function normalizeHue(h) {
|
|
374
|
+
h = h % 360
|
|
375
|
+
return h < 0 ? h + 360 : h
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* Check if a color is warm (reds, oranges, yellows)
|
|
380
|
+
*
|
|
381
|
+
* @param {number} h - Hue angle (0-360)
|
|
382
|
+
* @returns {boolean} True if warm
|
|
383
|
+
*/
|
|
384
|
+
function isWarmColor(h) {
|
|
385
|
+
return (h >= 0 && h < 120) || h > 300
|
|
386
|
+
}
|
|
387
|
+
|
|
284
388
|
/**
|
|
285
389
|
* Format OKLCH values as CSS string
|
|
286
390
|
*
|
|
@@ -316,62 +420,217 @@ export function formatHex(r, g, b) {
|
|
|
316
420
|
* @param {string} color - Base color in any supported format
|
|
317
421
|
* @param {Object} options - Options
|
|
318
422
|
* @param {string} [options.format='oklch'] - Output format: 'oklch' or 'hex'
|
|
423
|
+
* @param {string} [options.mode='fixed'] - Generation mode: 'fixed', 'natural', or 'vivid'
|
|
424
|
+
* @param {boolean} [options.exactMatch=false] - If true, shade 500 will be the exact input color
|
|
319
425
|
* @returns {Object} Object with shade levels as keys (50-950) and color values
|
|
426
|
+
*
|
|
427
|
+
* @example
|
|
428
|
+
* // Default fixed mode (predictable, constant hue)
|
|
429
|
+
* generateShades('#3b82f6')
|
|
430
|
+
*
|
|
431
|
+
* @example
|
|
432
|
+
* // Natural mode (temperature-aware hue shifts)
|
|
433
|
+
* generateShades('#3b82f6', { mode: 'natural' })
|
|
434
|
+
*
|
|
435
|
+
* @example
|
|
436
|
+
* // Vivid mode (higher saturation)
|
|
437
|
+
* generateShades('#3b82f6', { mode: 'vivid', exactMatch: true })
|
|
320
438
|
*/
|
|
321
439
|
export function generateShades(color, options = {}) {
|
|
322
|
-
const { format = 'oklch' } = options
|
|
323
|
-
const
|
|
440
|
+
const { format = 'oklch', mode = 'fixed', exactMatch = false } = options
|
|
441
|
+
const base = parseColor(color)
|
|
442
|
+
const config = MODE_CONFIG[mode] || MODE_CONFIG.fixed
|
|
443
|
+
|
|
444
|
+
// For fixed mode, use the original simple algorithm
|
|
445
|
+
if (mode === 'fixed') {
|
|
446
|
+
return generateFixedShades(base, color, format, exactMatch)
|
|
447
|
+
}
|
|
324
448
|
|
|
449
|
+
// For natural/vivid modes, use enhanced algorithm
|
|
450
|
+
return generateEnhancedShades(base, color, format, config, exactMatch)
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* Original fixed-lightness algorithm (default)
|
|
455
|
+
*/
|
|
456
|
+
function generateFixedShades(base, originalColor, format, exactMatch) {
|
|
325
457
|
const shades = {}
|
|
326
458
|
|
|
327
459
|
for (const level of SHADE_LEVELS) {
|
|
460
|
+
// Handle exact match at 500
|
|
461
|
+
if (exactMatch && level === 500) {
|
|
462
|
+
if (format === 'hex') {
|
|
463
|
+
shades[level] = originalColor.startsWith('#') ? originalColor : formatHexFromOklch(base)
|
|
464
|
+
} else {
|
|
465
|
+
shades[level] = formatOklch(base.l, base.c, base.h)
|
|
466
|
+
}
|
|
467
|
+
continue
|
|
468
|
+
}
|
|
469
|
+
|
|
328
470
|
const targetL = LIGHTNESS_MAP[level]
|
|
329
471
|
const chromaScale = CHROMA_SCALE[level]
|
|
472
|
+
const targetC = base.c * chromaScale
|
|
473
|
+
|
|
474
|
+
// Use gamut mapping to find valid chroma
|
|
475
|
+
const safeC = findMaxChroma(targetL, base.h, targetC)
|
|
476
|
+
|
|
477
|
+
if (format === 'hex') {
|
|
478
|
+
const rgb = oklchToRgb(targetL, safeC, base.h)
|
|
479
|
+
shades[level] = formatHex(rgb.r, rgb.g, rgb.b)
|
|
480
|
+
} else {
|
|
481
|
+
shades[level] = formatOklch(targetL, safeC, base.h)
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
return shades
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* Enhanced algorithm with hue shifting and curved chroma (natural/vivid modes)
|
|
490
|
+
*/
|
|
491
|
+
function generateEnhancedShades(base, originalColor, format, config, exactMatch) {
|
|
492
|
+
const shades = {}
|
|
493
|
+
const isWarm = isWarmColor(base.h)
|
|
494
|
+
|
|
495
|
+
// Calculate hue shift direction based on color temperature
|
|
496
|
+
const hueShiftLight = isWarm ? config.hueShift.light : -config.hueShift.light
|
|
497
|
+
const hueShiftDark = isWarm ? config.hueShift.dark : -config.hueShift.dark
|
|
498
|
+
|
|
499
|
+
// Define endpoints
|
|
500
|
+
const lightEnd = {
|
|
501
|
+
l: LIGHTNESS_MAP[50],
|
|
502
|
+
c: base.c * config.lightEndChroma,
|
|
503
|
+
h: normalizeHue(base.h + hueShiftLight),
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
const darkEnd = {
|
|
507
|
+
l: LIGHTNESS_MAP[950],
|
|
508
|
+
c: base.c * config.darkEndChroma,
|
|
509
|
+
h: normalizeHue(base.h + hueShiftDark),
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
// Control point for chroma curve (peaks at middle)
|
|
513
|
+
const peakChroma = base.c * config.chromaBoost
|
|
514
|
+
|
|
515
|
+
for (let i = 0; i < SHADE_LEVELS.length; i++) {
|
|
516
|
+
const level = SHADE_LEVELS[i]
|
|
517
|
+
|
|
518
|
+
// Handle exact match at 500 (index 5)
|
|
519
|
+
if (exactMatch && level === 500) {
|
|
520
|
+
if (format === 'hex') {
|
|
521
|
+
shades[level] = originalColor.startsWith('#') ? originalColor : formatHexFromOklch(base)
|
|
522
|
+
} else {
|
|
523
|
+
shades[level] = formatOklch(base.l, base.c, base.h)
|
|
524
|
+
}
|
|
525
|
+
continue
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
let targetL, targetC, targetH
|
|
529
|
+
|
|
530
|
+
// Split the curve at the base color (index 5 = shade 500)
|
|
531
|
+
if (i <= 5) {
|
|
532
|
+
// Light half: interpolate from lightEnd to base
|
|
533
|
+
const t = i / 5
|
|
534
|
+
targetL = lerp(lightEnd.l, base.l, t)
|
|
535
|
+
targetH = lerp(lightEnd.h, base.h, t)
|
|
536
|
+
|
|
537
|
+
// Bézier curve for chroma with peak at middle
|
|
538
|
+
const controlC = (lightEnd.c + peakChroma) / 2
|
|
539
|
+
targetC = quadBezier(lightEnd.c, controlC, peakChroma, t)
|
|
540
|
+
} else {
|
|
541
|
+
// Dark half: interpolate from base to darkEnd
|
|
542
|
+
const t = (i - 5) / 5
|
|
543
|
+
targetL = lerp(base.l, darkEnd.l, t)
|
|
544
|
+
targetH = lerp(base.h, darkEnd.h, t)
|
|
545
|
+
|
|
546
|
+
// Bézier curve for chroma, descending from peak
|
|
547
|
+
const controlC = (peakChroma + darkEnd.c) / 2
|
|
548
|
+
targetC = quadBezier(peakChroma, controlC, darkEnd.c, t)
|
|
549
|
+
}
|
|
330
550
|
|
|
331
|
-
//
|
|
332
|
-
|
|
333
|
-
|
|
551
|
+
// Normalize hue
|
|
552
|
+
targetH = normalizeHue(targetH)
|
|
553
|
+
|
|
554
|
+
// Gamut map to find maximum valid chroma
|
|
555
|
+
const safeC = findMaxChroma(targetL, targetH, targetC)
|
|
334
556
|
|
|
335
557
|
if (format === 'hex') {
|
|
336
|
-
const rgb = oklchToRgb(targetL,
|
|
558
|
+
const rgb = oklchToRgb(targetL, safeC, targetH)
|
|
337
559
|
shades[level] = formatHex(rgb.r, rgb.g, rgb.b)
|
|
338
560
|
} else {
|
|
339
|
-
shades[level] = formatOklch(targetL,
|
|
561
|
+
shades[level] = formatOklch(targetL, safeC, targetH)
|
|
340
562
|
}
|
|
341
563
|
}
|
|
342
564
|
|
|
343
565
|
return shades
|
|
344
566
|
}
|
|
345
567
|
|
|
568
|
+
/**
|
|
569
|
+
* Helper to format OKLCH as hex
|
|
570
|
+
*/
|
|
571
|
+
function formatHexFromOklch(oklch) {
|
|
572
|
+
const rgb = oklchToRgb(oklch.l, oklch.c, oklch.h)
|
|
573
|
+
return formatHex(rgb.r, rgb.g, rgb.b)
|
|
574
|
+
}
|
|
575
|
+
|
|
346
576
|
/**
|
|
347
577
|
* Generate shades for multiple colors
|
|
348
578
|
*
|
|
349
|
-
* @param {Object} colors - Object with color names as keys and color values
|
|
350
|
-
* @param {Object} options -
|
|
579
|
+
* @param {Object} colors - Object with color names as keys and color values or config objects
|
|
580
|
+
* @param {Object} options - Default options passed to generateShades
|
|
351
581
|
* @returns {Object} Object with color names, each containing shade levels
|
|
352
582
|
*
|
|
353
583
|
* @example
|
|
584
|
+
* // Simple usage with defaults
|
|
354
585
|
* generatePalettes({
|
|
355
586
|
* primary: '#3b82f6',
|
|
356
587
|
* secondary: '#64748b'
|
|
357
588
|
* })
|
|
358
|
-
*
|
|
589
|
+
*
|
|
590
|
+
* @example
|
|
591
|
+
* // With per-color options
|
|
592
|
+
* generatePalettes({
|
|
593
|
+
* primary: { base: '#3b82f6', mode: 'vivid', exactMatch: true },
|
|
594
|
+
* secondary: '#64748b', // Uses defaults
|
|
595
|
+
* neutral: { base: '#737373', mode: 'fixed' }
|
|
596
|
+
* })
|
|
359
597
|
*/
|
|
360
598
|
export function generatePalettes(colors, options = {}) {
|
|
361
599
|
const palettes = {}
|
|
362
600
|
|
|
363
|
-
for (const [name,
|
|
364
|
-
//
|
|
365
|
-
if (typeof
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
601
|
+
for (const [name, colorConfig] of Object.entries(colors)) {
|
|
602
|
+
// Pre-defined shades (object with numeric keys)
|
|
603
|
+
if (typeof colorConfig === 'object' && colorConfig !== null && !colorConfig.base) {
|
|
604
|
+
// Check if it's a shades object (has numeric keys like 50, 100, etc)
|
|
605
|
+
const keys = Object.keys(colorConfig)
|
|
606
|
+
if (keys.some(k => !isNaN(parseInt(k)))) {
|
|
607
|
+
palettes[name] = colorConfig
|
|
608
|
+
continue
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
// Color config object with base and options
|
|
613
|
+
if (typeof colorConfig === 'object' && colorConfig !== null && colorConfig.base) {
|
|
614
|
+
const { base, ...colorOptions } = colorConfig
|
|
615
|
+
palettes[name] = generateShades(base, { ...options, ...colorOptions })
|
|
616
|
+
}
|
|
617
|
+
// Simple color string
|
|
618
|
+
else if (typeof colorConfig === 'string') {
|
|
619
|
+
palettes[name] = generateShades(colorConfig, options)
|
|
369
620
|
}
|
|
370
621
|
}
|
|
371
622
|
|
|
372
623
|
return palettes
|
|
373
624
|
}
|
|
374
625
|
|
|
626
|
+
/**
|
|
627
|
+
* Get available generation modes
|
|
628
|
+
* @returns {string[]} Array of mode names
|
|
629
|
+
*/
|
|
630
|
+
export function getAvailableModes() {
|
|
631
|
+
return Object.keys(MODE_CONFIG)
|
|
632
|
+
}
|
|
633
|
+
|
|
375
634
|
/**
|
|
376
635
|
* Check if a color string is valid
|
|
377
636
|
*
|
|
@@ -403,4 +662,5 @@ export default {
|
|
|
403
662
|
generatePalettes,
|
|
404
663
|
isValidColor,
|
|
405
664
|
getShadeLevels,
|
|
665
|
+
getAvailableModes,
|
|
406
666
|
}
|