@uniweb/build 1.5.0 → 1.5.2

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.5.0",
3
+ "version": "1.5.2",
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/content-reader": "~1.5.1",
60
- "@uniweb/projections": "~1.1.4",
59
+ "@uniweb/theming": "~1.2.0",
60
+ "@uniweb/semantic-parser": "~1.12.1",
61
61
  "@uniweb/schemas": "~1.5.0",
62
- "@uniweb/content-writer": "~1.3.0",
63
- "@uniweb/semantic-parser": "~1.12.0",
64
- "@uniweb/theming": "~1.2.0"
62
+ "@uniweb/content-reader": "~1.5.3",
63
+ "@uniweb/projections": "~1.1.4",
64
+ "@uniweb/content-writer": "~1.3.0"
65
65
  },
66
66
  "optionalDependencies": {
67
- "@uniweb/runtime": "~1.1.8"
67
+ "@uniweb/runtime": "~1.1.9"
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/runtime": "~1.1.8",
77
- "@uniweb/core": "~1.3.0"
76
+ "@uniweb/core": "~1.3.0",
77
+ "@uniweb/runtime": "~1.1.9"
78
78
  },
79
79
  "peerDependenciesMeta": {
80
80
  "vite": {
@@ -1,9 +1,9 @@
1
1
  /**
2
2
  * Prose without its stylesheet — the one foundation mistake that renders fine and looks wrong.
3
3
  *
4
- * kit's `<Prose>` (and `<Article>`, which is `<Prose>` in an `<article>`) draws content inside
5
- * Tailwind Typography's `prose` classes. Those classes style nothing unless the foundation's
6
- * stylesheet brings the plugin, which is one line:
4
+ * kit's `<Prose>` (and `<Article>`, which is `<Prose>` in an `<article>`) draws content inside a
5
+ * `prose` container. The class styles nothing unless the foundation's stylesheet imports kit's
6
+ * prose layer, which is one line:
7
7
  *
8
8
  * @import "@uniweb/kit/prose-tokens.css";
9
9
  *
@@ -20,7 +20,7 @@
20
20
  /** kit's Prose module, wherever kit resolves from — the workspace or node_modules. */
21
21
  const PROSE_MODULE = /[\\/]kit[\\/]src[\\/]styled[\\/]Prose[\\/]index\.jsx$/
22
22
 
23
- /** A rule of the typography plugin's, which every one of its selectors starts with. */
23
+ /** A rule of kit's prose layer, which every one of its selectors starts with. ⛔ Tailwind Typography's until 2026-10-19. */
24
24
  const PROSE_RULE = /\.prose\b/
25
25
 
26
26
  /**
@@ -6,17 +6,43 @@
6
6
  * outside it — a link to `javascript:`, an inert `{.name}`, MathML outside the
7
7
  * allowlist — the renderer leaves out of the page, and a backend that cleans the
8
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.
9
+ * The author hears it here first, once per thing, naming the file — and a push
10
+ * says it again for what the backend removed, in the same words (`uwx/cleaned.js`).
10
11
  */
11
12
  import { outsideVocabulary } from '@uniweb/content-reader/vocabulary'
12
13
 
14
+ /**
15
+ * A value as a message shows it: on one line, each control character escaped, and a long
16
+ * one cut short — an image held as data can run to megabytes.
17
+ *
18
+ * @param {*} value
19
+ * @returns {string}
20
+ */
21
+ export function shown(value) {
22
+ const line = String(value).replace(
23
+ /[\u0000-\u001f\u007f-\u009f]/g,
24
+ (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, '0')}`
25
+ )
26
+ return line.length > 60 ? `${line.slice(0, 59)}…` : line
27
+ }
28
+
13
29
  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`,
30
+ url: (f) => `a link to \`${shown(f.found)}\` is never followed`,
31
+ target: (f) => `\`target=${shown(f.found)}\` is not a link target`,
32
+ 'style-name': (f) => `\`{${shown(f.found)}}\` is not a style name`,
33
+ color: (f) => `\`${shown(f.found)}\` is not a colour`,
34
+ mathml: (f) => `a formula's MathML holds what a page cannot show (${shown(f.found)})`,
35
+ 'ref-id': (f) => `\`${shown(f.found)}\` is not an inset reference`,
36
+ }
37
+
38
+ /**
39
+ * One finding of `outsideVocabulary`, as a sentence: what it is, and why a page leaves it out.
40
+ *
41
+ * @param {{rule: string, found: string}} finding
42
+ * @returns {string}
43
+ */
44
+ export function saysOutside(finding) {
45
+ return SAYS[finding.rule]?.(finding) ?? `\`${shown(finding.found)}\` is not part of a document (${finding.rule})`
20
46
  }
21
47
 
22
48
  /**
@@ -27,7 +53,6 @@ const SAYS = {
27
53
  */
28
54
  export function warnOutsideVocabulary(doc, warn) {
29
55
  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`)
56
+ warn(`${saysOutside(finding)} — a page leaves it out, and a backend that cleans what it stores removes it`)
32
57
  }
33
58
  }
@@ -0,0 +1,175 @@
1
+ // What a backend removed from a push's content, read against what the push sent.
2
+ //
3
+ // ⭐ A backend that cleans each rich-text write to the vocabulary (`@uniweb/content-reader/vocabulary`)
4
+ // answers, beside each finalized document, what it removed — `finalized[].cleaned`, absent when
5
+ // nothing was. Each removal names its item by the `$uuid` the returned document gives it (on a
6
+ // first push, the one the push minted), the field and its language (`data.content.en`; no language
7
+ // for one document in every language), a JSON Pointer into that field's document AS SENT, and the
8
+ // vocabulary's rule. A push never writes the returned document over the author's file, so the file
9
+ // still holds what went, and this is what says so.
10
+ //
11
+ // Read against what the push sent, each removal names what went. One this version's own check
12
+ // finds too is worded as the build words it before a push (`site/vocabulary-warnings.js`); one it
13
+ // does not is a removal this version keeps — the backend cleans to another version of the
14
+ // vocabulary — and is named by what its path holds.
15
+
16
+ import { cleanToVocabulary, outsideVocabulary } from '@uniweb/content-reader/vocabulary'
17
+ import { walkSiteUnits } from './site-diff.js'
18
+ import { saysOutside, shown } from '../site/vocabulary-warnings.js'
19
+
20
+ const isObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v)
21
+ const isDocument = (v) => isObject(v) && v.type === 'doc'
22
+
23
+ /** The value a JSON Pointer (RFC 6901) names in `value`, or undefined. */
24
+ function pointedAt(value, pointer) {
25
+ if (typeof pointer !== 'string' || (pointer !== '' && !pointer.startsWith('/'))) return undefined
26
+ for (const token of pointer === '' ? [] : pointer.slice(1).split('/')) {
27
+ const key = token.replace(/~1/g, '/').replace(/~0/g, '~')
28
+ if (value === null || typeof value !== 'object' || !Object.hasOwn(value, key)) return undefined
29
+ value = value[key]
30
+ }
31
+ return value
32
+ }
33
+
34
+ /** The keys from `node` down to the object whose `$uuid` is `uuid`, or null. */
35
+ function placeOf(node, uuid, place = []) {
36
+ if (node === null || typeof node !== 'object') return null
37
+ if (!Array.isArray(node) && node.$uuid === uuid) return place
38
+ for (const [key, value] of Object.entries(node)) {
39
+ const found = placeOf(value, uuid, [...place, Array.isArray(node) ? Number(key) : key])
40
+ if (found) return found
41
+ }
42
+ return null
43
+ }
44
+
45
+ const at = (value, place) => place.reduce((v, key) => (v !== null && typeof v === 'object' ? v[key] : undefined), value)
46
+
47
+ // What a removal this version keeps held, by its rule — there is no finding of ours to word it,
48
+ // so it is named by what its path holds.
49
+ const HELD = {
50
+ 'node-type': (value) => `a \`${shown(value?.type)}\` node`,
51
+ 'mark-type': (value) => `a \`${shown(value?.type)}\` mark`,
52
+ attribute: (value, name) => `the attribute \`${shown(name)}\``,
53
+ 'style-name': (value, name) => `the style name \`${shown(name)}\``,
54
+ url: (value) => `the URL \`${shown(value)}\``,
55
+ target: (value) => `\`target=${shown(value)}\``,
56
+ color: (value) => `the colour \`${shown(value)}\``,
57
+ mathml: () => "a formula's MathML",
58
+ 'ref-id': (value) => `the inset reference \`${shown(value)}\``,
59
+ }
60
+
61
+ /**
62
+ * One removal, read against the item it was sent as: `{ locale, says, keeps }` — `says` a
63
+ * sentence, `keeps` true where this version of the vocabulary keeps what the backend removed.
64
+ */
65
+ function describeRemoval(removal, item) {
66
+ const keys = typeof removal?.field === 'string' ? removal.field.split('.') : []
67
+ // The backend names an item's fields under `data`; on the wire they are the item's own keys.
68
+ if (keys[0] === 'data' && !(isObject(item) && Object.hasOwn(item, 'data'))) keys.shift()
69
+ const [name, locale = null] = keys
70
+ const rule = typeof removal?.rule === 'string' ? removal.rule : 'unknown'
71
+ const path = typeof removal?.path === 'string' ? removal.path : ''
72
+ let doc = isObject(item) && name ? item[name] : undefined
73
+ if (locale !== null && !isDocument(doc)) doc = isObject(doc) ? doc[locale] : undefined
74
+ if (!isDocument(doc)) {
75
+ return { locale, keeps: false, says: `the backend removed what \`${shown(path || '/')}\` held in \`${shown(removal?.field)}\` (${rule})` }
76
+ }
77
+ const ours = outsideVocabulary(doc).find((f) => f.path === path && f.rule === rule)
78
+ if (ours) return { locale, keeps: false, says: `${saysOutside(ours)} — the backend removed it` }
79
+ const last = path.slice(path.lastIndexOf('/') + 1).replace(/~1/g, '/').replace(/~0/g, '~')
80
+ const what = HELD[rule]?.(pointedAt(doc, path), last) ?? `what \`${shown(path || '/')}\` held (${rule})`
81
+ return { locale, keeps: true, says: `the backend removed ${what}, which this version of Uniweb keeps` }
82
+ }
83
+
84
+ /**
85
+ * What the backend removed from a site-content push: `[{ unit, locale, says, keeps }]`.
86
+ *
87
+ * `unit` is the path the unit is keyed by (`pages/home/hero.md`, `layout/footer.md`), found by
88
+ * the `$uuid` the returned document gives the item — null for an item that is no unit. Each
89
+ * removal is read against the item the push sent for that unit.
90
+ *
91
+ * @param {object} p
92
+ * @param {Array<{document?: object, cleaned?: object[]}>} p.finalized - the lane's finalized entries
93
+ * @param {object|null} p.sent - the site-content document the push sent
94
+ * @returns {Array<{unit: string|null, locale: string|null, says: string, keeps: boolean}>}
95
+ */
96
+ export function siteContentRemovals({ finalized, sent }) {
97
+ const sentAt = new Map()
98
+ if (sent) walkSiteUnits(sent, (path, record) => sentAt.has(path) || sentAt.set(path, record))
99
+ const out = []
100
+ for (const entry of finalized || []) {
101
+ if (!Array.isArray(entry?.cleaned) || !entry.cleaned.length) continue
102
+ const unitOf = new Map()
103
+ if (entry.document) {
104
+ walkSiteUnits(entry.document, (path, record) => {
105
+ if (typeof record?.$uuid === 'string' && !unitOf.has(record.$uuid)) unitOf.set(record.$uuid, path)
106
+ })
107
+ }
108
+ for (const removal of entry.cleaned) {
109
+ const unit = unitOf.get(removal?.$uuid) ?? null
110
+ out.push({ unit, ...describeRemoval(removal, unit ? sentAt.get(unit) : undefined) })
111
+ }
112
+ }
113
+ return out
114
+ }
115
+
116
+ /**
117
+ * What the backend removed from a records push: `[{ index, locale, says, keeps }]`, `index` the
118
+ * submitted position the finalized entry answers for. Each removal is read against the document
119
+ * the push sent at that position, at the place its item holds in the returned document — the
120
+ * backend returns a record item for item as it was sent.
121
+ *
122
+ * @param {object} p
123
+ * @param {Array<{index: number, document?: object, cleaned?: object[]}>} p.finalized
124
+ * @param {Array<object|null>} p.sent - the documents the push sent, in submitted order
125
+ * @returns {Array<{index: number, locale: string|null, says: string, keeps: boolean}>}
126
+ */
127
+ export function recordRemovals({ finalized, sent }) {
128
+ const out = []
129
+ for (const entry of finalized || []) {
130
+ if (!Array.isArray(entry?.cleaned) || !entry.cleaned.length) continue
131
+ const sentDoc = sent?.[entry.index]
132
+ for (const removal of entry.cleaned) {
133
+ const place = entry.document ? placeOf(entry.document, removal?.$uuid) : null
134
+ out.push({ index: entry.index, ...describeRemoval(removal, place && sentDoc ? at(sentDoc, place) : undefined) })
135
+ }
136
+ }
137
+ return out
138
+ }
139
+
140
+ /**
141
+ * A record with each rich-text document in it cleaned to the vocabulary — what a backend that
142
+ * cleans at write stores for it — or the record itself when nothing in it is outside. A push
143
+ * compares what the backend returns with this as well as with what it sent, so content the
144
+ * backend cleaned reads as this copy's own, not as someone else's.
145
+ *
146
+ * ⚠️ Only what this version of the vocabulary removes: a removal it keeps still reads as a
147
+ * difference.
148
+ *
149
+ * @param {object} record - one item's fields, as sent
150
+ * @returns {object}
151
+ */
152
+ export function cleanedRecord(record) {
153
+ if (!isObject(record)) return record
154
+ let out = null
155
+ for (const [key, value] of Object.entries(record)) {
156
+ const cleaned = cleanedRichText(value)
157
+ if (cleaned !== value) (out ??= { ...record })[key] = cleaned
158
+ }
159
+ return out ?? record
160
+ }
161
+
162
+ // A document, or a document per language, cleaned — the same value when nothing in it is outside.
163
+ function cleanedRichText(value) {
164
+ if (isDocument(value)) {
165
+ const { document, removed } = cleanToVocabulary(value)
166
+ return removed.length ? document : value
167
+ }
168
+ if (!isObject(value) || !Object.keys(value).length || !Object.values(value).every(isDocument)) return value
169
+ let out = null
170
+ for (const [locale, doc] of Object.entries(value)) {
171
+ const { document, removed } = cleanToVocabulary(doc)
172
+ if (removed.length) (out ??= { ...value })[locale] = document
173
+ }
174
+ return out ?? value
175
+ }
package/src/uwx/index.js CHANGED
@@ -144,6 +144,8 @@ export {
144
144
  siteItemsByKey,
145
145
  stampUnitUuids,
146
146
  } from './site-diff.js'
147
+ // What a backend that cleans rich text at write removed from a push, read against what was sent.
148
+ export { siteContentRemovals, recordRemovals, cleanedRecord } from './cleaned.js'
147
149
  export {
148
150
  recordsToEntities,
149
151
  buildRecordEntities,