@uniweb/build 1.1.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniweb/build",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "Build tooling for the Uniweb Component Web Platform",
5
5
  "type": "module",
6
6
  "exports": {
@@ -56,15 +56,15 @@
56
56
  "js-yaml": "^4.1.0",
57
57
  "sharp": "^0.35.3",
58
58
  "yaml": "^2.5.0",
59
- "@uniweb/theming": "~1.1.0",
60
- "@uniweb/semantic-parser": "~1.5.1",
61
- "@uniweb/schemas": "~1.0.1",
62
- "@uniweb/projections": "~1.1.0",
63
- "@uniweb/content-reader": "~1.2.8",
64
- "@uniweb/content-writer": "~1.0.1"
59
+ "@uniweb/semantic-parser": "~1.6.0",
60
+ "@uniweb/theming": "~1.2.0",
61
+ "@uniweb/schemas": "~1.1.0",
62
+ "@uniweb/projections": "~1.1.1",
63
+ "@uniweb/content-reader": "~1.3.0",
64
+ "@uniweb/content-writer": "~1.1.0"
65
65
  },
66
66
  "optionalDependencies": {
67
- "@uniweb/runtime": "~1.1.0"
67
+ "@uniweb/runtime": "~1.1.1"
68
68
  },
69
69
  "peerDependencies": {
70
70
  "vite": "^5.0.0 || ^6.0.0 || ^7.0.0",
@@ -73,8 +73,8 @@
73
73
  "@tailwindcss/vite": "^4.0.0",
74
74
  "@vitejs/plugin-react": "^4.0.0 || ^5.0.0",
75
75
  "vite-plugin-svgr": "^4.0.0",
76
- "@uniweb/core": "~1.1.0",
77
- "@uniweb/runtime": "~1.1.0"
76
+ "@uniweb/core": "~1.2.0",
77
+ "@uniweb/runtime": "~1.1.1"
78
78
  },
79
79
  "peerDependenciesMeta": {
80
80
  "vite": {
@@ -26,6 +26,7 @@ import { poolDirsForSchema, schemaForPoolDirs, RECORDS_DIR } from '../site/entit
26
26
  import { parseFrontmatter } from '../utils/frontmatter.js'
27
27
  import { computeSourceHash } from './freeform-manifest.js'
28
28
  import { layoutAreaRoute } from '../site/layout-folder.js'
29
+ import { warnOutsideVocabulary } from '../site/vocabulary-warnings.js'
29
30
 
30
31
  // Try to import content-reader for markdown → ProseMirror conversion
31
32
  let markdownToProseMirror
@@ -46,6 +47,11 @@ try {
46
47
  }
47
48
 
48
49
 
50
+ /** Report what a translation's markdown could not honour, naming the file. */
51
+ function warnFor(filePath) {
52
+ return (message) => console.warn(`[i18n] ${relative(process.cwd(), filePath)}: ${message}`)
53
+ }
54
+
49
55
  /**
50
56
  * Normalize route for filesystem path
51
57
  * Removes leading slash, replaces remaining slashes with path separators
@@ -83,8 +89,10 @@ export async function loadFreeformTranslation(section, page, locale, localesDir)
83
89
  const content = await readFile(filePath, 'utf-8')
84
90
  const { frontmatter, body } = parseFrontmatter(content, filePath)
85
91
 
86
- // Convert markdown body to ProseMirror
87
- const proseMirrorContent = markdownToProseMirror(body)
92
+ // Convert markdown body to ProseMirror — HTML in it shows as written,
93
+ // except `<br>`, and the translator is told which file.
94
+ const proseMirrorContent = markdownToProseMirror(body, { onWarning: warnFor(filePath) })
95
+ warnOutsideVocabulary(proseMirrorContent, warnFor(filePath))
88
96
 
89
97
  return {
90
98
  content: proseMirrorContent,
@@ -138,7 +146,8 @@ export async function loadFreeformRecord(item, schema, locale, localesDir) {
138
146
  const { frontmatter, body } = parseFrontmatter(content, filePath)
139
147
 
140
148
  // Convert markdown body to ProseMirror (if body exists)
141
- const proseMirrorContent = body.trim() ? markdownToProseMirror(body) : null
149
+ const proseMirrorContent = body.trim() ? markdownToProseMirror(body, { onWarning: warnFor(filePath) }) : null
150
+ if (proseMirrorContent) warnOutsideVocabulary(proseMirrorContent, warnFor(filePath))
142
151
 
143
152
  return {
144
153
  frontmatter: Object.keys(frontmatter).length > 0 ? frontmatter : null,
package/src/index.js CHANGED
@@ -73,6 +73,13 @@ export {
73
73
  isExtensionUrl,
74
74
  isSiteRelativeExtensionUrl
75
75
  } from './uwx/site.js'
76
+ // Where a site finds a workspace extension — one rule, which the build, prerender
77
+ // and the dev server follow, for the CLI to ask rather than restate.
78
+ export {
79
+ extensionCandidates,
80
+ extensionNameOf,
81
+ findSiteExtension
82
+ } from './site/workspace-extension.js'
76
83
 
77
84
  // Foundation source root resolution (reads package.json::main)
78
85
  export { resolveFoundationSrcDir, resolveFoundationSrcPath } from './utils/foundation-source-root.js'
package/src/prerender.js CHANGED
@@ -54,13 +54,20 @@ import { isSiteRelative, resolveExtensionDist } from './site/emit-extensions.js'
54
54
  * sections belonged until the page ran in the browser.
55
55
  *
56
56
  * Resolution order:
57
- * 1. The extension's own build — `<project>/<name>/dist/` or
58
- * `<project>/extensions/<name>/dist/` — found by the emitter's lookup
59
- * (`resolveExtensionDist`), so the two lanes find the same build.
57
+ * 1. The extension's own build — the `dist/` of the workspace extension with the
58
+ * URL's name, `<name>/` or `extensions/<name>/` in the folder the site is in or
59
+ * a folder above it, up to the workspace root (site/workspace-extension.js) —
60
+ * found by the emitter's lookup (`resolveExtensionDist`), so the two lanes find
61
+ * the same build.
60
62
  * 2. A file in the site's dist, for an extension no workspace package builds (a
61
63
  * pre-built module placed in `public/`). Its bare imports resolve only where
62
64
  * the site's own dependencies carry them.
63
65
  * 3. Original URL (absolute or remote — let import() handle it)
66
+ *
67
+ * @param {string} url - as the payload carries it
68
+ * @param {string} distDir - the site's `dist/`
69
+ * @param {string} projectRoot - the folder the site is in, where the search starts
70
+ * @param {string} [base] - the site's deployment base
64
71
  */
65
72
  export function resolveExtensionPath(url, distDir, projectRoot, base) {
66
73
  // The payload now carries FINAL, base-resolved URLs (see
@@ -421,7 +421,11 @@ export async function defineSiteConfig(options = {}) {
421
421
  // nothing else resolves them: the foundation dev plugin rewrites a LOCAL
422
422
  // foundation's imports, and a served one is not ours to rewrite. So the dev server
423
423
  // answers the import map a build writes, bridging to the site's own copies.
424
- serveInDev: foundationInfo.type === 'url'
424
+ // ⭐ An extension is always loaded from a URL — a workspace one, served by the
425
+ // site plugin from its build, as much as a remote one — so a site with any
426
+ // extension gets the same bridge. ⛔ Until 2026-10-13 only a URL foundation did,
427
+ // and in dev an extension's `import 'react'` had nothing to resolve against.
428
+ serveInDev: foundationInfo.type === 'url' || hasExtensions
425
429
  }))
426
430
  }
