@uniweb/build 1.4.0 → 1.5.1
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 +8 -8
- package/src/site/vocabulary-warnings.js +34 -9
- package/src/uwx/cleaned.js +175 -0
- package/src/uwx/index.js +2 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniweb/build",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.1",
|
|
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/
|
|
60
|
-
"@uniweb/
|
|
61
|
-
"@uniweb/semantic-parser": "~1.
|
|
59
|
+
"@uniweb/content-reader": "~1.5.2",
|
|
60
|
+
"@uniweb/projections": "~1.1.4",
|
|
61
|
+
"@uniweb/semantic-parser": "~1.12.0",
|
|
62
62
|
"@uniweb/content-writer": "~1.3.0",
|
|
63
63
|
"@uniweb/schemas": "~1.5.0",
|
|
64
|
-
"@uniweb/
|
|
64
|
+
"@uniweb/theming": "~1.2.0"
|
|
65
65
|
},
|
|
66
66
|
"optionalDependencies": {
|
|
67
|
-
"@uniweb/runtime": "~1.1.
|
|
67
|
+
"@uniweb/runtime": "~1.1.8"
|
|
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/
|
|
77
|
-
"@uniweb/
|
|
76
|
+
"@uniweb/runtime": "~1.1.8",
|
|
77
|
+
"@uniweb/core": "~1.3.0"
|
|
78
78
|
},
|
|
79
79
|
"peerDependenciesMeta": {
|
|
80
80
|
"vite": {
|
|
@@ -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
|
-
|
|
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,
|