@uniweb/build 0.15.13 → 0.16.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 +9 -8
- package/src/i18n/collections.js +23 -52
- package/src/i18n/data-strings.js +114 -0
- package/src/i18n/extract.js +150 -52
- package/src/i18n/merge.js +34 -0
- package/src/index.js +1 -0
- package/src/prerender.js +14 -30
- package/src/site/build-site-data.js +23 -4
- package/src/site/collection-processor.js +85 -7
- package/src/site/content-collector.js +4 -3
- package/src/site/data-ball.js +2 -1
- package/src/site/data-fetcher.js +2 -2
- package/src/site/head-markers.js +16 -6
- package/src/site/plugin.js +112 -15
- package/src/uwx/locale-sync.js +37 -16
- package/src/uwx/site.js +10 -7
- package/src/validate-data.js +167 -3
|
@@ -21,7 +21,7 @@ import { writeFile, readFile, mkdir, cp } from 'node:fs/promises'
|
|
|
21
21
|
import { existsSync } from 'node:fs'
|
|
22
22
|
import { join, resolve, dirname } from 'node:path'
|
|
23
23
|
|
|
24
|
-
import { resolveDefaultLocale } from '@uniweb/core'
|
|
24
|
+
import { resolveDefaultLocale, DATA_DIR } from '@uniweb/core'
|
|
25
25
|
import { collectSiteContent } from './content-collector.js'
|
|
26
26
|
import { processCollections, writeCollectionFiles } from './collection-processor.js'
|
|
27
27
|
import { processAssets, rewriteSiteContentPaths } from './asset-processor.js'
|
|
@@ -35,7 +35,9 @@ import {
|
|
|
35
35
|
renderPageMarkdown,
|
|
36
36
|
resolveAgentsConfig,
|
|
37
37
|
selectIndexablePages,
|
|
38
|
+
selectIndexBranches,
|
|
38
39
|
pageMarkdownFilename,
|
|
40
|
+
branchIndexFilename,
|
|
39
41
|
INDEX_FILENAME
|
|
40
42
|
} from '@uniweb/projections'
|
|
41
43
|
|
|
@@ -133,8 +135,8 @@ export async function buildSiteData({
|
|
|
133
135
|
)
|
|
134
136
|
await writeCollectionFiles(resolvedSiteRoot, collections, siteContent.config.collections)
|
|
135
137
|
|
|
136
|
-
const publicDataDir = join(resolvedSiteRoot, 'public',
|
|
137
|
-
const distDataDir = join(resolvedDistDir,
|
|
138
|
+
const publicDataDir = join(resolvedSiteRoot, 'public', DATA_DIR)
|
|
139
|
+
const distDataDir = join(resolvedDistDir, DATA_DIR)
|
|
138
140
|
if (existsSync(publicDataDir)) {
|
|
139
141
|
await cp(publicDataDir, distDataDir, { recursive: true })
|
|
140
142
|
}
|
|
@@ -228,7 +230,7 @@ export async function buildSiteData({
|
|
|
228
230
|
const collectionIndexes = []
|
|
229
231
|
for (const [collName, collConfig] of Object.entries(collections)) {
|
|
230
232
|
if (!collConfig.search?.enabled || !collConfig.route) continue
|
|
231
|
-
const cascadeFile = join(resolvedDistDir,
|
|
233
|
+
const cascadeFile = join(resolvedDistDir, DATA_DIR, `${collName}.json`)
|
|
232
234
|
if (!existsSync(cascadeFile)) continue
|
|
233
235
|
let collectionData
|
|
234
236
|
try {
|
|
@@ -297,6 +299,23 @@ async function writeProjections(siteContent, distDir) {
|
|
|
297
299
|
if (agents.index) {
|
|
298
300
|
const index = renderSiteIndex(siteContent, { ...options, exclude: agents.exclude })
|
|
299
301
|
await writeFile(join(distDir, INDEX_FILENAME), index)
|
|
302
|
+
|
|
303
|
+
// Additive scoped indexes; the root one above stays complete. See
|
|
304
|
+
// `selectIndexBranches` for why this is not a delegation.
|
|
305
|
+
if (agents.branchIndexes) {
|
|
306
|
+
const branches = selectIndexBranches(siteContent.pages, {
|
|
307
|
+
exclude: agents.exclude,
|
|
308
|
+
minPages: agents.branchMinPages
|
|
309
|
+
})
|
|
310
|
+
for (const branch of branches) {
|
|
311
|
+
const target = join(distDir, branchIndexFilename(branch.route))
|
|
312
|
+
await mkdir(dirname(target), { recursive: true })
|
|
313
|
+
await writeFile(
|
|
314
|
+
target,
|
|
315
|
+
renderSiteIndex(siteContent, { ...options, exclude: agents.exclude, branch: branch.route })
|
|
316
|
+
)
|
|
317
|
+
}
|
|
318
|
+
}
|
|
300
319
|
}
|
|
301
320
|
|
|
302
321
|
if (!agents.markdown) return
|
|
@@ -32,11 +32,12 @@
|
|
|
32
32
|
* await writeCollectionFiles(siteDir, collections)
|
|
33
33
|
*/
|
|
34
34
|
|
|
35
|
-
import { readFile, readdir, stat, writeFile, mkdir, copyFile } from 'node:fs/promises'
|
|
36
|
-
import { join, basename, extname, dirname, relative, resolve } from 'node:path'
|
|
35
|
+
import { readFile, readdir, stat, writeFile, mkdir, copyFile, rm } from 'node:fs/promises'
|
|
36
|
+
import { join, basename, extname, dirname, relative, resolve, sep } from 'node:path'
|
|
37
37
|
import { existsSync } from 'node:fs'
|
|
38
38
|
import yaml from 'js-yaml'
|
|
39
39
|
import { parseBibtex } from '@citestyle/bibtex'
|
|
40
|
+
import { DATA_DIR } from '@uniweb/core'
|
|
40
41
|
import { applyFilter, applySort } from './data-fetcher.js'
|
|
41
42
|
import { resolveAssetPath, walkContentAssets, isLocalAssetPath } from './assets.js'
|
|
42
43
|
|
|
@@ -642,6 +643,62 @@ export async function processCollections(siteDir, collectionsConfig, collections
|
|
|
642
643
|
return results
|
|
643
644
|
}
|
|
644
645
|
|
|
646
|
+
/**
|
|
647
|
+
* Reconcile a deferred collection's per-record directory with the records it
|
|
648
|
+
* should hold this run — delete the `<slug>.json` files that are no longer
|
|
649
|
+
* backed by a record.
|
|
650
|
+
*
|
|
651
|
+
* Why this is not optional. `public/data/` is a persistent, normally-committed
|
|
652
|
+
* directory, so anything written there survives until something removes it.
|
|
653
|
+
* Without this, unpublishing a record (`published: false`, which the build
|
|
654
|
+
* honours automatically) or deleting its source file drops it from the cascade
|
|
655
|
+
* listing — it vanishes from the site — while its per-record file stays on
|
|
656
|
+
* disk with the full body, gets committed, and gets deployed. The author has
|
|
657
|
+
* every reason to believe the content is gone. It is still fetchable at a URL
|
|
658
|
+
* that was public a moment ago.
|
|
659
|
+
*
|
|
660
|
+
* `public/data/` is the build's output directory and nothing else — authors
|
|
661
|
+
* provide structured data through `collections/`, which is the only supported
|
|
662
|
+
* way. So `<name>/` is entirely ours and the reconciliation is total: anything
|
|
663
|
+
* in it that this run did not write is stale by definition. `expected` is
|
|
664
|
+
* empty when a collection stops declaring `deferred:`, which correctly clears
|
|
665
|
+
* a directory that will otherwise never be written again.
|
|
666
|
+
*
|
|
667
|
+
* NOT covered: a collection removed from `site.yml` entirely. There is no
|
|
668
|
+
* declaration left to reconcile against, so pruning it would mean the build
|
|
669
|
+
* asserting ownership of a directory on a name match alone. That needs the
|
|
670
|
+
* ownership question answered on purpose, not as a side effect of this.
|
|
671
|
+
*
|
|
672
|
+
* @param {string} dataDir - `public/data/`, the containing output directory
|
|
673
|
+
* @param {string} name - the declared collection name
|
|
674
|
+
* @param {Set<string>} expected - filenames this run wrote, e.g. `hello.json`
|
|
675
|
+
* @returns {Promise<string[]>} the entry names removed
|
|
676
|
+
*/
|
|
677
|
+
async function pruneOrphanedRecords(dataDir, name, expected) {
|
|
678
|
+
const recordsDir = join(dataDir, name)
|
|
679
|
+
|
|
680
|
+
// This routine deletes, and `name` reaches it from site.yml. A name that
|
|
681
|
+
// resolves outside the output directory would make the traversal somebody
|
|
682
|
+
// else's files, so refuse rather than trust the caller.
|
|
683
|
+
const contained = resolve(recordsDir)
|
|
684
|
+
if (contained !== resolve(dataDir, name) || !contained.startsWith(resolve(dataDir) + sep)) {
|
|
685
|
+
console.warn(
|
|
686
|
+
`[collection-processor] Refusing to prune "${name}" — it does not resolve ` +
|
|
687
|
+
`inside ${dataDir}`
|
|
688
|
+
)
|
|
689
|
+
return []
|
|
690
|
+
}
|
|
691
|
+
if (!existsSync(recordsDir)) return []
|
|
692
|
+
|
|
693
|
+
const removed = []
|
|
694
|
+
for (const entry of await readdir(recordsDir, { withFileTypes: true })) {
|
|
695
|
+
if (expected.has(entry.name)) continue
|
|
696
|
+
await rm(join(recordsDir, entry.name), { recursive: true, force: true })
|
|
697
|
+
removed.push(entry.isDirectory() ? `${entry.name}/` : entry.name)
|
|
698
|
+
}
|
|
699
|
+
return removed
|
|
700
|
+
}
|
|
701
|
+
|
|
645
702
|
/**
|
|
646
703
|
* Write collection data to JSON files in public/data/
|
|
647
704
|
*
|
|
@@ -660,7 +717,7 @@ export async function writeCollectionFiles(siteDir, collections, collectionsConf
|
|
|
660
717
|
return
|
|
661
718
|
}
|
|
662
719
|
|
|
663
|
-
const dataDir = join(siteDir, 'public',
|
|
720
|
+
const dataDir = join(siteDir, 'public', DATA_DIR)
|
|
664
721
|
await mkdir(dataDir, { recursive: true })
|
|
665
722
|
|
|
666
723
|
for (const [name, items] of Object.entries(collections)) {
|
|
@@ -677,13 +734,15 @@ export async function writeCollectionFiles(siteDir, collections, collectionsConf
|
|
|
677
734
|
const recordsDir = join(dataDir, name)
|
|
678
735
|
await mkdir(recordsDir, { recursive: true })
|
|
679
736
|
|
|
680
|
-
|
|
737
|
+
const written = new Set()
|
|
681
738
|
for (const item of items) {
|
|
682
739
|
if (!item || typeof item !== 'object' || !item.slug) continue
|
|
683
|
-
const
|
|
684
|
-
await writeFile(
|
|
685
|
-
|
|
740
|
+
const filename = `${item.slug}.json`
|
|
741
|
+
await writeFile(join(recordsDir, filename), JSON.stringify(item, null, 2))
|
|
742
|
+
written.add(filename)
|
|
686
743
|
}
|
|
744
|
+
const perRecordCount = written.size
|
|
745
|
+
const pruned = await pruneOrphanedRecords(dataDir, name, written)
|
|
687
746
|
|
|
688
747
|
const stripped = items.map((item) => {
|
|
689
748
|
if (!item || typeof item !== 'object') return item
|
|
@@ -697,10 +756,29 @@ export async function writeCollectionFiles(siteDir, collections, collectionsConf
|
|
|
697
756
|
`[collection-processor] Generated ${cascadePath} (${items.length} items, ` +
|
|
698
757
|
`deferred: [${deferred.join(', ')}]) + ${perRecordCount} per-record files`
|
|
699
758
|
)
|
|
759
|
+
if (pruned.length > 0) {
|
|
760
|
+
// A deletion is always worth naming. These files were public a moment
|
|
761
|
+
// ago, so "which ones went" is the question an author will have.
|
|
762
|
+
console.log(
|
|
763
|
+
`[collection-processor] Removed ${pruned.length} stale per-record ` +
|
|
764
|
+
`file(s) from ${recordsDir}: ${pruned.join(', ')}`
|
|
765
|
+
)
|
|
766
|
+
}
|
|
700
767
|
} else {
|
|
701
768
|
const filepath = join(dataDir, `${name}.json`)
|
|
702
769
|
await writeFile(filepath, JSON.stringify(items, null, 2))
|
|
703
770
|
console.log(`[collection-processor] Generated ${filepath} (${items.length} items)`)
|
|
771
|
+
|
|
772
|
+
// This collection is not deferred, so it has no per-record files. If it
|
|
773
|
+
// used to, the directory is still there and will never be written again
|
|
774
|
+
// — every file in it is stale. Same reconciliation, empty expected set.
|
|
775
|
+
const pruned = await pruneOrphanedRecords(dataDir, name, new Set())
|
|
776
|
+
if (pruned.length > 0) {
|
|
777
|
+
console.log(
|
|
778
|
+
`[collection-processor] Removed ${pruned.length} per-record file(s) ` +
|
|
779
|
+
`from ${join(dataDir, name)} — "${name}" no longer declares deferred:`
|
|
780
|
+
)
|
|
781
|
+
}
|
|
704
782
|
}
|
|
705
783
|
}
|
|
706
784
|
}
|
|
@@ -2152,9 +2152,10 @@ export async function collectSiteContent(sitePath, options = {}) {
|
|
|
2152
2152
|
// base prefix while the hydrated browser routes were fine.
|
|
2153
2153
|
//
|
|
2154
2154
|
// Only a real base is written. At '/' the field stays absent, because in
|
|
2155
|
-
// shell mode `config.base` is the SERVING layer's channel
|
|
2156
|
-
//
|
|
2157
|
-
//
|
|
2155
|
+
// shell mode `config.base` is the SERVING layer's channel — the host injects
|
|
2156
|
+
// whatever subpath it serves the site under, and a build-time '/' would be a
|
|
2157
|
+
// meaningless value sitting in its slot. The build deliberately does not model
|
|
2158
|
+
// what that subpath looks like; that is the host's shape, not ours.
|
|
2158
2159
|
if (base && base !== '/') {
|
|
2159
2160
|
siteConfig.base = base
|
|
2160
2161
|
}
|
package/src/site/data-ball.js
CHANGED
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
import { existsSync } from 'node:fs'
|
|
20
20
|
import { readFile, readdir } from 'node:fs/promises'
|
|
21
21
|
import { join, relative, sep } from 'node:path'
|
|
22
|
+
import { DATA_DIR } from '@uniweb/core'
|
|
22
23
|
import { isLocalAssetPath } from './assets.js'
|
|
23
24
|
|
|
24
25
|
// Walk a dist subdir for *.json → { "<posix-relpath>": <parsedJson> }. Unparseable
|
|
@@ -58,7 +59,7 @@ function collectionOf(relPath) {
|
|
|
58
59
|
*/
|
|
59
60
|
export async function assembleDataBall(distDir, schemalessNames = []) {
|
|
60
61
|
const schemaless = new Set(schemalessNames)
|
|
61
|
-
const allData = await readJsonTree(join(distDir,
|
|
62
|
+
const allData = await readJsonTree(join(distDir, DATA_DIR))
|
|
62
63
|
const data = {}
|
|
63
64
|
for (const [relPath, value] of Object.entries(allData)) {
|
|
64
65
|
if (schemaless.has(collectionOf(relPath))) data[relPath] = value
|
package/src/site/data-fetcher.js
CHANGED
|
@@ -20,7 +20,7 @@ import { readFile } from 'node:fs/promises'
|
|
|
20
20
|
import { join } from 'node:path'
|
|
21
21
|
import { existsSync } from 'node:fs'
|
|
22
22
|
import yaml from 'js-yaml'
|
|
23
|
-
import { matchWhere } from '@uniweb/core'
|
|
23
|
+
import { matchWhere, collectionDataUrl } from '@uniweb/core'
|
|
24
24
|
|
|
25
25
|
/**
|
|
26
26
|
* Infer schema name from path or URL
|
|
@@ -290,7 +290,7 @@ export function parseFetchConfig(fetch) {
|
|
|
290
290
|
if (fetch.collection) {
|
|
291
291
|
if (fetch.filter !== undefined) warnFilterDeprecated()
|
|
292
292
|
return {
|
|
293
|
-
path:
|
|
293
|
+
path: collectionDataUrl(fetch.collection),
|
|
294
294
|
url: undefined,
|
|
295
295
|
schema: fetch.schema || fetch.collection,
|
|
296
296
|
prerender: fetch.prerender ?? true,
|
package/src/site/head-markers.js
CHANGED
|
@@ -1,16 +1,26 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Stable markers for head content the build injects.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* (which produces `dist/index.html`)
|
|
6
|
-
* that HTML per page)
|
|
7
|
-
*
|
|
8
|
-
*
|
|
4
|
+
* Several stages can write the same block: this package's vite plugin
|
|
5
|
+
* (`transformIndexHtml`, which produces `dist/index.html`), this package's
|
|
6
|
+
* prerenderer (which post-processes that HTML per page), and — since the
|
|
7
|
+
* 2026-07-28 seam fix — `@uniweb/runtime`'s `injectPageContent()`, the prerender
|
|
8
|
+
* seam every lane shares. The marker lets a later stage tell "already injected"
|
|
9
|
+
* from "never injected" instead of guessing, so a page passing through several
|
|
10
|
+
* gets exactly one copy.
|
|
9
11
|
*
|
|
10
12
|
* The theme CSS uses `id="uniweb-theme"` on its <style> for the same purpose;
|
|
11
13
|
* <link> tags have no natural id to hang that on, hence the comment marker.
|
|
12
14
|
*
|
|
15
|
+
* **The marker itself now lives in `@uniweb/theming`**, beside the code that
|
|
16
|
+
* generates the block it delimits. This package and `@uniweb/runtime` cannot
|
|
17
|
+
* import one another, but both depend on `@uniweb/theming` — so that is the one
|
|
18
|
+
* home neither has to reach across a dependency boundary to read, and the
|
|
19
|
+
* literal is never duplicated (two halves of a dedupe check that drift apart
|
|
20
|
+
* stop deduping, silently). Re-exported here so every existing import in this
|
|
21
|
+
* package keeps working unchanged.
|
|
22
|
+
*
|
|
13
23
|
* @module @uniweb/build/site
|
|
14
24
|
*/
|
|
15
25
|
|
|
16
|
-
export
|
|
26
|
+
export { FONT_LINKS_MARKER } from '@uniweb/theming'
|
package/src/site/plugin.js
CHANGED
|
@@ -33,13 +33,15 @@
|
|
|
33
33
|
import { resolve, join } from 'node:path'
|
|
34
34
|
import { watch, existsSync } from 'node:fs'
|
|
35
35
|
import { readFile, readdir } from 'node:fs/promises'
|
|
36
|
-
import { resolveDefaultLocale } from '@uniweb/core'
|
|
36
|
+
import { resolveDefaultLocale, DATA_DIR } from '@uniweb/core'
|
|
37
37
|
import {
|
|
38
38
|
renderSiteIndex,
|
|
39
39
|
renderPageMarkdown,
|
|
40
40
|
resolveAgentsConfig,
|
|
41
41
|
selectIndexablePages,
|
|
42
|
+
selectIndexBranches,
|
|
42
43
|
pageMarkdownFilename,
|
|
44
|
+
branchIndexFilename,
|
|
43
45
|
applyRouteTranslation,
|
|
44
46
|
INDEX_FILENAME
|
|
45
47
|
} from '@uniweb/projections'
|
|
@@ -275,19 +277,68 @@ function escapeXml(str) {
|
|
|
275
277
|
.replace(/'/g, ''')
|
|
276
278
|
}
|
|
277
279
|
|
|
280
|
+
/** The three defined Content Signals, in the order they are emitted. */
|
|
281
|
+
const CONTENT_SIGNAL_KEYS = ['search', 'ai-input', 'ai-train']
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Format the `Content-Signal:` directive from `seo.robots.contentSignals`.
|
|
285
|
+
*
|
|
286
|
+
* Content Signals express what a site permits its content to be *used for*,
|
|
287
|
+
* which is a different axis from `Disallow:` — that governs fetching, this
|
|
288
|
+
* governs use after fetching. The three defined signals:
|
|
289
|
+
*
|
|
290
|
+
* - `search` — appear in search results
|
|
291
|
+
* - `ai-input` — be retrieved at inference time (RAG, grounding)
|
|
292
|
+
* - `ai-train` — be used to train a model
|
|
293
|
+
*
|
|
294
|
+
* **Emitted only when declared.** There is no default: a preference the site
|
|
295
|
+
* owner did not state is not ours to assert, in either direction. An absent
|
|
296
|
+
* signal means "unstated", which is not the same as `no`.
|
|
297
|
+
*
|
|
298
|
+
* Unknown keys are ignored rather than passed through — the vocabulary is a
|
|
299
|
+
* closed set, and forwarding an invented signal would produce a directive no
|
|
300
|
+
* crawler honors while reading as though it were doing something.
|
|
301
|
+
*
|
|
302
|
+
* @param {Object|null} signals - e.g. `{ search: true, 'ai-input': true, 'ai-train': false }`
|
|
303
|
+
* @returns {string} The directive line, or `''` when nothing is declared
|
|
304
|
+
*/
|
|
305
|
+
export function formatContentSignals(signals) {
|
|
306
|
+
if (!signals || typeof signals !== 'object') return ''
|
|
307
|
+
|
|
308
|
+
const parts = []
|
|
309
|
+
for (const key of CONTENT_SIGNAL_KEYS) {
|
|
310
|
+
if (!(key in signals)) continue
|
|
311
|
+
const value = signals[key]
|
|
312
|
+
const yes = value === true || value === 'yes'
|
|
313
|
+
const no = value === false || value === 'no'
|
|
314
|
+
if (!yes && !no) continue
|
|
315
|
+
parts.push(`${key}=${yes ? 'yes' : 'no'}`)
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
return parts.length ? `Content-Signal: ${parts.join(', ')}` : ''
|
|
319
|
+
}
|
|
320
|
+
|
|
278
321
|
/**
|
|
279
322
|
* Generate robots.txt content
|
|
280
323
|
*/
|
|
281
|
-
function generateRobotsTxt(baseUrl, options = {}) {
|
|
324
|
+
export function generateRobotsTxt(baseUrl, options = {}) {
|
|
282
325
|
const {
|
|
283
326
|
disallow = [],
|
|
284
327
|
allow = [],
|
|
285
328
|
crawlDelay = null,
|
|
286
|
-
additionalSitemaps = []
|
|
329
|
+
additionalSitemaps = [],
|
|
330
|
+
contentSignals = null
|
|
287
331
|
} = options
|
|
288
332
|
|
|
289
333
|
let content = 'User-agent: *\n'
|
|
290
334
|
|
|
335
|
+
const signals = formatContentSignals(contentSignals)
|
|
336
|
+
if (signals) {
|
|
337
|
+
// Inside the User-agent group, before the rules — the directive applies to
|
|
338
|
+
// the group it sits in.
|
|
339
|
+
content += `${signals}\n`
|
|
340
|
+
}
|
|
341
|
+
|
|
291
342
|
for (const path of allow) {
|
|
292
343
|
content += `Allow: ${path}\n`
|
|
293
344
|
}
|
|
@@ -622,6 +673,31 @@ export function siteContentPlugin(options = {}) {
|
|
|
622
673
|
source: renderSiteIndex(content, { ...options, exclude: agents.exclude })
|
|
623
674
|
})
|
|
624
675
|
console.log(`[site-content] Generated ${localeDir}${INDEX_FILENAME}`)
|
|
676
|
+
|
|
677
|
+
// Branch indexes are ADDITIVE — the root index above still enumerates
|
|
678
|
+
// every page, because the two-hop criterion depends on it. These are a
|
|
679
|
+
// scoped entry point for an agent already inside a branch.
|
|
680
|
+
if (agents.branchIndexes) {
|
|
681
|
+
const branches = selectIndexBranches(content.pages, {
|
|
682
|
+
exclude: agents.exclude,
|
|
683
|
+
minPages: agents.branchMinPages
|
|
684
|
+
})
|
|
685
|
+
for (const branch of branches) {
|
|
686
|
+
this.emitFile({
|
|
687
|
+
type: 'asset',
|
|
688
|
+
fileName: `${localeDir}${branchIndexFilename(branch.route)}`,
|
|
689
|
+
source: renderSiteIndex(content, {
|
|
690
|
+
...options,
|
|
691
|
+
exclude: agents.exclude,
|
|
692
|
+
branch: branch.route
|
|
693
|
+
})
|
|
694
|
+
})
|
|
695
|
+
}
|
|
696
|
+
if (branches.length) {
|
|
697
|
+
const names = branches.map(b => `${b.route} (${b.count})`).join(', ')
|
|
698
|
+
console.log(`[site-content] Generated ${branches.length} branch index(es): ${names}`)
|
|
699
|
+
}
|
|
700
|
+
}
|
|
625
701
|
}
|
|
626
702
|
|
|
627
703
|
if (agents.markdown) {
|
|
@@ -1006,18 +1082,39 @@ export function siteContentPlugin(options = {}) {
|
|
|
1006
1082
|
const agents = resolveAgentsConfig(siteContent.config)
|
|
1007
1083
|
const url = req.url.split('?')[0]
|
|
1008
1084
|
|
|
1009
|
-
|
|
1085
|
+
// `/llms.txt`, `/fr/llms.txt`, and the branch form `/docs/llms.txt`.
|
|
1086
|
+
// The optional middle group is the branch route; an empty one is the
|
|
1087
|
+
// site index, so a single pattern serves both rather than two that
|
|
1088
|
+
// could drift.
|
|
1089
|
+
const indexMatch = url.match(
|
|
1090
|
+
new RegExp(`^(?:\\/(${LOCALE_RE}))?((?:\\/[^/]+)*)\\/${INDEX_FILENAME}$`)
|
|
1091
|
+
)
|
|
1010
1092
|
if (indexMatch && agents.index) {
|
|
1011
1093
|
const localized = (indexMatch[1] ? await getTranslatedContent(indexMatch[1]) : null) || siteContent
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1094
|
+
const branch = indexMatch[2] || null
|
|
1095
|
+
|
|
1096
|
+
// Serve a branch index only where the build would emit one, or dev
|
|
1097
|
+
// and the built output disagree about which URLs exist.
|
|
1098
|
+
const served =
|
|
1099
|
+
!branch ||
|
|
1100
|
+
(agents.branchIndexes &&
|
|
1101
|
+
selectIndexBranches(localized.pages, {
|
|
1102
|
+
exclude: agents.exclude,
|
|
1103
|
+
minPages: agents.branchMinPages
|
|
1104
|
+
}).some(b => b.route === branch))
|
|
1105
|
+
|
|
1106
|
+
if (served) {
|
|
1107
|
+
res.setHeader('Content-Type', 'text/plain; charset=utf-8')
|
|
1108
|
+
res.end(
|
|
1109
|
+
renderSiteIndex(localized, {
|
|
1110
|
+
...projectionOptions(localized),
|
|
1111
|
+
locale: indexMatch[1] || projectionOptions(localized).locale,
|
|
1112
|
+
exclude: agents.exclude,
|
|
1113
|
+
branch
|
|
1114
|
+
})
|
|
1115
|
+
)
|
|
1116
|
+
return
|
|
1117
|
+
}
|
|
1021
1118
|
}
|
|
1022
1119
|
|
|
1023
1120
|
const markdownMatch = url.match(new RegExp(`^(?:\\/(${LOCALE_RE}))?\\/(.+)\\.md$`))
|
|
@@ -1038,12 +1135,12 @@ export function siteContentPlugin(options = {}) {
|
|
|
1038
1135
|
}
|
|
1039
1136
|
|
|
1040
1137
|
// Handle localized collection data (e.g., /fr/data/articles.json)
|
|
1041
|
-
const localeDataMatch = req.url.match(new RegExp(`^\\/(${LOCALE_RE})\\/
|
|
1138
|
+
const localeDataMatch = req.url.match(new RegExp(`^\\/(${LOCALE_RE})\\/${DATA_DIR}\\/(.+\\.json)$`))
|
|
1042
1139
|
if (localeDataMatch) {
|
|
1043
1140
|
const locale = localeDataMatch[1]
|
|
1044
1141
|
const filename = localeDataMatch[2]
|
|
1045
1142
|
const collectionName = filename.replace('.json', '')
|
|
1046
|
-
const sourcePath = join(resolvedSitePath, 'public',
|
|
1143
|
+
const sourcePath = join(resolvedSitePath, 'public', DATA_DIR, filename)
|
|
1047
1144
|
|
|
1048
1145
|
if (existsSync(sourcePath)) {
|
|
1049
1146
|
try {
|
package/src/uwx/locale-sync.js
CHANGED
|
@@ -108,29 +108,50 @@ export function loadLocaleTranslations(siteRoot, locales, subdir = '') {
|
|
|
108
108
|
* map lives only on disk (`locales/{locale}.json`) and is recovered on pull (see
|
|
109
109
|
* unwrapLocalizedContent).
|
|
110
110
|
*
|
|
111
|
-
*
|
|
112
|
-
* for
|
|
113
|
-
*
|
|
114
|
-
*
|
|
111
|
+
* ALWAYS returns the per-locale map — `{ [sourceLocale]: doc }` at minimum. A target
|
|
112
|
+
* locale with no translation for THIS doc is omitted (it falls back to the source
|
|
113
|
+
* locale through the delivery locale chain), so the payload stays lean.
|
|
114
|
+
*
|
|
115
|
+
* It used to return the bare doc when nothing had been translated, which made the
|
|
116
|
+
* wire shape depend on *whether a given section happened to have a translation* —
|
|
117
|
+
* so one push could carry a map for a translated section and a bare doc for its
|
|
118
|
+
* untranslated neighbour, on the same page. The field declares `localized: true`,
|
|
119
|
+
* and a bare doc makes that declaration false: `{type, content}` is an object, so
|
|
120
|
+
* it satisfies "must be a map" with `type` and `content` sitting where locale codes
|
|
121
|
+
* belong, and a store cannot validate the one property the field declares. Every
|
|
122
|
+
* reader then has to disambiguate by asking "does this object happen to look like a
|
|
123
|
+
* ProseMirror doc" — a heuristic that has to be implemented identically in three
|
|
124
|
+
* codebases forever, and has already fail-opened in one of them.
|
|
125
|
+
*
|
|
126
|
+
* `localizeScalar` below has always wrapped unconditionally, so the producer was
|
|
127
|
+
* also inconsistent with itself: a section's `title` shipped as `{en: …}` while its
|
|
128
|
+
* `content` shipped bare, in the same payload.
|
|
129
|
+
*
|
|
130
|
+
* Readers stay tolerant of the bare form permanently — it exists in stores, in
|
|
131
|
+
* `.uwx` files on disk, and in every backup taken before this change. This changes
|
|
132
|
+
* what we WRITE, never what we accept (see `isLocalizedContent`, and
|
|
133
|
+
* `unwrapLocalizedContent`, which returns `content[sourceLocale]` so a source-only
|
|
134
|
+
* map round-trips back to a bare doc on the file lane).
|
|
115
135
|
*
|
|
116
136
|
* @param {object} doc - the source-locale ProseMirror content doc
|
|
117
137
|
* @param {string} sourceLocale
|
|
118
|
-
* @param {string[]} targetLocales
|
|
119
|
-
* @param {object} translations - `{ locale: { hash: tgt } }` from loadLocaleTranslations
|
|
138
|
+
* @param {string[]} [targetLocales]
|
|
139
|
+
* @param {object} [translations] - `{ locale: { hash: tgt } }` from loadLocaleTranslations
|
|
120
140
|
*/
|
|
121
141
|
export function localizeContentDoc(doc, sourceLocale, targetLocales, translations) {
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
142
|
+
// Non-docs (null, an already-localized map) pass through untouched.
|
|
143
|
+
if (!isProseMirrorDoc(doc)) return doc
|
|
144
|
+
|
|
125
145
|
const result = { [sourceLocale]: doc }
|
|
126
|
-
|
|
127
|
-
const
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
146
|
+
if (targetLocales && translations) {
|
|
147
|
+
for (const locale of targetLocales) {
|
|
148
|
+
const table = translations[locale]
|
|
149
|
+
if (!table) continue
|
|
150
|
+
const resolved = resolveDocForLocale(doc, table)
|
|
151
|
+
if (resolved) result[locale] = resolved
|
|
152
|
+
}
|
|
131
153
|
}
|
|
132
|
-
|
|
133
|
-
return Object.keys(result).length > 1 ? result : doc
|
|
154
|
+
return result
|
|
134
155
|
}
|
|
135
156
|
|
|
136
157
|
/**
|
package/src/uwx/site.js
CHANGED
|
@@ -54,7 +54,7 @@ import {
|
|
|
54
54
|
processMarkdownFile,
|
|
55
55
|
} from '../site/content-collector.js'
|
|
56
56
|
import { normalizeHideIn } from '../site/nav-visibility.js'
|
|
57
|
-
import { resolveDefaultLocale, validateLanguageConfig } from '@uniweb/core'
|
|
57
|
+
import { resolveDefaultLocale, validateLanguageConfig, collectionDataUrl } from '@uniweb/core'
|
|
58
58
|
import { emitEntitySyncPackage } from './entity-document.js'
|
|
59
59
|
import { loadLocaleTranslations, localizeScalar, localizeScalarList, localizeContentDoc, localesDir, isLocalizedContent } from './locale-sync.js'
|
|
60
60
|
import { unwrapLocalized } from './backfill.js'
|
|
@@ -182,7 +182,7 @@ function buildPageData(config, ctx) {
|
|
|
182
182
|
// `schema` (the collection name) is BOTH the content.data key and part of the
|
|
183
183
|
// dataStore cache key (deriveCacheKey hashes {path,url,schema,…}; `collection`
|
|
184
184
|
// is ignored). Mirrors the static build's parseFetchConfig resolution.
|
|
185
|
-
fetch = { path:
|
|
185
|
+
fetch = { path: collectionDataUrl(collection), schema: collection, ...rest }
|
|
186
186
|
}
|
|
187
187
|
setIf(data, 'fetch', fetch)
|
|
188
188
|
if (isDynamic) {
|
|
@@ -663,11 +663,14 @@ export async function siteProjectToDocument(siteRoot, opts = {}) {
|
|
|
663
663
|
|
|
664
664
|
// Wrap each section's content into its per-locale form (source doc + target
|
|
665
665
|
// structural maps from locales/{locale}.json, or a free-form body override from
|
|
666
|
-
// locales/freeform/**)
|
|
667
|
-
//
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
666
|
+
// locales/freeform/**). A non-invasive post-pass over the built tree.
|
|
667
|
+
//
|
|
668
|
+
// Runs for EVERY site, including single-locale ones: `content` declares
|
|
669
|
+
// `localized: true`, so it ships as `{ [sourceLocale]: doc }` whatever the
|
|
670
|
+
// language count. Guarding this on `targetLocales.length` is what used to send
|
|
671
|
+
// a bare doc from single-locale sites — see localizeContentDoc for why one
|
|
672
|
+
// field with two shapes is a store that cannot validate its own declaration.
|
|
673
|
+
await localizeContentTree(pages, layoutSections, sourceLocale, targetLocales, translations, siteRoot)
|
|
671
674
|
|
|
672
675
|
// Collection DECLARATIONS — the merged collections.yml + site.yml::collections
|
|
673
676
|
// config (the records themselves are separate entities; this is just the config).
|