@uniweb/build 0.56.0 → 0.58.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 +6 -6
- package/src/content/index.js +3 -0
- package/src/foundation-name.js +63 -0
- package/src/i18n/data-strings.js +1 -1
- package/src/i18n/freeform.js +20 -19
- package/src/i18n/index.js +4 -4
- package/src/i18n/records.js +10 -6
- package/src/index.js +8 -0
- package/src/prerender.js +1 -1
- package/src/schema.js +67 -10
- package/src/site/build-site-data.js +4 -3
- package/src/site/config.js +1 -1
- package/src/site/content-collector.js +5 -4
- package/src/site/entity-pool.js +186 -44
- package/src/site/plugin.js +20 -15
- package/src/site/queries-config.js +16 -6
- package/src/site/query-processor.js +129 -114
- package/src/site/record-draft.js +40 -0
- package/src/site/records-config.js +156 -107
- package/src/utils/numeric-prefix.js +1 -1
- package/src/uwx/asset-map.js +2 -1
- package/src/uwx/backfill.js +24 -4
- package/src/uwx/entity-source.js +4 -4
- package/src/uwx/folder.js +89 -49
- package/src/uwx/index.js +12 -11
- package/src/uwx/project-writer.js +13 -11
- package/src/uwx/records-project.js +108 -75
- package/src/uwx/records.js +319 -227
- package/src/uwx/registry-package.js +8 -1
- package/src/uwx/self-scope.js +5 -5
- package/src/uwx/site-project.js +3 -3
- package/src/uwx/site.js +45 -39
- package/src/uwx/sync-package.js +19 -15
- package/src/validate-data.js +3 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniweb/build",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.58.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/schemas": "^0.3.3",
|
|
61
|
-
"@uniweb/projections": "^0.7.3",
|
|
62
60
|
"@uniweb/content-writer": "^0.3.4",
|
|
63
61
|
"@uniweb/content-reader": "^1.2.5",
|
|
62
|
+
"@uniweb/projections": "^0.7.3",
|
|
64
63
|
"@uniweb/semantic-parser": "^1.4.1",
|
|
64
|
+
"@uniweb/schemas": "^0.3.3",
|
|
65
65
|
"@uniweb/theming": "^0.1.15"
|
|
66
66
|
},
|
|
67
67
|
"optionalDependencies": {
|
|
68
|
-
"@uniweb/runtime": "^0.27.
|
|
68
|
+
"@uniweb/runtime": "^0.27.4"
|
|
69
69
|
},
|
|
70
70
|
"peerDependencies": {
|
|
71
71
|
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0",
|
|
@@ -74,8 +74,8 @@
|
|
|
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/
|
|
78
|
-
"@uniweb/
|
|
77
|
+
"@uniweb/core": "^0.29.8",
|
|
78
|
+
"@uniweb/runtime": "^0.27.4"
|
|
79
79
|
},
|
|
80
80
|
"peerDependenciesMeta": {
|
|
81
81
|
"vite": {
|
package/src/content/index.js
CHANGED
|
@@ -21,6 +21,9 @@ export {
|
|
|
21
21
|
writeQueryFiles,
|
|
22
22
|
getQueryLastModified,
|
|
23
23
|
} from '../site/query-processor.js'
|
|
24
|
+
// Where a site's records live — `paths.records`, else `records/` — so a caller
|
|
25
|
+
// holding its own config (a document's, not a `site.yml`) asks the one resolver.
|
|
26
|
+
export { resolveRecordsDir } from '../site/entity-pool.js'
|
|
24
27
|
export {
|
|
25
28
|
parseFetchConfig,
|
|
26
29
|
executeFetch,
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// A foundation's NAME — what it registers as (`@<org>/<name>`) and what a site pins
|
|
2
|
+
// (`@<org>/<name>@<version>`). A registry keys foundations by it: (name, version) is
|
|
3
|
+
// unique on a backend, and the uuid a backend mints for one never leaves that backend,
|
|
4
|
+
// so the name is the identity that travels.
|
|
5
|
+
//
|
|
6
|
+
// ⭐ ONE RULE, and every reader asks it: the build (`schema.json`'s `_self.name`),
|
|
7
|
+
// `uniweb register`, and `push` / `publish` looking a local foundation up in a
|
|
8
|
+
// catalog. **`main.js`'s `name`, else `package.json`'s `name`.**
|
|
9
|
+
//
|
|
10
|
+
// ⛔ `package.json::name` IS WORKSPACE PLUMBING FIRST. pnpm needs it unique in the
|
|
11
|
+
// workspace, and a site depends on it by that name (`"src": "file:../src"`,
|
|
12
|
+
// `site.yml::foundation: src`). The scaffold names it `src` for exactly that reason,
|
|
13
|
+
// which is why it is only the fallback here — and why `src` and `foundation` are
|
|
14
|
+
// refused as a foundation's name: they name the folder, not the foundation, and every
|
|
15
|
+
// project in an org would register the same one.
|
|
16
|
+
//
|
|
17
|
+
// ⛔ `package.json::uniweb.id` WAS A REGISTRY-NAME OVERRIDE, 2026-07-17 → 2026-09-21.
|
|
18
|
+
// It is retired: the name lives in `main.js`, and two homes for one fact is how `push`
|
|
19
|
+
// and `register` came to look the same foundation up under different names.
|
|
20
|
+
|
|
21
|
+
/** Names that say where the code lives, not what the foundation is. */
|
|
22
|
+
export const FORBIDDEN_FOUNDATION_NAMES = new Set(['src', 'foundation'])
|
|
23
|
+
|
|
24
|
+
// One segment of a name: lowercase letters, digits and inner hyphens.
|
|
25
|
+
const SEGMENT = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The foundation's name, by the one rule: `main.js`'s `name`, else `package.json`'s.
|
|
29
|
+
*
|
|
30
|
+
* @param {{ config?: object, pkg?: object }} sources - `main.js`'s default export
|
|
31
|
+
* and the parsed `package.json`
|
|
32
|
+
* @returns {{ name: string|null, source: 'main.js'|'package.json'|null }}
|
|
33
|
+
*/
|
|
34
|
+
export function foundationNameOf({ config, pkg } = {}) {
|
|
35
|
+
const fromConfig = typeof config?.name === 'string' ? config.name.trim() : ''
|
|
36
|
+
if (fromConfig) return { name: fromConfig, source: 'main.js' }
|
|
37
|
+
const fromPkg = typeof pkg?.name === 'string' ? pkg.name.trim() : ''
|
|
38
|
+
if (fromPkg) return { name: fromPkg, source: 'package.json' }
|
|
39
|
+
return { name: null, source: null }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Is this a name a foundation can register under? `marketing`, or `@acme/marketing`
|
|
44
|
+
* when the name carries its own scope.
|
|
45
|
+
*
|
|
46
|
+
* @param {unknown} name
|
|
47
|
+
* @returns {string|null} why it cannot, or null when it can
|
|
48
|
+
*/
|
|
49
|
+
export function checkFoundationName(name) {
|
|
50
|
+
if (typeof name !== 'string' || !name.trim()) return 'the foundation has no name'
|
|
51
|
+
const scoped = /^@([^/]+)\/([^/]+)$/.exec(name)
|
|
52
|
+
if (!scoped && name.includes('/')) {
|
|
53
|
+
return `"${name}" is not a foundation name — it is a name, or @org/name`
|
|
54
|
+
}
|
|
55
|
+
const [scope, bare] = scoped ? [scoped[1], scoped[2]] : [null, name]
|
|
56
|
+
if (FORBIDDEN_FOUNDATION_NAMES.has(bare)) {
|
|
57
|
+
return `"${bare}" names the folder, not the foundation — and every project in an org would register the same one`
|
|
58
|
+
}
|
|
59
|
+
if (!SEGMENT.test(bare) || (scope !== null && !SEGMENT.test(scope))) {
|
|
60
|
+
return `"${name}" is not a foundation name — use lowercase letters, digits and hyphens (like "marketing")`
|
|
61
|
+
}
|
|
62
|
+
return null
|
|
63
|
+
}
|
package/src/i18n/data-strings.js
CHANGED
|
@@ -31,7 +31,7 @@ export const HEURISTIC_SKIP_FIELDS = new Set([
|
|
|
31
31
|
'pmid', 'bibtex', 'pdf', 'code', 'data', 'slides', 'video',
|
|
32
32
|
'repository', 'caseStudy', 'website', 'avatar', 'image',
|
|
33
33
|
'thumbnail', 'currency', 'order', 'hidden', 'current',
|
|
34
|
-
'featured', 'published', 'allDay', 'remote', 'hybrid',
|
|
34
|
+
'featured', 'published', 'draft', 'allDay', 'remote', 'hybrid',
|
|
35
35
|
'noindex', 'corresponding', 'required', 'virtual',
|
|
36
36
|
'lastModified', 'date', 'updated', 'posted', 'submitted',
|
|
37
37
|
'accepted', 'startDate', 'endDate', 'deadline',
|
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
|
+
* records/{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:)
|
|
@@ -22,7 +22,7 @@ import { readFile, readdir, stat } from 'fs/promises'
|
|
|
22
22
|
import { existsSync } from 'node:fs'
|
|
23
23
|
import { join, relative, dirname, sep } from 'node:path'
|
|
24
24
|
import yaml from 'js-yaml'
|
|
25
|
-
import { poolDirsForSchema,
|
|
25
|
+
import { poolDirsForSchema, schemaForPoolDirs, RECORDS_DIR } from '../site/entity-pool.js'
|
|
26
26
|
import { parseFrontmatter } from '../utils/frontmatter.js'
|
|
27
27
|
import { computeSourceHash } from './freeform-manifest.js'
|
|
28
28
|
|
|
@@ -101,13 +101,13 @@ export async function loadFreeformTranslation(section, page, locale, localesDir)
|
|
|
101
101
|
}
|
|
102
102
|
|
|
103
103
|
/**
|
|
104
|
-
* Load a free-form translation for one
|
|
104
|
+
* Load a free-form translation for one RECORD.
|
|
105
105
|
*
|
|
106
|
-
* Path:
|
|
106
|
+
* Path: records/{schema dirs}/{slug}.md — mirroring the records directory exactly.
|
|
107
107
|
*
|
|
108
|
-
* ⛔ KEYED BY THE
|
|
108
|
+
* ⛔ KEYED BY THE RECORD, NOT BY A QUERY. It used to be
|
|
109
109
|
* `collections/{queryName}/{slug}.md`, which was fine only while a
|
|
110
|
-
* collection was also a directory of files. With `
|
|
110
|
+
* collection was also a directory of files. With `records/{schema}/` holding records,
|
|
111
111
|
* TWO queries can cover one schema — so a query-keyed path would make an author
|
|
112
112
|
* write the same translation once per query, and finding neither from the other.
|
|
113
113
|
* ⚠️ And a query is renameable where a record's model is not: keying on the query
|
|
@@ -185,7 +185,7 @@ async function discoverMarkdownFiles(dir, baseDir) {
|
|
|
185
185
|
* register a translation that has no manifest entry yet.
|
|
186
186
|
*
|
|
187
187
|
* ⭐ Each path is relative to the locale's free-form directory — `pages/about/story.md`,
|
|
188
|
-
* `page-ids/<id>/intro.md`, `
|
|
188
|
+
* `page-ids/<id>/intro.md`, `records/article/hello.md` — the form the manifest keys a
|
|
189
189
|
* translation by and `freeformPathsFor` derives. ⛔ Until 2026-09-14 each was relative
|
|
190
190
|
* to its own subdirectory (`about/story.md`), so none matched a manifest key: the build
|
|
191
191
|
* registered no new file, and a status counted every stale or orphaned file as up to date.
|
|
@@ -217,10 +217,10 @@ export async function discoverFreeformTranslations(locale, localesDir) {
|
|
|
217
217
|
result.pageIds = (await discoverMarkdownFiles(pageIdsDir, freeformDir)).sort()
|
|
218
218
|
}
|
|
219
219
|
|
|
220
|
-
// Discover
|
|
221
|
-
const
|
|
222
|
-
if (existsSync(
|
|
223
|
-
result.records = (await discoverMarkdownFiles(
|
|
220
|
+
// Discover record translations
|
|
221
|
+
const recordsDir = join(freeformDir, RECORDS_DIR)
|
|
222
|
+
if (existsSync(recordsDir)) {
|
|
223
|
+
result.records = (await discoverMarkdownFiles(recordsDir, freeformDir)).sort()
|
|
224
224
|
}
|
|
225
225
|
|
|
226
226
|
return result
|
|
@@ -246,7 +246,7 @@ export async function getFreeformFileMeta(filePath) {
|
|
|
246
246
|
* Parse a free-form translation file path to extract metadata
|
|
247
247
|
*
|
|
248
248
|
* @param {string} relativePath - Path relative to locale's freeform dir
|
|
249
|
-
* @returns {Object} { type, pageRoute?, pageId?,
|
|
249
|
+
* @returns {Object} { type, pageRoute?, pageId?, schema?, stableId, slug? }
|
|
250
250
|
*/
|
|
251
251
|
export function parseFreeformPath(relativePath) {
|
|
252
252
|
const parts = relativePath.split('/')
|
|
@@ -266,11 +266,12 @@ export function parseFreeformPath(relativePath) {
|
|
|
266
266
|
return { type: 'pageId', pageId, stableId }
|
|
267
267
|
}
|
|
268
268
|
|
|
269
|
-
if (parts[0] ===
|
|
270
|
-
//
|
|
269
|
+
if (parts[0] === RECORDS_DIR) {
|
|
270
|
+
// records/std/person/ada.md → { type: 'record', schema: '@std/person', slug: 'ada' }
|
|
271
|
+
// ⛔ This returned `queryName: parts[1]` until 2026-09-21 — the first schema
|
|
272
|
+
// segment (`std` above), and never a query's name. Nothing read it.
|
|
271
273
|
const slug = parts[parts.length - 1].replace('.md', '')
|
|
272
|
-
|
|
273
|
-
return { type: 'record', queryName, slug }
|
|
274
|
+
return { type: 'record', schema: schemaForPoolDirs(parts.slice(1, -1)), slug }
|
|
274
275
|
}
|
|
275
276
|
|
|
276
277
|
return { type: 'unknown', relativePath }
|
|
@@ -343,7 +344,7 @@ function freeformSections(siteContent) {
|
|
|
343
344
|
* - `canJudge(path)` — whether this content can say a translation at `path` is
|
|
344
345
|
* orphaned. ⛔ Only a page section's (`pages/…`, `page-ids/…`), and not one for a page
|
|
345
346
|
* whose sections the content does not carry — a prerendered site with split content
|
|
346
|
-
* rewrites `site-content.json` without them. A record's (`
|
|
347
|
+
* rewrites `site-content.json` without them. A record's (`records/…`) is read from
|
|
347
348
|
* records this content does not hold. A check that cannot see the source must never
|
|
348
349
|
* call its translation orphaned: `prune --freeform` deletes what it is told is.
|
|
349
350
|
*
|
|
@@ -413,10 +414,10 @@ export function buildFreeformPath(section, page, preferPageId = true) {
|
|
|
413
414
|
*
|
|
414
415
|
* @param {string} schema - the entity's model ref (`@/name` or `@org/name`)
|
|
415
416
|
* @param {string} slug - the entity's slug
|
|
416
|
-
* @returns {string|null} e.g. `
|
|
417
|
+
* @returns {string|null} e.g. `records/article/getting-started.md`, or null for a
|
|
417
418
|
* ref this layout cannot express
|
|
418
419
|
*/
|
|
419
420
|
export function buildFreeformRecordPath(schema, slug) {
|
|
420
421
|
const dirs = poolDirsForSchema(schema)
|
|
421
|
-
return dirs ? `${
|
|
422
|
+
return dirs ? `${RECORDS_DIR}/${dirs.join('/')}/${slug}.md` : null
|
|
422
423
|
}
|
package/src/i18n/index.js
CHANGED
|
@@ -21,7 +21,7 @@ import {
|
|
|
21
21
|
buildLocalizedRecords,
|
|
22
22
|
getRecordLocales,
|
|
23
23
|
translateRecordData,
|
|
24
|
-
|
|
24
|
+
RECORD_LOCALES_DIR
|
|
25
25
|
} from './records.js'
|
|
26
26
|
import { resolveDefaultLocale } from '@uniweb/core'
|
|
27
27
|
import { generateSearchIndex } from '@uniweb/projections'
|
|
@@ -75,7 +75,7 @@ export {
|
|
|
75
75
|
buildLocalizedRecords,
|
|
76
76
|
getRecordLocales,
|
|
77
77
|
translateRecordData,
|
|
78
|
-
|
|
78
|
+
RECORD_LOCALES_DIR,
|
|
79
79
|
|
|
80
80
|
// Locale resolution
|
|
81
81
|
getAvailableLocales,
|
|
@@ -234,7 +234,7 @@ export async function extractRecordManifest(siteRoot, options = {}) {
|
|
|
234
234
|
const manifest = await extractRecordContent(siteRoot)
|
|
235
235
|
|
|
236
236
|
// Ensure collections locales directory exists
|
|
237
|
-
const recordLocalesDir = join(siteRoot, localesDir,
|
|
237
|
+
const recordLocalesDir = join(siteRoot, localesDir, RECORD_LOCALES_DIR)
|
|
238
238
|
if (!existsSync(recordLocalesDir)) {
|
|
239
239
|
await mkdir(recordLocalesDir, { recursive: true })
|
|
240
240
|
}
|
|
@@ -517,7 +517,7 @@ async function warnAboutFreeformIssues(locale, freeformDir, siteContent) {
|
|
|
517
517
|
}
|
|
518
518
|
|
|
519
519
|
// Check for orphaned translations — only those this content can judge: never a
|
|
520
|
-
// record's (`
|
|
520
|
+
// record's (`records/…`), which is read from records the site content does not hold.
|
|
521
521
|
const orphaned = await getOrphanedTranslations(freeformDir, validPaths)
|
|
522
522
|
for (const item of orphaned) {
|
|
523
523
|
if (!canJudge(item.path)) continue
|
package/src/i18n/records.js
CHANGED
|
@@ -23,7 +23,7 @@ import { loadFreeformRecord } from './freeform.js'
|
|
|
23
23
|
// is not, wherever the value came from. Moved rather than copied: two tuned
|
|
24
24
|
// denylists would drift, and drift here is silent.
|
|
25
25
|
import { resolveQueriesConfig } from '../site/queries-config.js'
|
|
26
|
-
import { poolDirsForSchema, schemaForPoolDirs,
|
|
26
|
+
import { poolDirsForSchema, schemaForPoolDirs, resolveRecordsDir } from '../site/entity-pool.js'
|
|
27
27
|
import {
|
|
28
28
|
NON_TRANSLATABLE_TYPES,
|
|
29
29
|
HEURISTIC_SKIP_FIELDS,
|
|
@@ -37,7 +37,11 @@ import {
|
|
|
37
37
|
// contexts were keyed by the QUERY, so two queries over one schema produced two
|
|
38
38
|
// entries for one record, each invisible from the other, and a renamed query
|
|
39
39
|
// orphaned every translation under it.
|
|
40
|
-
|
|
40
|
+
//
|
|
41
|
+
// ⚠️ `RECORD_LOCALES_DIR`, and not `RECORDS_DIR`, since 2026-09-21: that name is the
|
|
42
|
+
// site's own `records/` directory now (`site/entity-pool.js`), and this one is
|
|
43
|
+
// `locales/records/` — two directories, one word, so the constant says which.
|
|
44
|
+
export const RECORD_LOCALES_DIR = 'records'
|
|
41
45
|
|
|
42
46
|
// ---------------------------------------------------------------------------
|
|
43
47
|
// Schema resolution
|
|
@@ -72,7 +76,7 @@ async function resolveSchema(queryName, siteRoot) {
|
|
|
72
76
|
// an author to write into it was the one remaining place the framework
|
|
73
77
|
// contradicted its own rule that `collections/` is the only way to provide
|
|
74
78
|
// structured data. The schema describes the source, so it lives with it.
|
|
75
|
-
const companionPath = join(siteRoot,
|
|
79
|
+
const companionPath = join(resolveRecordsDir(siteRoot).abs, `${queryName}.schema.js`)
|
|
76
80
|
if (existsSync(companionPath)) {
|
|
77
81
|
try {
|
|
78
82
|
const mod = await import(pathToFileURL(companionPath).href)
|
|
@@ -154,7 +158,7 @@ function isFieldTranslatable(fieldDef) {
|
|
|
154
158
|
* ⛔ A RECORD'S SYSTEM FIELDS ARE NEVER PROSE — never extracted, never translated, by
|
|
155
159
|
* either path. A `$`-prefixed key at any depth is the system's (`$name`, the handle a
|
|
156
160
|
* parametric page's URL names; `$uuid`), and a record's top-level `slug` and `path`
|
|
157
|
-
* are its handle and its placement in `
|
|
161
|
+
* are its handle and its placement in `folder.yml`. Until 2026-09-14 they became
|
|
158
162
|
* translation units, and a translation whose source happened to equal one rewrote it,
|
|
159
163
|
* so a record's page stopped matching its URL.
|
|
160
164
|
*
|
|
@@ -657,7 +661,7 @@ export async function buildLocalizedRecords(siteRoot, options = {}) {
|
|
|
657
661
|
const {
|
|
658
662
|
locales = [],
|
|
659
663
|
outputDir = join(siteRoot, 'dist'),
|
|
660
|
-
recordLocalesDir = join(siteRoot, 'locales',
|
|
664
|
+
recordLocalesDir = join(siteRoot, 'locales', RECORD_LOCALES_DIR),
|
|
661
665
|
localesDir = join(siteRoot, 'locales'),
|
|
662
666
|
freeformEnabled = true
|
|
663
667
|
} = options
|
|
@@ -987,7 +991,7 @@ export async function translateRecordData(items, queryName, siteRoot, options =
|
|
|
987
991
|
* @returns {Promise<string[]>} Array of locale codes
|
|
988
992
|
*/
|
|
989
993
|
export async function getRecordLocales(localesPath) {
|
|
990
|
-
const recordLocalesDir = join(localesPath,
|
|
994
|
+
const recordLocalesDir = join(localesPath, RECORD_LOCALES_DIR)
|
|
991
995
|
if (!existsSync(recordLocalesDir)) return []
|
|
992
996
|
|
|
993
997
|
try {
|
package/src/index.js
CHANGED
|
@@ -11,8 +11,16 @@ export {
|
|
|
11
11
|
discoverComponents,
|
|
12
12
|
buildSchema,
|
|
13
13
|
getExposedComponents,
|
|
14
|
+
readFoundationName,
|
|
14
15
|
} from './schema.js'
|
|
15
16
|
|
|
17
|
+
// A foundation's name — the one rule the build, `register` and `push` share
|
|
18
|
+
export {
|
|
19
|
+
foundationNameOf,
|
|
20
|
+
checkFoundationName,
|
|
21
|
+
FORBIDDEN_FOUNDATION_NAMES,
|
|
22
|
+
} from './foundation-name.js'
|
|
23
|
+
|
|
16
24
|
// Data-conformance checking (your content vs the schemas your foundation declares)
|
|
17
25
|
export {
|
|
18
26
|
validateItem,
|
package/src/prerender.js
CHANGED
|
@@ -407,7 +407,7 @@ export function expandDynamicPages(pages, fetched, onProgress = () => {}, stats
|
|
|
407
407
|
for (const paramValue of values) {
|
|
408
408
|
|
|
409
409
|
// Create concrete route: /blog/:slug → /blog/my-post. Under `[...path]` the
|
|
410
|
-
// record's URL is its placement (the folder `
|
|
410
|
+
// record's URL is its placement (the folder `folder.yml` put it in, carried
|
|
411
411
|
// as `path`) plus its handle — the split rule in reverse. ⛔ A FILE PATH, so
|
|
412
412
|
// decoded: the server decodes the request before looking the file up.
|
|
413
413
|
const capture = catchAll ? joinPathCapture({ dir: item.path, slug: paramValue }) : null
|
package/src/schema.js
CHANGED
|
@@ -23,6 +23,8 @@ import {
|
|
|
23
23
|
deriveRecordsSupport,
|
|
24
24
|
unnameableIn,
|
|
25
25
|
} from './foundation/derive-supports.js'
|
|
26
|
+
import { foundationNameOf } from './foundation-name.js'
|
|
27
|
+
import { resolveFoundationSrcDir } from './utils/foundation-source-root.js'
|
|
26
28
|
|
|
27
29
|
// Component meta file name
|
|
28
30
|
const META_FILE_NAME = 'meta.js'
|
|
@@ -176,13 +178,21 @@ export async function loadPackageJson(srcDir) {
|
|
|
176
178
|
const content = await readFile(packagePath, 'utf-8')
|
|
177
179
|
const pkg = JSON.parse(content)
|
|
178
180
|
|
|
179
|
-
// Extract only identity fields for schema.
|
|
180
|
-
//
|
|
181
|
-
//
|
|
182
|
-
//
|
|
183
|
-
//
|
|
181
|
+
// Extract only identity fields for schema. `name` here is the package's — the
|
|
182
|
+
// FALLBACK for the foundation's name, which `main.js` supplies first
|
|
183
|
+
// (`foundation-name.js`, applied in `buildSchema`).
|
|
184
|
+
//
|
|
185
|
+
// ⛔ `uniweb.id` is no longer read (retired 2026-09-21): it was a registry-name
|
|
186
|
+
// override, and the name lives in `main.js` now. Said here rather than refused —
|
|
187
|
+
// a build does not register, and `uniweb register` refuses it by name.
|
|
188
|
+
if (pkg.uniweb?.id !== undefined) {
|
|
189
|
+
console.warn(
|
|
190
|
+
`[uniweb] ${packagePath}: \`uniweb.id\` is no longer read — a foundation's name is ` +
|
|
191
|
+
`\`name\` in main.js's default export (name: '${pkg.uniweb.id}').`
|
|
192
|
+
)
|
|
193
|
+
}
|
|
184
194
|
return {
|
|
185
|
-
name: pkg.
|
|
195
|
+
name: pkg.name,
|
|
186
196
|
version: pkg.version,
|
|
187
197
|
description: pkg.description,
|
|
188
198
|
// `uniweb.supports` joins them because it is the same KIND of fact: static,
|
|
@@ -197,6 +207,43 @@ export async function loadPackageJson(srcDir) {
|
|
|
197
207
|
}
|
|
198
208
|
}
|
|
199
209
|
|
|
210
|
+
/**
|
|
211
|
+
* A local foundation's name, read from its source by the one rule
|
|
212
|
+
* (`foundation-name.js`) — for `uniweb register` and for `push` / `publish` looking
|
|
213
|
+
* the foundation up, which need it before a build, or without one.
|
|
214
|
+
*
|
|
215
|
+
* ⛔ Refuses `package.json::uniweb.id` (retired 2026-09-21). This is the reader
|
|
216
|
+
* whose answer registers, so a leftover stops here rather than registering the
|
|
217
|
+
* foundation under a different name than the one it was given.
|
|
218
|
+
*
|
|
219
|
+
* @param {string} foundationDir - the foundation package's root
|
|
220
|
+
* @returns {Promise<{ name: string|null, source: 'main.js'|'package.json'|null, mainFile: string }>}
|
|
221
|
+
* `mainFile` is the foundation's `main.js`, whether or not it exists yet
|
|
222
|
+
*/
|
|
223
|
+
export async function readFoundationName(foundationDir) {
|
|
224
|
+
const pkgPath = join(foundationDir, 'package.json')
|
|
225
|
+
let pkg = {}
|
|
226
|
+
if (existsSync(pkgPath)) {
|
|
227
|
+
try {
|
|
228
|
+
pkg = JSON.parse(await readFile(pkgPath, 'utf-8')) || {}
|
|
229
|
+
} catch {
|
|
230
|
+
pkg = {}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
if (pkg.uniweb?.id !== undefined) {
|
|
234
|
+
throw new Error(
|
|
235
|
+
`${pkgPath}: \`uniweb.id\` is no longer read — a foundation's name is \`name\` in ` +
|
|
236
|
+
`main.js's default export. Move it there (name: '${pkg.uniweb.id}') and remove uniweb.id.`
|
|
237
|
+
)
|
|
238
|
+
}
|
|
239
|
+
const srcDir = join(foundationDir, resolveFoundationSrcDir(foundationDir))
|
|
240
|
+
const config = await loadFoundationConfig(srcDir)
|
|
241
|
+
return {
|
|
242
|
+
...foundationNameOf({ config, pkg }),
|
|
243
|
+
mainFile: join(srcDir, FOUNDATION_FILE_NAMES[0]),
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
200
247
|
/**
|
|
201
248
|
* Load the foundation's declarations from `src/main.js`.
|
|
202
249
|
*
|
|
@@ -310,7 +357,12 @@ function warnMisplacedCapabilities(module, filePath) {
|
|
|
310
357
|
* still resolve from the project's own `node_modules`.
|
|
311
358
|
*/
|
|
312
359
|
async function importFoundationConfig(filePath) {
|
|
313
|
-
|
|
360
|
+
// ⭐ IMPORTED BY CONTENT, as `loadMetaFile` imports `meta.js`: Node caches a module
|
|
361
|
+
// by URL, so a second read in one process returned the FIRST content. That matters
|
|
362
|
+
// since 2026-09-21, when `register` can write a `name` into this file and `push`
|
|
363
|
+
// reads the name again after the release, in the same process that read it before.
|
|
364
|
+
const content = createHash('sha1').update(await readFile(filePath)).digest('hex').slice(0, 16)
|
|
365
|
+
const href = `${pathToFileURL(filePath).href}?content=${content}`
|
|
314
366
|
try {
|
|
315
367
|
return await import(href)
|
|
316
368
|
} catch (error) {
|
|
@@ -333,7 +385,7 @@ async function importFoundationConfig(filePath) {
|
|
|
333
385
|
external: ['react', 'react/*', 'react-dom', 'react-dom/*'],
|
|
334
386
|
logLevel: 'silent',
|
|
335
387
|
})
|
|
336
|
-
return await import(pathToFileURL(outfile).href)
|
|
388
|
+
return await import(`${pathToFileURL(outfile).href}?content=${content}`)
|
|
337
389
|
} finally {
|
|
338
390
|
await rm(outfile, { force: true }).catch(() => {})
|
|
339
391
|
}
|
|
@@ -932,13 +984,18 @@ export async function buildSchema(srcDir, sectionPaths, derivedSupports = null)
|
|
|
932
984
|
const supports = composeSupports(identity.supports, derived)
|
|
933
985
|
reportSupports(srcDir, identity.supports, derived, supports.supports)
|
|
934
986
|
|
|
987
|
+
// The foundation's name, by the one rule every reader shares (`foundation-name.js`):
|
|
988
|
+
// `main.js`'s `name`, else `package.json`'s. ⚠️ Until 2026-09-21 the `main.js` value
|
|
989
|
+
// was described here as "editor-facing identity", as if a display name — but it is
|
|
990
|
+
// the name that registers, and the only name the framework sends.
|
|
991
|
+
const { name } = foundationNameOf({ config: foundationConfig, pkg: identity })
|
|
992
|
+
|
|
935
993
|
return {
|
|
936
994
|
_self: {
|
|
937
995
|
...configWithoutExtension,
|
|
938
996
|
...identity,
|
|
939
997
|
...supports,
|
|
940
|
-
|
|
941
|
-
...(foundationConfig.name && { name: foundationConfig.name }),
|
|
998
|
+
...(name && { name }),
|
|
942
999
|
...(foundationConfig.description && { description: foundationConfig.description }),
|
|
943
1000
|
...(foundationConfig.defaultLayout && { defaultLayout: foundationConfig.defaultLayout }),
|
|
944
1001
|
...(isExtension && { role: 'extension' }),
|
|
@@ -24,6 +24,7 @@ import { join, resolve, dirname } from 'node:path'
|
|
|
24
24
|
import { resolveDefaultLocale, DATA_DIR } from '@uniweb/core'
|
|
25
25
|
import { collectSiteContent } from './content-collector.js'
|
|
26
26
|
import { processQueries, writeQueryFiles } from './query-processor.js'
|
|
27
|
+
import { resolveRecordsDir } from './entity-pool.js'
|
|
27
28
|
import { processAssets, rewriteSiteContentPaths } from './asset-processor.js'
|
|
28
29
|
import { processAdvancedAssets } from './advanced-processors.js'
|
|
29
30
|
import {
|
|
@@ -61,7 +62,7 @@ import {
|
|
|
61
62
|
* re-derive, so we always ship enough for the ones that do.
|
|
62
63
|
* - `data/<collection>.json` (+ per-record files for `deferred:`
|
|
63
64
|
* collections) — same shape `processQueries` produces today.
|
|
64
|
-
* - `records/<path under
|
|
65
|
+
* - `records/<path under the records directory>` — the records' co-located assets, where
|
|
65
66
|
* the compiled records point.
|
|
66
67
|
* - `assets/<media>` — processed images / video posters / PDF
|
|
67
68
|
* thumbnails. Filtered by the deploy CLI to MEDIA only at upload time.
|
|
@@ -143,7 +144,7 @@ export async function buildSiteData({
|
|
|
143
144
|
const byQuery = await processQueries(
|
|
144
145
|
resolvedSiteRoot,
|
|
145
146
|
siteContent.config.queries,
|
|
146
|
-
siteContent.config?.paths
|
|
147
|
+
resolveRecordsDir(resolvedSiteRoot, siteContent.config?.paths).rel,
|
|
147
148
|
basePath,
|
|
148
149
|
{ locale: resolveDefaultLocale(siteContent.config) ?? null }
|
|
149
150
|
)
|
|
@@ -155,7 +156,7 @@ export async function buildSiteData({
|
|
|
155
156
|
await cp(publicDataDir, distDataDir, { recursive: true })
|
|
156
157
|
}
|
|
157
158
|
|
|
158
|
-
// ⭐ AND THE RECORDS' CO-LOCATED ASSETS — `public/records/<path under
|
|
159
|
+
// ⭐ AND THE RECORDS' CO-LOCATED ASSETS — `public/records/<path under records/>`,
|
|
159
160
|
// where the compiled records point (`/records/…`), mirrored the way vite mirrors
|
|
160
161
|
// all of `public/` on the bundle lane. ⛔ Until 2026-09-14 only `public/data` was
|
|
161
162
|
// copied, so a record's image URL named a file this lane's `dist/` did not have.
|
package/src/site/config.js
CHANGED
|
@@ -484,7 +484,7 @@ export async function defineSiteConfig(options = {}) {
|
|
|
484
484
|
const allowed = ['..']
|
|
485
485
|
const parentDir = resolve(siteRoot, '..')
|
|
486
486
|
const paths = siteConfig.paths || {}
|
|
487
|
-
for (const key of ['pages', 'layout', '
|
|
487
|
+
for (const key of ['pages', 'layout', 'records']) {
|
|
488
488
|
if (paths[key]) {
|
|
489
489
|
const resolved = resolve(siteRoot, paths[key])
|
|
490
490
|
if (!resolved.startsWith(parentDir)) {
|
|
@@ -822,7 +822,7 @@ function resolveDisplayTitle(declaredTitle, segment, sections) {
|
|
|
822
822
|
return extractH1(sections[0]?.content) || prettifySlug(segment)
|
|
823
823
|
}
|
|
824
824
|
|
|
825
|
-
// ⭐ The rule now lives in `utils/numeric-prefix.js` — `records.yml` needs the
|
|
825
|
+
// ⭐ The rule now lives in `utils/numeric-prefix.js` — `records/folder.yml` needs the
|
|
826
826
|
// identical one, and a records reader cannot import this module without closing
|
|
827
827
|
// a cycle. Imported at the top; still re-exported below for existing callers.
|
|
828
828
|
|
|
@@ -2727,9 +2727,10 @@ export async function collectSiteContent(sitePath, options = {}) {
|
|
|
2727
2727
|
// The `query:` shorthand ships as `config.fetch`, desugared below; carried raw
|
|
2728
2728
|
// it would sit beside `config.queries`, the declarations, and read as one.
|
|
2729
2729
|
const { publishLanguages: _publishLanguages, query: _query, ...runtimeSiteConfig } = siteConfig
|
|
2730
|
-
// ⛔ `$`-prefixed keys
|
|
2731
|
-
// `$backend`, `$services`, `$secrets` —
|
|
2732
|
-
//
|
|
2730
|
+
// ⛔ `$`-prefixed keys were the project's BACKEND-SCOPED state — `$uuid`, `$org`,
|
|
2731
|
+
// `$backend`, `$services`, `$secrets` — until they moved to `sync.json`
|
|
2732
|
+
// (2026-09-20), and a `site.yml` written before then can still carry them. This
|
|
2733
|
+
// payload is a PUBLISHED artifact that a visitor can fetch. They have no runtime reader (nothing in core, runtime
|
|
2733
2734
|
// or kit reads a `config.$*` key), so this removes noise for four of them and a
|
|
2734
2735
|
// real disclosure for the fifth: `$secrets` carries no values, but its entries
|
|
2735
2736
|
// name every secret the site has, and an inventory of credential names is not
|