427
431
 
@@ -42,6 +42,7 @@ import { authoredRedirectTarget } from '@uniweb/core/resolve-route'
42
42
  import { parseFrontmatter } from '../utils/frontmatter.js'
43
43
  import { parseGrid } from '@uniweb/schemas/grid'
44
44
  import { runtimeServicesConfig, refuseRetiredServiceKeys, refuseUnreadableServices } from '../uwx/services-request.js'
45
+ import { warnOutsideVocabulary } from './vocabulary-warnings.js'
45
46
 
46
47
  // Try to import content-reader, fall back to simplified parser
47
48
  let markdownToProseMirror
@@ -1068,7 +1069,12 @@ async function processMarkdownFile(filePath, id, siteRoot, defaultStableId = nul
1068
1069
  // come through here (a free-form translation, a record body) kept raw references
1069
1070
  // nothing resolved, and the same section's content differed between a build and a
1070
1071
  // document stored as written.
1071
- const proseMirrorContent = markdownToProseMirror(markdown)
1072
+ //
1073
+ // HTML in the markdown is not content: it shows as written, except `<br>`
1074
+ // (content-reader, since 2026-10-14). Say so with the file in hand.
1075
+ const warnHere = (message) => console.warn(`[content-collector] ${relative(siteRoot, filePath)}: ${message}`)
1076
+ const proseMirrorContent = markdownToProseMirror(markdown, { onWarning: warnHere })
1077
+ warnOutsideVocabulary(proseMirrorContent, warnHere)
1072
1078
 
1073
1079
  // `query: team` → `fetch: { query: team }`; a list, one config per name, each under
1074
1080
  // its own `as` — the one helper every level uses. Unrelated to a section type's
@@ -44,10 +44,17 @@
44
44
  * the extension from its own build (`resolveExtensionPath`), because the copy
45
45
  * sits under the site package, where the extension's bare imports
46
46
  * (`@uniweb/core`, `react`) do not resolve.
47
+ *
48
+ * ── Which folder is the extension ──
49
+ *
50
+ * Not decided here: `./workspace-extension.js` holds the rule, and the dev
51
+ * server, prerender and the CLI ask it too.
47
52
  */
48
53
 
49
54
  import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
50
55
  import { join, relative, resolve } from 'node:path'
56
+ import { extensionNameOf, findExtensionPackage } from './workspace-extension.js'
57
+ import { stripBasePath } from './extension-urls.js'
51
58
 
52
59
  /** True for the `/effects/entry.js` form — the only one this site can serve. */
53
60
  export function isSiteRelative(decl) {
@@ -64,8 +71,8 @@ function walk(dir, base = dir) {
64
71
  })
65
72
  }
66
73
 
