@uniweb/build 0.50.0 → 0.52.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniweb/build",
3
- "version": "0.50.0",
3
+ "version": "0.52.0",
4
4
  "description": "Build tooling for the Uniweb Component Web Platform",
5
5
  "type": "module",
6
6
  "exports": {
@@ -57,15 +57,15 @@
57
57
  "js-yaml": "^4.1.0",
58
58
  "sharp": "^0.35.3",
59
59
  "yaml": "^2.5.0",
60
- "@uniweb/content-reader": "^1.2.4",
60
+ "@uniweb/content-reader": "^1.2.5",
61
61
  "@uniweb/schemas": "^0.2.13",
62
- "@uniweb/semantic-parser": "^1.4.0",
63
62
  "@uniweb/theming": "^0.1.15",
64
63
  "@uniweb/projections": "^0.7.2",
64
+ "@uniweb/semantic-parser": "^1.4.0",
65
65
  "@uniweb/content-writer": "^0.3.4"
66
66
  },
67
67
  "optionalDependencies": {
68
- "@uniweb/runtime": "^0.25.0"
68
+ "@uniweb/runtime": "^0.26.1"
69
69
  },
70
70
  "peerDependencies": {
71
71
  "vite": "^5.0.0 || ^6.0.0 || ^7.0.0",
@@ -74,7 +74,7 @@
74
74
  "@tailwindcss/vite": "^4.0.0",
75
75
  "@vitejs/plugin-react": "^4.0.0 || ^5.0.0",
76
76
  "vite-plugin-svgr": "^4.0.0",
77
- "@uniweb/core": "^0.29.0"
77
+ "@uniweb/core": "^0.29.2"
78
78
  },
79
79
  "peerDependenciesMeta": {
80
80
  "vite": {
@@ -2,6 +2,7 @@ import { readFileSync } from 'node:fs'
2
2
  import { resolve, join } from 'node:path'
3
3
  import { pathToFileURL } from 'node:url'
4
4
  import yaml from 'js-yaml'
5
+ import { YAML_OPTIONS } from '../utils/yaml-schema.js'
5
6
 
6
7
  /**
7
8
  * Mount a site's own request handler in the dev server.
@@ -67,7 +68,7 @@ export function mountDevApi(server, { root }) {
67
68
  // rule working: if it were readable from `config`, it would also be published.
68
69
  let site
69
70
  try {
70
- site = yaml.load(readFileSync(join(root, 'site.yml'), 'utf8')) || {}
71
+ site = yaml.load(readFileSync(join(root, 'site.yml'), 'utf8'), YAML_OPTIONS) || {}
71
72
  } catch {
72
73
  return false
73
74
  }
package/src/dev/plugin.js CHANGED
@@ -27,6 +27,7 @@ import { existsSync, readdirSync } from 'node:fs'
27
27
  import { build } from 'vite'
28
28
  import { resolveFoundationSrcPath } from '../utils/foundation-source-root.js'
29
29
  import { DEV_REBUILD_MARKER } from '../vite-foundation-plugin.js'
30
+ import { shouldRegenerateForFile } from '../generate-entry.js'
30
31
 
31
32
  /** Directories that never hold foundation source and must never be walked. */
32
33
  const UNWATCHABLE_DIRS = new Set(['node_modules', 'dist', 'build', 'coverage'])
@@ -237,19 +238,22 @@ export function foundationDevPlugin(options = {}) {
237
238
  }, 200)
238
239
  }
239
240
 
