@uniweb/build 1.1.1 → 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.1",
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/projections": "~1.1.0",
60
- "@uniweb/content-writer": "~1.0.1",
61
- "@uniweb/semantic-parser": "~1.5.1",
62
- "@uniweb/schemas": "~1.0.1",
63
- "@uniweb/content-reader": "~1.2.8",
64
- "@uniweb/theming": "~1.1.0"
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,
@@ -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
@@ -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
+ }
@@ -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;