67
- /** Is this file part of what a browser fetches? */
68
- function isBrowserAsset(rel) {
74
+ /** Is this file part of what a browser fetches? The dev server serves the same set. */
75
+ export function isBrowserAsset(rel) {
69
76
  if (rel.endsWith('.map')) return false
70
77
  if (rel === 'runtime-pin.json') return false
71
78
  if (rel.startsWith('meta/') || rel.startsWith(`meta\\`)) return false
@@ -74,9 +81,9 @@ function isBrowserAsset(rel) {
74
81
  }
75
82
 
76
83
  /**
77
- * Locate the built `dist/` behind a site-relative extension URL:
78
- * `/effects/entry.js` → `<project>/effects/dist/` or
79
- * `<project>/extensions/effects/dist/`, with `rest` the path inside it.
84
+ * Locate the workspace extension behind a site-relative extension URL —
85
+ * `/effects/entry.js` → the folder `./workspace-extension.js` finds for
86
+ * `effects` — with `distDir` its build and `rest` the path inside it.
80
87
  *
81
88
  * One lookup for both lanes that need the extension's build: this emitter, which
82
89
  * copies it into the site's output, and prerender, which imports it from where it
@@ -84,50 +91,57 @@ function isBrowserAsset(rel) {
84
91
  * cannot, by construction rather than by two lists kept in step by hand.
85
92
  *
86
93
  * @param {string} url - a site-relative URL, with any deployment base removed
87
- * @param {string} projectRoot - the directory holding the site and its extensions
88
- * @returns {{ distDir: string, urlBase: string, rest: string }|null}
94
+ * @param {string} projectRoot - the folder the site is in; the search starts
95
+ * there and climbs to the workspace root
96
+ * @returns {{ distDir: string, urlBase: string, rest: string, packageDir: string, built: boolean }|null}
97
+ * null when no workspace extension has that name; `built: false` when one does
98
+ * and has no `dist/` yet
89
99
  */
90
100
  export function resolveExtensionDist(url, projectRoot) {
91
- const parts = url.replace(/^\//, '').split('/')
92
- if (parts.length < 2) return null
93
- const pkgName = parts[0]
94
-
95
- for (const candidate of [
96
- join(projectRoot, pkgName, 'dist'),
97
- join(projectRoot, 'extensions', pkgName, 'dist')
98
- ]) {
99
- if (existsSync(candidate)) {
100
- return { distDir: candidate, urlBase: pkgName, rest: parts.slice(1).join('/') }
101
- }
102
- }
103
- return null
101
+ const named = extensionNameOf(url)
102
+ if (!named) return null
103
+ const packageDir = findExtensionPackage(named.name, projectRoot)
104
+ if (!packageDir) return null
105
+ const distDir = join(packageDir, 'dist')
106
+ return { distDir, urlBase: named.name, rest: named.rest, packageDir, built: existsSync(distDir) }
104
107
  }
105
108
 
106
109
  /**
107
110
  * Files to emit for a site's declared extensions.
108
111
  *
109
- * Returns `{ fileName, source }` pairs for Rollup's `emitFile`, plus the
110
- * declarations that could not be resolved — the caller warns about those rather
111
- * than failing, because an absolute-URL extension is legitimately not ours to
112
- * emit and a missing workspace build is a warning the developer can act on.
112
+ * Returns `{ fileName, source }` pairs for Rollup's `emitFile`, plus what could
113
+ * not be emitted, for the caller to warn about rather than fail on: an
114
+ * absolute-URL extension is legitimately not ours to emit, and a missing or
115
+ * unbuilt workspace extension is something the developer can act on.
113
116
  *
114
- * @param {Array} extensions - `site.yml::extensions`, as declared.
117
+ * @param {Array} extensions - the payload's `config.extensions`.
115
118
  * @param {string} siteDir - the site package directory.
119
+ * @param {{ base?: string }} [options] - `base`: the deployment base the payload's
120
+ * URLs carry. `dist/` has no base segment, so it is removed before the URL names
121
+ * a folder — with `/docs/` left on, `/docs/effects/entry.js` named an extension
122
+ * `docs`, and a site deployed under a subpath emitted none.
123
+ * @returns {{ emit: Array<{ fileName: string, source: Buffer }>, unresolved: string[],
124
+ * unbuilt: Array<{ url: string, dir: string }> }}
116
125
  */
117
- export function collectExtensionAssets(extensions, siteDir) {
126
+ export function collectExtensionAssets(extensions, siteDir, { base } = {}) {
118
127
  const emit = []
119
128
  const unresolved = []
120
- if (!Array.isArray(extensions)) return { emit, unresolved }
129
+ const unbuilt = []
130
+ if (!Array.isArray(extensions)) return { emit, unresolved, unbuilt }
121
131
  const projectRoot = resolve(siteDir, '..')
122
132
 
123
133
  for (const decl of extensions) {
124
134
  if (!isSiteRelative(decl)) continue // absolute URL or a ref — someone else serves it
125
135
  const url = typeof decl === 'string' ? decl : decl.url
126
- const found = resolveExtensionDist(url, projectRoot)
136
+ const found = resolveExtensionDist(stripBasePath(url, base), projectRoot)
127
137
  if (!found) {
128
138
  unresolved.push(url)
129
139
  continue
130
140
  }
141
+ if (!found.built) {
142
+ unbuilt.push({ url, dir: found.packageDir })
143
+ continue
144
+ }
131
145
  for (const rel of walk(found.distDir)) {
132
146
  if (!isBrowserAsset(rel)) continue
133
147
  emit.push({
@@ -136,5 +150,5 @@ export function collectExtensionAssets(extensions, siteDir) {
136
150
  })
137
151
  }
138
152
  }
139
- return { emit, unresolved }
153
+ return { emit, unresolved, unbuilt }
140
154
  }
@@ -13,8 +13,8 @@
13
13
  *
14
14
  * Three implementations of one question, none of which agreed, and the cause was
15
15
  * a pure function sitting behind a bundler import. So it moved here, with no
16
- * dependency beyond `node:fs` and `node:path`, the same reason `@uniweb/core`'s
17
- * `route-match` and `data-paths` are leaves.
16
+ * dependency beyond `node:fs`, `node:path` and the leaf package classifier, the
17
+ * same reason `@uniweb/core`'s `route-match` and `data-paths` are leaves.
18
18
  *
19
19
  * ## What it answers, and what it refuses
20
20
  *
@@ -30,6 +30,21 @@
30
30
 
31
31
  import { existsSync, readFileSync } from 'node:fs'
32
32
  import { resolve, join } from 'node:path'
33
+ import { classifyPackage } from '../utils/classify-package.js'
34
+
35
+ /**
36
+ * A folder found by NAME rather than named outright — a sibling of the site, or
37
+ * `foundations/<name>` — is the foundation only if it is one. A `file:` dep names
38
+ * its folder outright and is taken as written.
39
+ *
40
+ * ⛔ Until 2026-10-13 any folder of the right name passed. In a workspace holding
41
+ * `foundations/blog` and `sites/blog`, the site `sites/blog` resolved its
42
+ * foundation to ITSELF, and `sites/main` resolved it to the other site — while
43
+ * each site's `file:` dep named `foundations/blog` correctly, one check too late.
44
+ */
45
+ function isFoundationDir(dir) {
46
+ return existsSync(dir) && classifyPackage(dir) === 'foundation'
47
+ }
33
48
 
34
49
  /**
35
50
  * A catalog ref — `@org/name@version` — in its parts, or null for anything else.
@@ -174,7 +189,7 @@ export function detectFoundationType(foundation, siteRoot, { served = null } = {
174
189
 
175
190
  // Check if it's a local workspace sibling (directory name matches package name)
176
191
  const localPath = resolve(siteRoot, '..', name)
177
- if (existsSync(localPath)) {
192
+ if (isFoundationDir(localPath)) {
178
193
  return {
179
194
  type: 'local',
180
195
  name,
@@ -201,7 +216,7 @@ export function detectFoundationType(foundation, siteRoot, { served = null } = {
201
216
 
202
217
  // Check in foundations/ directory (for multi-site projects)
203
218
  const foundationsPath = resolve(siteRoot, '..', '..', 'foundations', name)
204
- if (existsSync(foundationsPath)) {
219
+ if (isFoundationDir(foundationsPath)) {
205
220
  return {
206
221
  type: 'local',
207
222
  name,
@@ -226,14 +241,18 @@ export function detectFoundationType(foundation, siteRoot, { served = null } = {
226
241
  // a registry ref, or a URL — none of the supported shapes. Fail with
227
242
  // guidance rather than fall through to a node_modules lookup that will
228
243
  // produce a confusing error later in the build.
244
+ const notFoundations = [localPath, foundationsPath].filter((dir) => existsSync(dir))
229
245
  throw new Error(
230
246
  `site.yml foundation: '${name}' did not resolve.\n` +
231
247
  `Foundations must be one of:\n` +
232
- ` - a workspace-local sibling (a directory next to the site, named '${name}')\n` +
248
+ ` - a foundation next to the site, in a folder named '${name}'\n` +
233
249
  ` - a 'file:' dep in the site's package.json\n` +
234
- ` - a directory in '../../foundations/${name}'\n` +
250
+ ` - a foundation in '../../foundations/${name}'\n` +
235
251
  ` - a versioned registry ref: '@org/${name}@<version>'\n` +
236
252
  ` - a full URL: 'https://...'\n` +
253
+ (notFoundations.length
254
+ ? `${notFoundations.join(' and ')} ${notFoundations.length > 1 ? 'exist' : 'exists'}, but not as a foundation package.\n`
255
+ : '') +
237
256
  `Foundations are runtime federated modules, not npm packages — there is no fall-through to node_modules.`
238
257
  )
239
258
  }
@@ -30,7 +30,7 @@
30
30
  * })
31
31
  */
32
32
 
33
- import { resolve, join } from 'node:path'
33
+ import { resolve, join, relative, sep } from 'node:path'
34
34
  import { watch, existsSync } from 'node:fs'
35
35
  import { readFile, readdir } from 'node:fs/promises'
36
36
  import { resolveDefaultLocale, resolveFetchConfigs, DATA_DIR } from '@uniweb/core'
@@ -54,13 +54,38 @@ import { RECORDS_DIR, resolveRecordsDir } from './entity-pool.js'
54
54
  import { executeFetch, mergeDataIntoContent, toFetchList } from './data-fetcher.js'
55
55
  import { shouldSplitContent } from './split-content.js'
56
56
  import { FONT_LINKS_MARKER } from './head-markers.js'
57
- import { collectExtensionAssets } from './emit-extensions.js'
57
+ import { collectExtensionAssets, isBrowserAsset } from './emit-extensions.js'
58
+ import { extensionNameOf, findSiteExtension } from './workspace-extension.js'
58
59
 
59
60
  // BCP 47 locale code pattern: en, zh-CN, zh-Hant, pt-BR, fr-CA, sr-Latn, etc.
60
61
  const LOCALE_RE = '[a-z]{2,3}(?:-[A-Za-z]{2,4})?'
61
62
 
62
63
  const FAVICON_AUTODETECT = ['favicon.svg', 'favicon.ico', 'favicon.png']
63
64
 
65
+ const EXTENSION_CONTENT_TYPES = {
66
+ js: 'text/javascript',
67
+ mjs: 'text/javascript',
68
+ css: 'text/css',
69
+ json: 'application/json',
70
+ svg: 'image/svg+xml',
71
+ png: 'image/png',
72
+ jpg: 'image/jpeg',
73
+ jpeg: 'image/jpeg',
74
+ gif: 'image/gif',
75
+ webp: 'image/webp',
76
+ avif: 'image/avif',
77
+ woff: 'font/woff',
78
+ woff2: 'font/woff2',
79
+ ttf: 'font/ttf',
80
+ otf: 'font/otf',
81
+ wasm: 'application/wasm'
82
+ }
83
+
84
+ /** The Content-Type the dev server answers an extension's built file with. */
85
+ function extensionContentType(file) {
86
+ return EXTENSION_CONTENT_TYPES[file.split('.').pop()?.toLowerCase()] || 'application/octet-stream'
87
+ }
88
+
64
89
  function faviconTypeFor(href) {
65
90
  const ext = href.split('?')[0].split('.').pop()?.toLowerCase()
66
91
  if (ext === 'svg') return 'image/svg+xml'
@@ -1022,6 +1047,71 @@ export function siteContentPlugin(options = {}) {
1022
1047
  console.error(`[dev-api] ${err.message}`)
1023
1048
  }
1024
1049
 
1050
+ // A site-relative workspace extension (`/effects/entry.js`) is served from
1051
+ // the site's own origin: the build copies its built files there
1052
+ // (`collectExtensionAssets`, in generateBundle). Dev serves the same set,
1053
+ // from the same folder, found by the same rule (`./workspace-extension.js`),
1054
+ // so `uniweb dev` and `uniweb build` cannot disagree about whether a site
1055
+ // has its extension. Its bare imports (`react`, `@uniweb/core`) resolve
1056
+ // through the import map a dev page with extensions carries (`./config.js`,
1057
+ // `serveInDev`). ⚠️ Synchronous, like the mount above, so it runs before
1058
+ // Vite's SPA fallback.
1059
+ //
1060
+ // ⛔ Until 2026-10-13 nothing served it in dev: the request reached the SPA
1061
+ // fallback, which answered `index.html` with a 200, `import()` failed on
1062
+ // it, and the runtime dropped the extension. Measured on the `extensions`
1063
+ // template.
1064
+ const warnedExtensions = new Set()
1065
+ devServer.middlewares.use(async (req, res, next) => {
1066
+ const declared = siteContent?.config?.extensions
1067
+ const named = Array.isArray(declared) && declared.length ? extensionNameOf(req.url, basePath) : null
1068
+ const entry =
1069
+ named &&
1070
+ declared
1071
+ .map((d) => (typeof d === 'string' ? d : d?.url))
1072
+ .find((url) => extensionNameOf(url, basePath)?.name === named.name)
1073
+ if (!entry || !named.rest) return next()
1074
+
1075
+ const packageDir = findSiteExtension(named.name, resolvedSitePath)
1076
+ if (packageDir) {
1077
+ const distDir = join(packageDir, 'dist')
1078
+ let rest = named.rest
1079
+ try {
1080
+ rest = decodeURIComponent(rest)
1081
+ } catch {}
1082
+ const file = resolve(distDir, rest)
1083
+ if (file.startsWith(distDir + sep) && isBrowserAsset(rest) && existsSync(file)) {
1084
+ try {
1085
+ const body = await readFile(file)
1086
+ res.setHeader('Content-Type', extensionContentType(file))
1087
+ res.setHeader('Cache-Control', 'no-cache')
1088
+ res.end(body)
1089
+ return
1090
+ } catch (err) {
1091
+ return next(err)
1092
+ }
1093
+ }
1094
+ }
1095
+
1096
+ // Only the declared entry is answered when its file is missing: the runtime
1097
+ // drops an extension it cannot load either way, and a 404 says what the
1098
+ // SPA's HTML would hide. Any other path under that name is left to Vite.
1099
+ if (extensionNameOf(entry, basePath)?.rest !== named.rest) return next()
1100
+ if (!warnedExtensions.has(entry)) {
1101
+ warnedExtensions.add(entry)
1102
+ console.warn(
1103
+ packageDir
1104
+ ? `[site-content] Extension '${entry}' is not built (${relative(resolvedSitePath, packageDir)}). ` +
1105
+ `Build it — \`uniweb build\` from the workspace root — and reload.`
1106
+ : `[site-content] Extension '${entry}' is site-relative, and no workspace extension has its name. ` +
1107
+ `A site finds one in <name>/ or extensions/<name>/, in the folder the site is in or a folder ` +
1108
+ `above it, up to the workspace root.`
1109
+ )
1110
+ }
1111
+ res.statusCode = 404
1112
+ res.end()
1113
+ })
1114
+
1025
1115
  // Watch for content changes in dev mode
1026
1116
  if (shouldWatch) {
1027
1117
  // Debounce rebuilds. One that `queries.yml` or `site.yml` asked for also
@@ -1681,17 +1771,26 @@ export function siteContentPlugin(options = {}) {
1681
1771
  // Without this the build succeeds, prerender renders the extension's
1682
1772
  // sections from the workspace, and the browser then 404s and replaces
1683
1773
  // them with `Component not found` on hydration.
1684
- const { emit, unresolved } = collectExtensionAssets(
1774
+ const { emit, unresolved, unbuilt } = collectExtensionAssets(
1685
1775
  finalContent.config?.extensions,
1686
- resolve(sitePath)
1776
+ resolve(sitePath),
1777
+ { base: finalContent.config?.base }
1687
1778
  )
1688
1779
  for (const asset of emit) {
1689
1780
  this.emitFile({ type: 'asset', fileName: asset.fileName, source: asset.source })
1690
1781
  }
1691
1782
  for (const url of unresolved) {
1692
1783
  this.warn(
1693
- `Extension '${url}' is site-relative but no built extension was found for it. ` +
1694
- `The site will 404 on it at runtime — build the extension, or reference it by URL.`
1784
+ `Extension '${url}' is site-relative, and no workspace extension has its name. ` +
1785
+ `A site finds one in <name>/ or extensions/<name>/, in the folder the site is in or a folder ` +
1786
+ `above it, up to the workspace root. The site will 404 on it at runtime — move the extension ` +
1787
+ `there, or reference it by URL.`
1788
+ )
1789
+ }
1790
+ for (const { url, dir } of unbuilt) {
1791
+ this.warn(
1792
+ `Extension '${url}' is not built (${relative(resolve(sitePath), dir) || dir}), so the site will ` +
1793
+ `404 on it at runtime. Run \`uniweb build\` from the workspace root, which builds extensions first.`
1695
1794
  )
1696
1795
  }
1697
1796
  },
@@ -72,6 +72,7 @@ import { toDeliveredRecord, toStoredRecord, storedPath, briefFieldMap, contentBo
72
72
  import { collectNestedRefs } from '@uniweb/schemas/format'
73
73
  import { labelIn } from './entry-label.js'
74
74
  import { fileRecordValue, makeFilePreview } from './file-records.js'
75
+ import { warnOutsideVocabulary } from './vocabulary-warnings.js'
75
76
 
76
77
  // Try to import content-reader for markdown parsing
77
78
  let markdownToProseMirror
@@ -563,8 +564,11 @@ async function processContentItem(dir, filename, config, siteRoot, basePath, rec
563
564
  // record was published.
564
565
  await processDataItemAssets(frontmatter, filepath, siteRoot, recordsRoot, basePath)
565
566
 
566
- // Parse markdown body to ProseMirror
567
- const content = markdownToProseMirror(body)
567
+ // Parse markdown body to ProseMirror — HTML in it shows as written, except
568
+ // `<br>`, and the author is told which file.
569
+ const warnHere = (message) => console.warn(`[records] ${relative(siteRoot, filepath)}: ${message}`)
570
+ const content = markdownToProseMirror(body, { onWarning: warnHere })
571
+ warnOutsideVocabulary(content, warnHere)
568
572
 
569
573
  // Process assets (resolve paths, copy co-located files)
570
574
  // This modifies content in place, updating paths to site-root-relative
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Say, with the file in hand, what in a document a page leaves out.
3
+ *
4
+ * A document holds the rich-text vocabulary (`@uniweb/content-reader/vocabulary`):
5
+ * its types and attributes, and the values that reach a page's markup. What is
6
+ * outside it — a link to `javascript:`, an inert `{.name}`, MathML outside the
7
+ * allowlist — the renderer leaves out of the page, and a backend that cleans the
8
+ * content it stores removes, so the next pull would write the file without it.
9
+ * The author hears it here first, once per thing, naming the file.
10
+ */
11
+ import { outsideVocabulary } from '@uniweb/content-reader/vocabulary'
12
+
13
+ const SAYS = {
14
+ url: (f) => `a link to \`${f.found}\` is never followed`,
15
+ target: (f) => `\`target=${f.found}\` is not a link target`,
16
+ 'style-name': (f) => `\`{${f.found}}\` is not a style name`,
17
+ color: (f) => `\`${f.found}\` is not a colour`,
18
+ mathml: (f) => `a formula's MathML holds what a page cannot show (${f.found})`,
19
+ 'ref-id': (f) => `\`${f.found}\` is not an inset reference`,
20
+ }
21
+
22
+ /**
23
+ * Warn, for each thing in `doc` outside the vocabulary, with the file it came from.
24
+ *
25
+ * @param {Object} doc - a ProseMirror document
26
+ * @param {(message: string) => void} warn - prints one message, prefixed with the file
27
+ */
28
+ export function warnOutsideVocabulary(doc, warn) {
29
+ for (const finding of outsideVocabulary(doc)) {
30
+ const says = SAYS[finding.rule]?.(finding) ?? `\`${finding.found}\` is not part of a document (${finding.rule})`
31
+ warn(`${says} — a page leaves it out, and a backend that cleans what it stores removes it`)
32
+ }
33
+ }
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Where a site finds a WORKSPACE extension — the one rule, asked by every lane.
3
+ *
4
+ * A site declares a workspace extension by the URL it serves it at
5
+ * (`extensions: ['/effects/entry.js']`) or by name (`extensions: [effects]`).
6
+ * Either way the extension's name is `effects`, and this module answers which
7
+ * folder of the workspace holds it. Each lane that needs that answer asks here:
8
+ * the site build, which copies the extension's build into the site's output
9
+ * (`emit-extensions.js`); prerender, which imports it (`../prerender.js`); the
10
+ * dev server, which serves it (`plugin.js`); and the CLI — `add extension`
11
+ * deciding whether a site can load what it just made, `doctor`, and `push` /
12
+ * `publish` bringing a named extension along.
13
+ *
14
+ * ── The rule ──
15
+ *
16
+ * The extension named `<name>` is the nearest `<name>/` or `extensions/<name>/`
17
+ * that is a foundation package — looking first in the folder the site is in,
18
+ * then in each folder above it, up to the workspace root:
19
+ *
20
+ * project/ ← the workspace root (pnpm-workspace.yaml)
21
+ * ├── extensions/effects/ ← what sites/main loads as /effects/entry.js
22
+ * ├── sites/main/
23
+ * └── docs/
24
+ * ├── effects/ ← what docs/site loads: it is nearer
25
+ * └── site/
26
+ *
27
+ * ── Why each part ──
28
+ *
29
+ * - **Nearest first**, so a co-located project can keep its own extension under
30
+ * a name a shared one also has — the way a nearer `node_modules` wins.
31
+ * - **Up to the workspace root and no further** — the folder holding
32
+ * `pnpm-workspace.yaml`, or a `package.json` that lists `workspaces`, the same
33
+ * marker the CLI finds a workspace by. A folder outside the project is never
34
+ * part of it, and a site in no workspace looks only in the folder it is in.
35
+ * - **A foundation package, not any folder of that name** (`classifyPackage`):
36
+ * an extension is a foundation. A built SITE named `effects` has a `dist/` as
37
+ * well, and the more folders the search visits, the likelier a same-named one
38
+ * that is not the extension.
39
+ *
40
+ * ⛔ Until 2026-10-13 the build looked only in the folder the site is in, and
41
+ * `add extension` judged an extension reachable from the workspace root — so a
42
+ * site at `sites/main` was wired to `extensions/effects`, which its build then
43
+ * could not find. The rule was written three times — this lookup, `add`'s check
44
+ * and `doctor`'s parsing of the URL — and they disagreed. It is written once now.
45
+ *
46
+ * Node built-ins only, beside the leaf classifier, so any lane can import it
47
+ * without pulling in Vite.
48
+ */
49
+
50
+ import { existsSync, readFileSync } from 'node:fs'
51
+ import { dirname, join, resolve } from 'node:path'
52
+ import { classifyPackage } from '../utils/classify-package.js'
53
+ import { stripBasePath } from './extension-urls.js'
54
+
55
+ /** A folder holding a workspace manifest — what the CLI finds a workspace by. */
56
+ function isWorkspaceRoot(dir) {
57
+ if (existsSync(join(dir, 'pnpm-workspace.yaml'))) return true
58
+ const pkgPath = join(dir, 'package.json')
59
+ if (!existsSync(pkgPath)) return false
60
+ try {
61
+ return Array.isArray(JSON.parse(readFileSync(pkgPath, 'utf8')).workspaces)
62
+ } catch {
63
+ return false
64
+ }
65
+ }
66
+
67
+ /** One folder name — never a path, so a declaration cannot walk the search out of a folder. */
68
+ function isExtensionName(name) {
69
+ return typeof name === 'string' && name !== '' && name !== '.' && name !== '..' && !/[\\/]/.test(name)
70
+ }
71
+
72
+ /**
73
+ * The folders a site searches for a workspace extension, nearest first.
74
+ *
75
+ * @param {string} fromDir - the folder the site is in
76
+ * @returns {string[]} `fromDir`, then each folder above it up to and including
77
+ * the workspace root — or `fromDir` alone when no workspace holds it
78
+ */
79
+ export function extensionSearchDirs(fromDir) {
80
+ const start = resolve(fromDir)
81
+ const dirs = []
82
+ for (let dir = start; ; dir = dirname(dir)) {
83
+ dirs.push(dir)
84
+ if (isWorkspaceRoot(dir)) return dirs
85
+ if (dirname(dir) === dir) return [start]
86
+ }
87
+ }
88
+
89
+ /**
90
+ * Every folder that could hold the extension named `name`, in the order they are
91
+ * tried — for a message that says where the build looked.
92
+ *
93
+ * @param {string} name - the extension's name: its folder, and its URL's first segment
94
+ * @param {string} fromDir - the folder the site is in
95
+ * @returns {string[]}
96
+ */
97
+ export function extensionCandidates(name, fromDir) {
98
+ if (!isExtensionName(name)) return []
99
+ return extensionSearchDirs(fromDir).flatMap((dir) => [join(dir, name), join(dir, 'extensions', name)])
100
+ }
101
+
102
+ /**
103
+ * The folder of the workspace extension named `name`, or null.
104
+ *
105
+ * @param {string} name
106
+ * @param {string} fromDir - the folder the site is in
107
+ * @returns {string|null} an absolute package folder; built or not — the caller
108
+ * that needs its `dist/` checks for it, so "not built" is never reported as
109
+ * "not found"
110
+ */
111
+ export function findExtensionPackage(name, fromDir) {
112
+ for (const candidate of extensionCandidates(name, fromDir)) {
113
+ if (existsSync(candidate) && classifyPackage(candidate) === 'foundation') return candidate
114
+ }
115
+ return null
116
+ }
117
+
118
+ /**
119
+ * {@link findExtensionPackage} for a site named by its own folder.
120
+ *
121
+ * @param {string} name
122
+ * @param {string} siteDir - the site's folder
123
+ * @returns {string|null}
124
+ */
125
+ export function findSiteExtension(name, siteDir) {
126
+ return findExtensionPackage(name, dirname(resolve(siteDir)))
127
+ }
128
+
129
+ /**
130
+ * The extension name a site-relative URL refers to: its first segment, once the
131
+ * deployment base is removed (`/docs/effects/entry.js` at base `/docs/` → `effects`).
132
+ *
133
+ * @param {string} url - a site-relative URL, as declared or as the payload carries it
134
+ * @param {string} [base] - the site's deployment base
135
+ * @returns {{ name: string, rest: string } | null} null when the URL names no file inside a folder
136
+ */
137
+ export function extensionNameOf(url, base) {
138
+ if (typeof url !== 'string' || !url.startsWith('/') || url.startsWith('//')) return null
139
+ const parts = stripBasePath(url, base).replace(/^\//, '').split(/[?#]/)[0].split('/')
140
+ if (parts.length < 2 || !isExtensionName(parts[0])) return null
141
+ return { name: parts[0], rest: parts.slice(1).join('/') }
142
+ }
@@ -27,7 +27,8 @@ const TEXT_KINDS = new Set(['string', 'text'])
27
27
  // Rich-content `format` markers on a `text` field — the file-based body shape that
28
28
  // replaced the retired `richtext` kind (2026-06-02 / uwx-format.md). A `text` field
29
29
  // carrying one round-trips as the RAW source string (no ProseMirror conversion).
30
- const CONTENT_TEXT_FORMATS = new Set(['markdown', 'html'])
30
+ // ⛔ `html` was one until 2026-10-14; `@uniweb/schemas` refuses it by name since.
31
+ const CONTENT_TEXT_FORMATS = new Set(['markdown'])
31
32
 
32
33
  // The field an open map's key lowers into. `name` matches the idiom already in use
33
34
  // for this shape (the backend's site-content `collections` section), so an open map
@@ -113,7 +114,7 @@ export function isContentBodyField(field) {
113
114
  * (item_ref) ref to its full `@org/model/<section>` path. Falls back to
114
115
  * `resolveName` (model only) when not supplied.
115
116
  * @returns {Object} the declaration (`{ name, label?, plural?, icon?, description?,
116
- * source_locale?, linkable?, sections }`).
117
+ * source_locale?, linkable?, grantable?, sections }`).
117
118
  */
118
119
  export function toDataSchemaDeclaration(normalized, { name, resolveName, resolveOptions } = {}) {
119
120
  if (!name) throw new Error('toDataSchemaDeclaration: a registry name is required')
@@ -158,6 +159,10 @@ export function toDataSchemaDeclaration(normalized, { name, resolveName, resolve
158
159
  // reference, which read as a promise that one is accepted. A backend refuses it.
159
160
  if (normalized.linkable === false) decl.linkable = false
160
161
 
162
+ // `grantable` — AUTHORED ONLY, like `linkable`: a model's entries may be shared one at a
163
+ // time unless it says `grantable: false` (omitted ⇒ true) [Diego, 2026-10-14].
164
+ if (normalized.grantable === false) decl.grantable = false
165
+
161
166
  decl.sections = sections
162
167
  return decl
163
168
  }
@@ -424,6 +429,9 @@ function lowerLeaf(field, resolve, optResolve, { multiple = false } = {}) {
424
429
  if (leafType === 'ref') {
425
430
  out.type = 'entity_ref'
426
431
  if (field.ref) out.model = resolve(field.ref)
432
+ // The level on the entry it points to that opens this entry, for `read` — by link
433
+ // [Diego, 2026-10-14]. `@uniweb/schemas` admits it only here, as read, edit or admin.
434
+ if (field.readable_with) out.readable_with = field.readable_with
427
435
  return out
428
436
  }
429
437
 
@@ -47,7 +47,8 @@ import { declarationsToQueriesYml } from './records-project.js'
47
47
  import { FILLED_SECTION_TYPE, buildRouteOf } from './site.js'
48
48
  import { translationContext, SITE_META_CONTEXT } from '../i18n/extract.js'
49
49
  import { authorableDeclaration, DECLARATION_KEYS } from '../site/fetch-shapes.js'
50
- import { createTranslationCollector, writeLocaleTranslations, writeFreeformTranslations, unwrapLocalizedContent } from './locale-sync.js'
50
+ import { createTranslationCollector, writeLocaleTranslations, writeFreeformTranslations, unwrapLocalizedContent, isProseMirrorDoc } from './locale-sync.js'
51
+ import { unwritableNodes } from '@uniweb/content-writer'
51
52
  import { buildFreeformPath, freeformPathsFor } from '../i18n/freeform.js'
52
53
  import { unwrapLocalized, unwrapLocalizedList } from './backfill.js'
53
54
  import { LOCALIZED_FIELD_ASSUMPTION } from './localize.js'
@@ -1201,6 +1202,59 @@ function projectLayout(layoutSections, layoutBaseDir, report, prune, ctx) {
1201
1202
  if (prune && written.length > 0) pruneOrphanLayout(layoutBaseDir, new Set(written), report)
1202
1203
  }
1203
1204
 
1205
+ /**
1206
+ * ⛔ A PULL THAT WOULD DROP CONTENT WRITES NOTHING.
1207
+ *
1208
+ * A node a markdown file cannot hold is left out of the file it is written to,
1209
+ * and the push after the pull then deletes it from the site — with nobody told
1210
+ * but a log line. So every section document the projection would write is asked
1211
+ * first (`unwritableNodes`, `@uniweb/content-writer`), every locale's included,
1212
+ * and the pull stops, naming each section and what it holds.
1213
+ * ⛔ Until 2026-10-14 the writer logged one line per type and the pull went on:
1214
+ * measured with a button an editor saved as a node, which a pull and a push then
1215
+ * deleted.
1216
+ *
1217
+ * @param {object} document - the `@uniweb/site-content` document
1218
+ * @param {string} sourceLocale
1219
+ */
1220
+ function refuseContentFilesCannotHold(document, sourceLocale) {
1221
+ const found = []
1222
+ const name = (record, where) => {
1223
+ const own = record?.slug !== undefined ? pageDirName(record, sourceLocale) : record?.stable_id || record?.$uuid
1224
+ return own ? (where ? `${where}/${own}` : String(own)) : where
1225
+ }
1226
+ const visit = (value, where) => {
1227
+ if (Array.isArray(value)) {
1228
+ for (const item of value) visit(item, where)
1229
+ return
1230
+ }
1231
+ if (!value || typeof value !== 'object') return
1232
+ const here = name(value, where)
1233
+ for (const [key, field] of Object.entries(value)) {
1234
+ if (key === 'content') {
1235
+ const docs = isProseMirrorDoc(field)
1236
+ ? [field]
1237
+ : isPlainRecord(field)
1238
+ ? Object.values(field).filter(isProseMirrorDoc)
1239
+ : []
1240
+ const types = [...new Set(docs.flatMap((doc) => unwritableNodes(doc)))]
1241
+ if (types.length) found.push(`${here || 'a section'}: ${types.join(', ')}`)
1242
+ if (docs.length) continue
1243
+ }
1244
+ visit(field, here)
1245
+ }
1246
+ }
1247
+ visit(document?.pages, '')
1248
+ visit(document?.layout_sections, 'layout')
1249
+ if (found.length === 0) return
1250
+ throw new Error(
1251
+ 'This pull would leave content out of your files — a file cannot hold it, so your next push would ' +
1252
+ 'delete it from the site. Nothing was written.\n' +
1253
+ found.map((entry) => ` ${entry}`).join('\n') +
1254
+ '\nChange or remove it where it was made, then pull again.'
1255
+ )
1256
+ }
1257
+
1204
1258
  /**
1205
1259
  * Project a whole `@uniweb/site-content` document to a site's files: `info` →
1206
1260
  * config (siteInfoToConfig), `collections[]` declarations →
@@ -1226,6 +1280,9 @@ function projectLayout(layoutSections, layoutBaseDir, report, prune, ctx) {
1226
1280
  * @returns {{ config: object, collections: object, locales: object, pages: string[], sections: string[], layout: string[], deleted: string[], renamed: object[] }}
1227
1281
  */
1228
1282
  export function siteContentDocumentToProject({ document, siteRoot, backend = null, sourceLocale = LOCALIZED_FIELD_ASSUMPTION.defaultSourceLocale, prune = false, keepAuthoredFoundation = false, scope, dataModel = null }) {
1283
+ // Before anything is written: a pull that would leave content out of the files stops here.
1284
+ refuseContentFilesCannotHold(document, sourceLocale)
1285
+
1229
1286
  const report = { config: null, collections: null, locales: null, assets: null, pages: [], sections: [], layout: [], deleted: [], renamed: [] }
1230
1287
 
1231
1288
  // Collects target-locale translations of localized scalars as they're projected;