@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 +5 -5
- package/src/dev/api-mount.js +2 -1
- package/src/dev/plugin.js +15 -11
- package/src/generate-entry.js +29 -2
- package/src/i18n/freeform.js +116 -28
- package/src/i18n/index.js +11 -19
- package/src/i18n/records.js +144 -28
- package/src/prerender.js +4 -13
- package/src/resolve-data-schema.js +2 -1
- package/src/runtime-schema.js +13 -0
- package/src/schema.js +13 -3
- package/src/site/build-site-data.js +11 -5
- package/src/site/config.js +2 -1
- package/src/site/content-collector.js +17 -3
- package/src/site/data-fetcher.js +68 -144
- package/src/site/deploy-config.js +2 -1
- package/src/site/fetch-shapes.js +19 -7
- package/src/site/plugin.js +97 -41
- package/src/site/queries-config.js +15 -4
- package/src/site/query-processor.js +112 -64
- package/src/site/records-config.js +3 -2
- package/src/utils/frontmatter.js +2 -1
- package/src/utils/yaml-schema.js +44 -0
- package/src/uwx/backfill.js +3 -2
- package/src/uwx/entity-source.js +2 -1
- package/src/uwx/project-writer.js +3 -2
- package/src/uwx/records-project.js +9 -3
- package/src/uwx/records.js +7 -4
- package/src/uwx/site-project.js +2 -1
- package/src/uwx/site.js +6 -5
- package/src/validate-data.js +9 -8
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniweb/build",
|
|
3
|
-
"version": "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.
|
|
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.
|
|
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.
|
|
77
|
+
"@uniweb/core": "^0.29.2"
|
|
78
78
|
},
|
|
79
79
|
"peerDependenciesMeta": {
|
|
80
80
|
"vite": {
|
package/src/dev/api-mount.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
244
|
+
if (!filename || filename.includes('_entry.generated')) return
|
|
243
245
|
|
|
244
|
-
//
|
|
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
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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
|
}
|
package/src/generate-entry.js
CHANGED
|
@@ -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
|
}
|
package/src/i18n/freeform.js
CHANGED
|
@@ -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
|
-
*
|
|
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
|
|
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<
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
-
//
|
|
430
|
-
|
|
431
|
-
|
|
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
|
|