240
- const onChange = (eventType, filename) => {
241
+ // `filename` is relative to the watched target, so each target gets its own handler
242
+ const onChange = (target) => (eventType, filename) => {
241
243
  // Ignore generated files (build output triggers entry regeneration)
242
- if (filename && filename.includes('_entry.generated')) return
244
+ if (!filename || filename.includes('_entry.generated')) return
243
245
 
244
- // Only rebuild for source file changes
246
+ // Rebuild for source file changes, and for any file the entry is generated
247
+ // from — a data schema under `schemas/` is `.yml` or `.json` as often as `.js`
248
+ // (`shouldRegenerateForFile`)
245
249
  if (
246
- filename &&
247
- (filename.endsWith('.js') ||
248
- filename.endsWith('.jsx') ||
249
- filename.endsWith('.ts') ||
250
- filename.endsWith('.tsx') ||
251
- filename.endsWith('.css') ||
252
- filename.endsWith('.svg'))
250
+ filename.endsWith('.js') ||
251
+ filename.endsWith('.jsx') ||
252
+ filename.endsWith('.ts') ||
253
+ filename.endsWith('.tsx') ||
254
+ filename.endsWith('.css') ||
255
+ filename.endsWith('.svg') ||
256
+ shouldRegenerateForFile(join(target.path, filename), srcPath)
253
257
  ) {
254
258
  console.log(`[foundation] ${filename} changed`)
255
259
  scheduleRebuild()
@@ -259,7 +263,7 @@ export function foundationDevPlugin(options = {}) {
259
263
  const watchers = []
260
264
  for (const target of resolveWatchTargets(srcPath)) {
261
265
  try {
262
- watchers.push(watch(target.path, { recursive: target.recursive }, onChange))
266
+ watchers.push(watch(target.path, { recursive: target.recursive }, onChange(target)))
263
267
  } catch (err) {
264
268
  console.warn(`[foundation] Could not watch ${target.path}:`, err.message)
265
269
  }
@@ -30,7 +30,15 @@ import {
30
30
  LAYOUTS_PATH
31
31
  } from './schema.js'
32
32
  import { extractAllRuntimeSchemas, extractAllLayoutRuntimeSchemas } from './runtime-schema.js'
33
- import { collectSchemaRefs, buildDataSchemaMap } from './resolve-data-schema.js'
33
+ import { collectSchemaRefs, buildDataSchemaMap, SCHEMA_EXTENSIONS } from './resolve-data-schema.js'
34
+
35
+ /**
36
+ * The files a foundation's data schemas are read from, relative to its source root:
37
+ * `@/name` is `schemas/name.<ext>` (`resolve-data-schema.js::findSelfSchemaFile`), and
38
+ * `schemas.config.js` says where a scope's schemas live (`loadSchemaAliases`).
39
+ */
40
+ const SCHEMAS_PATH = 'schemas'
41
+ const SCHEMA_ALIASES_FILE = 'schemas.config.js'
34
42
 
35
43
  /**
36
44
  * Packages that may be bundled inside a foundation but require single-instance
@@ -407,12 +415,14 @@ export function getStructuralWatchPaths(srcDir, options = {}) {
407
415
  'main.js',
408
416
  'main.jsx',
409
417
  'styles.css',
410
- 'index.css'
418
+ 'index.css',
419
+ SCHEMA_ALIASES_FILE
411
420
  ]
412
421
 
413
422
  return [
414
423
  ...sectionPaths.map(path => join(srcDir, path)),
415
424
  join(srcDir, LAYOUTS_PATH),
425
+ join(srcDir, SCHEMAS_PATH),
416
426
  ...rootFiles.map(file => join(srcDir, file))
417
427
  ]
418
428
  }
@@ -503,5 +513,22 @@ export function shouldRegenerateForFile(file, srcDir) {
503
513
  }
504
514
  }
505
515
 
516
+ // ⭐ The foundation's data schemas — `schemas/<name>.<ext>` and `schemas.config.js`.
517
+ // The entry's `meta` carries each declared `data:` key with its schema's field
518
+ // defaults, and a `@/name` ref whose file is missing stops entry generation. A
519
+ // section receives only the keys its entry declares (2026-09-14), so an entry that
520
+ // falls behind withholds data: a `data:` key declaring `@/member` before
521
+ // `schemas/member.yml` exists fails the regeneration, and until 2026-09-14 writing
522
+ // the file triggered no other — the dev server kept the entry without the key.
523
+ if (rel.startsWith(`${SCHEMAS_PATH}/`)) {
524
+ const inner = rel.slice(SCHEMAS_PATH.length + 1)
525
+ if (!inner.includes('/') && SCHEMA_EXTENSIONS.some(ext => inner.endsWith(ext))) {
526
+ return `data schema: ${inner}`
527
+ }
528
+ }
529
+ if (rel === SCHEMA_ALIASES_FILE) {
530
+ return 'schema aliases changed'
531
+ }
532
+
506
533
  return null
507
534
  }
@@ -10,7 +10,7 @@
10
10
  * locales/freeform/{locale}/
11
11
  * pages/{pageRoute}/{stableId}.md - By route
12
12
  * page-ids/{pageId}/{stableId}.md - By page ID (stable)
13
- * collections/{queryName}/{slug}.md - Collection items
13
+ * entities/{schema dirs}/{slug}.md - Records
14
14
  *
15
15
  * Resolution order for sections:
16
16
  * 1. page-ids/{pageId}/{stableId}.md (if page has id:)
@@ -20,10 +20,11 @@
20
20
 
21
21
  import { readFile, readdir, stat } from 'fs/promises'
22
22
  import { existsSync } from 'node:fs'
23
- import { join, relative, dirname } from 'node:path'
23
+ import { join, relative, dirname, sep } from 'node:path'
24
24
  import yaml from 'js-yaml'
25
25
  import { poolDirsForSchema, ENTITIES_DIR } from '../site/entity-pool.js'
26
26
  import { parseFrontmatter } from '../utils/frontmatter.js'
27
+ import { computeSourceHash } from './freeform-manifest.js'
27
28
 
28
29
  // Try to import content-reader for markdown → ProseMirror conversion
29
30
  let markdownToProseMirror
@@ -70,30 +71,11 @@ function normalizeRouteForPath(route) {
70
71
  * @returns {Promise<Object|null>} Parsed translation { content } or null
71
72
  */
72
73
  export async function loadFreeformTranslation(section, page, locale, localesDir) {
73
- const stableId = section.stableId
74
- if (!stableId) return null
75
-
76
74
  const freeformDir = join(localesDir, 'freeform', locale)
77
75
  if (!existsSync(freeformDir)) return null
78
76
 
79
- const candidates = []
80
-
81
- // 1. Try page-ids path (if page has stable id)
82
- if (page.id) {
83
- candidates.push(join(freeformDir, 'page-ids', page.id, `${stableId}.md`))
84
- }
85
-
86
- // 2. Try pages path (by route)
87
- const routePath = normalizeRouteForPath(page.route)
88
- if (routePath) {
89
- candidates.push(join(freeformDir, 'pages', routePath, `${stableId}.md`))
90
- } else {
91
- // Root page
92
- candidates.push(join(freeformDir, 'pages', `${stableId}.md`))
93
- }
94
-
95
77
  // Try each candidate in order
96
- for (const filePath of candidates) {
78
+ for (const filePath of freeformPathsFor(section, page).map((rel) => join(freeformDir, rel))) {
97
79
  if (!existsSync(filePath)) continue
98
80
 
99
81
  try {
@@ -189,7 +171,7 @@ async function discoverMarkdownFiles(dir, baseDir) {
189
171
  const subFiles = await discoverMarkdownFiles(fullPath, baseDir)
190
172
  files.push(...subFiles)
191
173
  } else if (entry.isFile() && entry.name.endsWith('.md')) {
192
- files.push(relative(baseDir, fullPath))
174
+ files.push(relative(baseDir, fullPath).split(sep).join('/'))
193
175
  }
194
176
  }
195
177
 
@@ -199,11 +181,18 @@ async function discoverMarkdownFiles(dir, baseDir) {
199
181
  /**
200
182
  * Discover all free-form translation files for a locale
201
183
  *
202
- * Used by status commands to show what translations exist.
184
+ * Used by status commands to show what translations exist, and by the build to
185
+ * register a translation that has no manifest entry yet.
186
+ *
187
+ * ⭐ Each path is relative to the locale's free-form directory — `pages/about/story.md`,
188
+ * `page-ids/<id>/intro.md`, `entities/article/hello.md` — the form the manifest keys a
189
+ * translation by and `freeformPathsFor` derives. ⛔ Until 2026-09-14 each was relative
190
+ * to its own subdirectory (`about/story.md`), so none matched a manifest key: the build
191
+ * registered no new file, and a status counted every stale or orphaned file as up to date.
203
192
  *
204
193
  * @param {string} locale - Locale code
205
194
  * @param {string} localesDir - Path to locales directory
206
- * @returns {Promise<Object>} { pages: string[], pageIds: string[], collections: string[] }
195
+ * @returns {Promise<{ pages: string[], pageIds: string[], records: string[] }>}
207
196
  */
208
197
  export async function discoverFreeformTranslations(locale, localesDir) {
209
198
  const freeformDir = join(localesDir, 'freeform', locale)
@@ -219,19 +208,19 @@ export async function discoverFreeformTranslations(locale, localesDir) {
219
208
  // Discover pages translations
220
209
  const pagesDir = join(freeformDir, 'pages')
221
210
  if (existsSync(pagesDir)) {
222
- result.pages = await discoverMarkdownFiles(pagesDir, pagesDir)
211
+ result.pages = (await discoverMarkdownFiles(pagesDir, freeformDir)).sort()
223
212
  }
224
213
 
225
214
  // Discover page-ids translations
226
215
  const pageIdsDir = join(freeformDir, 'page-ids')
227
216
  if (existsSync(pageIdsDir)) {
228
- result.pageIds = await discoverMarkdownFiles(pageIdsDir, pageIdsDir)
217
+ result.pageIds = (await discoverMarkdownFiles(pageIdsDir, freeformDir)).sort()
229
218
  }
230
219
 
231
220
  // Discover entity translations
232
221
  const entitiesDir = join(freeformDir, ENTITIES_DIR)
233
222
  if (existsSync(entitiesDir)) {
234
- result.records = await discoverMarkdownFiles(entitiesDir, entitiesDir)
223
+ result.records = (await discoverMarkdownFiles(entitiesDir, freeformDir)).sort()
235
224
  }
236
225
 
237
226
  return result
@@ -287,6 +276,105 @@ export function parseFreeformPath(relativePath) {
287
276
  return { type: 'unknown', relativePath }
288
277
  }
289
278
 
279
+ /**
280
+ * Every path a section's free-form translation is read from, in the order the renderer
281
+ * tries them (`loadFreeformTranslation`): `page-ids/<page id>/<section>.md` when the page
282
+ * has an `id`, then `pages/<route>/<section>.md` (`pages/<section>.md` for the root page).
283
+ *
284
+ * ⭐ ONE LIST, for the loader and for whatever judges a translation file against the
285
+ * site. ⛔ Until 2026-09-14 the orphan and stale check derived the first path alone
286
+ * (`buildFreeformPath`), so on a page with an `id` a route-addressed file the page
287
+ * rendered was reported orphaned and never checked for staleness.
288
+ *
289
+ * @param {Object} section - Section with stableId
290
+ * @param {Object} page - Page with route and optional id
291
+ * @returns {string[]} Paths relative to the locale's free-form directory; none when the
292
+ * section has no stable id
293
+ */
294
+ export function freeformPathsFor(section, page) {
295
+ const stableId = section?.stableId
296
+ if (!stableId) return []
297
+ const paths = []
298
+ if (page?.id) paths.push(`page-ids/${page.id}/${stableId}.md`)
299
+ const routePath = normalizeRouteForPath(page?.route || '/')
300
+ paths.push(routePath ? `pages/${routePath}/${stableId}.md` : `pages/${stableId}.md`)
301
+ return paths
302
+ }
303
+
304
+ /**
305
+ * Every section whose free-form translation the renderer looks up, with the page it
306
+ * looks it up for — the traversal `merge.js::mergeTranslationsAsync` makes: each page's
307
+ * sections, the 404 page's, and each layout area's (whose route it defaults to
308
+ * `/layout/[<name>/]<area>`), subsections included. Keep the two the same.
309
+ *
310
+ * @param {Object} siteContent
311
+ * @returns {Array<{ section: Object, page: Object }>}
312
+ */
313
+ function freeformSections(siteContent) {
314
+ const out = []
315
+ const visit = (sections, page) => {
316
+ for (const section of sections || []) {
317
+ out.push({ section, page })
318
+ visit(section.subsections, page)
319
+ }
320
+ }
321
+ for (const page of siteContent?.pages || []) visit(page.sections, page)
322
+ if (siteContent?.notFound) visit(siteContent.notFound.sections, siteContent.notFound)
323
+ for (const [layoutName, areas] of Object.entries(siteContent?.layouts || {})) {
324
+ if (!areas || typeof areas !== 'object') continue
325
+ for (const [areaKey, layoutPage] of Object.entries(areas)) {
326
+ if (!layoutPage?.sections) continue
327
+ const route = layoutPage.route || `/layout/${layoutName === 'default' ? '' : layoutName + '/'}${areaKey}`
328
+ visit(layoutPage.sections, { ...layoutPage, route })
329
+ }
330
+ }
331
+ return out
332
+ }
333
+
334
+ /**
335
+ * What a site's content says about its free-form translations — the ONE index for the
336
+ * build's stale and orphan check and for the CLI's `status --freeform`, `update-hash` and
337
+ * `prune --freeform`, so no two of them can judge a file differently.
338
+ *
339
+ * - `validPaths` — every path the renderer reads a section's translation from
340
+ * (`freeformPathsFor`), for every section it translates;
341
+ * - `sourceHashes` — the hash of the source a translation at each of those paths
342
+ * translates, which a recorded hash is compared with;
343
+ * - `canJudge(path)` — whether this content can say a translation at `path` is
344
+ * orphaned. ⛔ Only a page section's (`pages/…`, `page-ids/…`), and not one for a page
345
+ * whose sections the content does not carry — a prerendered site with split content
346
+ * rewrites `site-content.json` without them. A record's (`entities/…`) is read from
347
+ * records this content does not hold. A check that cannot see the source must never
348
+ * call its translation orphaned: `prune --freeform` deletes what it is told is.
349
+ *
350
+ * @param {Object} siteContent - collected site content, as `dist/site-content.json` holds it
351
+ * @returns {{ validPaths: Set<string>, sourceHashes: Object<string, string>, canJudge: (path: string) => boolean }}
352
+ */
353
+ export function freeformSourceIndex(siteContent) {
354
+ const validPaths = new Set()
355
+ const sourceHashes = {}
356
+ for (const { section, page } of freeformSections(siteContent)) {
357
+ for (const path of freeformPathsFor(section, page)) {
358
+ validPaths.add(path)
359
+ if (section.content) sourceHashes[path] = computeSourceHash(section.content)
360
+ }
361
+ }
362
+
363
+ // The directories a translation for a page with no `sections` would sit in.
364
+ const unseen = new Set()
365
+ for (const page of siteContent?.pages || []) {
366
+ if (Array.isArray(page?.sections)) continue
367
+ for (const path of freeformPathsFor({ stableId: '_' }, page)) unseen.add(dirname(path))
368
+ }
369
+
370
+ const canJudge = (path) =>
371
+ typeof path === 'string' &&
372
+ (path.startsWith('pages/') || path.startsWith('page-ids/')) &&
373
+ !unseen.has(dirname(path))
374
+
375
+ return { validPaths, sourceHashes, canJudge }
376
+ }
377
+
290
378
  /**
291
379
  * Build the expected free-form translation path for a section
292
380
  *
package/src/i18n/index.js CHANGED
@@ -34,6 +34,8 @@ import {
34
34
  getFreeformFileMeta,
35
35
  parseFreeformPath,
36
36
  buildFreeformPath,
37
+ freeformPathsFor,
38
+ freeformSourceIndex,
37
39
  buildFreeformRecordPath
38
40
  } from './freeform.js'
39
41
  import {
@@ -85,6 +87,8 @@ export {
85
87
  getFreeformFileMeta,
86
88
  parseFreeformPath,
87
89
  buildFreeformPath,
90
+ freeformPathsFor,
91
+ freeformSourceIndex,
88
92
  buildFreeformRecordPath,
89
93
 
90
94
  // Free-form manifest functions
@@ -426,24 +430,10 @@ export async function buildLocalizedContent(siteRoot, options = {}) {
426
430
  */
427
431
  async function warnAboutFreeformIssues(locale, freeformDir, siteContent) {
428
432
  try {
429
- // Build map of valid source hashes
430
- const sourceHashes = {}
431
- const validPaths = new Set()
432
-
433
- for (const page of siteContent.pages || []) {
434
- for (const section of page.sections || []) {
435
- if (section.stableId) {
436
- const path = buildFreeformPath(section, page)
437
- if (path) {
438
- validPaths.add(path)
439
- // Compute source hash for staleness check
440
- if (section.content) {
441
- sourceHashes[path] = computeSourceHash(section.content)
442
- }
443
- }
444
- }
445
- }
446
- }
433
+ // Every path the renderer reads a section's translation from, for every section it
434
+ // translates, with the hash of each source — the index the CLI's free-form commands
435
+ // judge by too (`freeformSourceIndex`).
436
+ const { validPaths, sourceHashes, canJudge } = freeformSourceIndex(siteContent)
447
437
 
448
438
  // Check for stale translations
449
439
  const stale = await getStaleTranslations(freeformDir, sourceHashes)
@@ -451,9 +441,11 @@ async function warnAboutFreeformIssues(locale, freeformDir, siteContent) {
451
441
  console.warn(`[i18n] Free-form translation stale: ${locale}/${item.path} (source changed ${item.recordedDate})`)
452
442
  }
453
443
 
454
- // Check for orphaned translations
444
+ // Check for orphaned translations — only those this content can judge: never a
445
+ // record's (`entities/…`), which is read from records the site content does not hold.
455
446
  const orphaned = await getOrphanedTranslations(freeformDir, validPaths)
456
447
  for (const item of orphaned) {
448
+ if (!canJudge(item.path)) continue
457
449
  console.warn(`[i18n] Free-form translation orphaned: ${locale}/${item.path}`)
458
450
  }
459
451