hyper-html-api 0.12.0 → 0.14.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/README.md +4 -1
- package/conformance/MANIFEST.json +2 -2
- package/conformance/cases/all-matches-read-quoted.expected.json +3 -0
- package/conformance/cases/all-matches-read-quoted.html +5 -0
- package/conformance/cases/all-matches-read-quoted.meta +7 -0
- package/conformance/cases/all-matches-read-quoted.parsed.json +3 -0
- package/conformance/cases/all-matches-read.expected.json +3 -0
- package/conformance/cases/all-matches-read.html +6 -0
- package/conformance/cases/all-matches-read.meta +7 -0
- package/conformance/cases/all-matches-read.parsed.json +3 -0
- package/conformance/cases/stylevar-read.expected.json +12 -0
- package/conformance/cases/stylevar-read.html +7 -0
- package/conformance/cases/stylevar-read.meta +6 -0
- package/conformance/cases/stylevar-read.parsed.json +12 -0
- package/conformance/cases/stylevar-read.rules +1 -0
- package/conformance/cases/write-all-matches-attr.after.html +1 -0
- package/conformance/cases/write-all-matches-attr.after.json +3 -0
- package/conformance/cases/write-all-matches-attr.data.json +1 -0
- package/conformance/cases/write-all-matches-attr.html +1 -0
- package/conformance/cases/write-all-matches-attr.meta +6 -0
- package/conformance/cases/write-all-matches-attr.write.json +4 -0
- package/conformance/cases/write-all-matches-group.after.html +1 -0
- package/conformance/cases/write-all-matches-group.after.json +3 -0
- package/conformance/cases/write-all-matches-group.data.json +1 -0
- package/conformance/cases/write-all-matches-group.html +1 -0
- package/conformance/cases/write-all-matches-group.meta +6 -0
- package/conformance/cases/write-all-matches-group.write.json +4 -0
- package/conformance/cases/write-all-matches-text.after.html +1 -0
- package/conformance/cases/write-all-matches-text.after.json +3 -0
- package/conformance/cases/write-all-matches-text.data.json +1 -0
- package/conformance/cases/write-all-matches-text.html +1 -0
- package/conformance/cases/write-all-matches-text.meta +6 -0
- package/conformance/cases/write-all-matches-text.write.json +4 -0
- package/conformance/cases/write-all-matches-unmatched.data.json +1 -0
- package/conformance/cases/write-all-matches-unmatched.error.json +14 -0
- package/conformance/cases/write-all-matches-unmatched.html +1 -0
- package/conformance/cases/write-all-matches-unmatched.meta +6 -0
- package/conformance/cases/write-refused-raw-style.data.json +1 -0
- package/conformance/cases/write-refused-raw-style.error.json +11 -0
- package/conformance/cases/write-refused-raw-style.html +1 -0
- package/conformance/cases/write-refused-raw-style.meta +4 -0
- package/conformance/cases/write-refused-stylevar-values.data.json +1 -0
- package/conformance/cases/write-refused-stylevar-values.error.json +43 -0
- package/conformance/cases/write-refused-stylevar-values.html +1 -0
- package/conformance/cases/write-refused-stylevar-values.meta +5 -0
- package/conformance/cases/write-stylevar-clear.after.html +1 -0
- package/conformance/cases/write-stylevar-clear.after.json +4 -0
- package/conformance/cases/write-stylevar-clear.data.json +1 -0
- package/conformance/cases/write-stylevar-clear.html +1 -0
- package/conformance/cases/write-stylevar-clear.meta +5 -0
- package/conformance/cases/write-stylevar-clear.write.json +4 -0
- package/conformance/cases/write-stylevar-set.after.html +2 -0
- package/conformance/cases/write-stylevar-set.after.json +5 -0
- package/conformance/cases/write-stylevar-set.data.json +1 -0
- package/conformance/cases/write-stylevar-set.html +1 -0
- package/conformance/cases/write-stylevar-set.meta +6 -0
- package/conformance/cases/write-stylevar-set.write.json +4 -0
- package/conformance/cases/write-stylevar-unchanged.after.html +1 -0
- package/conformance/cases/write-stylevar-unchanged.after.json +3 -0
- package/conformance/cases/write-stylevar-unchanged.data.json +1 -0
- package/conformance/cases/write-stylevar-unchanged.html +1 -0
- package/conformance/cases/write-stylevar-unchanged.meta +5 -0
- package/conformance/cases/write-stylevar-unchanged.write.json +4 -0
- package/conformance/write-policy.json +23 -1
- package/dist/hyper-html-api.engine.esm.js +1 -1
- package/dist/hyper-html-api.engine.min.js +1 -1
- package/dist/hyper-html-api.min.js +8 -8
- package/dist/hyper-html-api.upgrade.min.js +2 -2
- package/package.json +4 -3
- package/src/engine/apply.js +22 -7
- package/src/engine/extract.js +8 -3
- package/src/engine/index.js +2 -1
- package/src/engine/round-trip.js +14 -1
- package/src/engine/rule-syntax.js +13 -3
- package/src/engine/style-vars.js +141 -0
- package/src/engine/write-plan.js +2 -3
- package/src/engine/write-policy.js +60 -0
- package/src/page.js +15 -5
- package/src/site.js +164 -0
- package/src/upgrade/migrate.js +2 -1
- package/src/upgrade/run.js +30 -0
- package/src/upgrade/shape-match.js +32 -13
- package/src/write.js +1 -1
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { POLICY_TOKENS } from '../lib/region-capabilities.js'
|
|
2
2
|
import { WriteRefused } from './errors.js'
|
|
3
|
+
import { isCustomPropertyName, readStyleVar, setStyleVar } from './style-vars.js'
|
|
3
4
|
|
|
4
5
|
export const WRITE_POLICY = Object.freeze({
|
|
5
6
|
refusedRuleForms: ['innerHTML', 'outerHTML'],
|
|
@@ -12,6 +13,11 @@ export const WRITE_POLICY = Object.freeze({
|
|
|
12
13
|
refusedAttributePrefixes: ['on'],
|
|
13
14
|
urlAttributes: ['href', 'src', 'srcset', 'action', 'formaction', 'poster', 'cite', 'data', 'ping', 'background', 'longdesc', 'usemap', 'xlink:href', 'manifest', 'codebase', 'icon'],
|
|
14
15
|
refusedSchemes: ['javascript', 'vbscript', 'data'],
|
|
16
|
+
customPropertyValues: Object.freeze({
|
|
17
|
+
maxLength: 200,
|
|
18
|
+
refusedCharacters: [';', '{', '}', '<', '>', '\\'],
|
|
19
|
+
refusedFunctions: ['url', 'src', 'image', 'image-set', '-webkit-image-set', 'cross-fade', 'element', 'expression', 'paint'],
|
|
20
|
+
}),
|
|
15
21
|
})
|
|
16
22
|
|
|
17
23
|
const TEXT_PROPS = new Set(['textContent', 'innerText'])
|
|
@@ -71,6 +77,48 @@ function isOrHoldsRulesTag(adapter, node) {
|
|
|
71
77
|
return holdsRulesTag(adapter, node)
|
|
72
78
|
}
|
|
73
79
|
|
|
80
|
+
// Every quote closed, every bracket closed by its own kind, no comment and no
|
|
81
|
+
// control character. A value that fails this would swallow the declarations
|
|
82
|
+
// after it in the style attribute.
|
|
83
|
+
function unbalancedReason(text) {
|
|
84
|
+
if (/[\u0000-\u001f\u007f]/.test(text)) return 'control characters are not allowed in a custom property value'
|
|
85
|
+
const stack = []
|
|
86
|
+
let quote = null
|
|
87
|
+
for (let i = 0; i < text.length; i++) {
|
|
88
|
+
const c = text[i]
|
|
89
|
+
if (quote) {
|
|
90
|
+
if (c === quote) quote = null
|
|
91
|
+
continue
|
|
92
|
+
}
|
|
93
|
+
if (c === '"' || c === "'") quote = c
|
|
94
|
+
else if (c === '/' && text[i + 1] === '*') return 'comments are not allowed in a custom property value'
|
|
95
|
+
else if (c === '(') stack.push(')')
|
|
96
|
+
else if (c === '[') stack.push(']')
|
|
97
|
+
else if (c === ')' || c === ']') {
|
|
98
|
+
if (stack.pop() !== c) return `"${c}" does not close anything in this custom property value`
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
if (quote) return 'a custom property value must close its quotes'
|
|
102
|
+
if (stack.length) return 'a custom property value must close its brackets'
|
|
103
|
+
return null
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// A custom property value that cannot load anything, close its declaration, or
|
|
107
|
+
// carry markup. Colors, font stacks, lengths and keywords all pass.
|
|
108
|
+
export function checkCustomPropertyValue(value, policy = WRITE_POLICY) {
|
|
109
|
+
const rules = policy.customPropertyValues
|
|
110
|
+
const text = String(value)
|
|
111
|
+
if (text.length > rules.maxLength) return `custom property values are limited to ${rules.maxLength} characters`
|
|
112
|
+
const ch = rules.refusedCharacters.find((c) => text.includes(c))
|
|
113
|
+
if (ch) return `${JSON.stringify(ch)} is not allowed in a custom property value`
|
|
114
|
+
const unbalanced = unbalancedReason(text)
|
|
115
|
+
if (unbalanced) return unbalanced
|
|
116
|
+
const lower = text.toLowerCase()
|
|
117
|
+
const fn = rules.refusedFunctions.find((f) => new RegExp(`(^|[^a-z0-9_-])${f.replace(/[-]/g, '\\-')}\\s*\\(`).test(lower))
|
|
118
|
+
if (fn) return `"${fn}()" is not allowed in a custom property value`
|
|
119
|
+
return null
|
|
120
|
+
}
|
|
121
|
+
|
|
74
122
|
// Wraps an adapter so every write is checked against the policy first. A write
|
|
75
123
|
// that breaks it is skipped and recorded; call assertClean() after apply() to
|
|
76
124
|
// throw one WriteRefused listing every violation.
|
|
@@ -108,6 +156,18 @@ export function guardAdapter(adapter, policy = WRITE_POLICY) {
|
|
|
108
156
|
removeAttr(node, name) {
|
|
109
157
|
if (allowAttr(node, name, null)) adapter.removeAttr(node, name)
|
|
110
158
|
},
|
|
159
|
+
// The engine's @--name write: one custom property, checked by name and
|
|
160
|
+
// value. Every other declaration in the attribute is left as it is.
|
|
161
|
+
styleVar(node, name, value) {
|
|
162
|
+
if ((readStyleVar(adapter.attr(node, 'style'), name) ?? '') === value) return
|
|
163
|
+
if (!allowTarget(node)) return
|
|
164
|
+
if (!isCustomPropertyName(name)) return refuse(node, `"${name}" is not a custom property`)
|
|
165
|
+
if (value !== '') {
|
|
166
|
+
const reason = checkCustomPropertyValue(value, policy)
|
|
167
|
+
if (reason) return refuse(node, reason)
|
|
168
|
+
}
|
|
169
|
+
setStyleVar(adapter, node, name, value)
|
|
170
|
+
},
|
|
111
171
|
prop(node, name, value) {
|
|
112
172
|
if (value === undefined) return adapter.prop(node, name)
|
|
113
173
|
if (policy.refusedRuleForms.includes(name)) return refuse(node, `"@${name}" writes HTML, only text is allowed`)
|
package/src/page.js
CHANGED
|
@@ -5,7 +5,7 @@ import { NoRulesTag, RulesParseError } from './engine/errors.js'
|
|
|
5
5
|
import { parseRelaxed } from './engine/rules.js'
|
|
6
6
|
import { writeDocument } from './write.js'
|
|
7
7
|
|
|
8
|
-
const PAGE = /^
|
|
8
|
+
const PAGE = /^blockeditor(?:\.[0-9]+)?$/
|
|
9
9
|
const TEXT = new Set(['paragraph', 'heading', 'list_item', 'callout', 'code_block', 'toggle'])
|
|
10
10
|
const MARKS = ['b', 'i', 's', 'c', 'a']
|
|
11
11
|
|
|
@@ -21,10 +21,19 @@ function refuse(message) {
|
|
|
21
21
|
throw new PageWriteRefused('invalid-content', message)
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
// Inert content (a starter article kept in a <template>) is not part of the page, as in a browser.
|
|
25
|
+
// Cheerio parents a template's content under a root node, which closest() does not climb past.
|
|
26
|
+
function live(_, node) {
|
|
27
|
+
for (let up = node.parent; up; up = up.parent) {
|
|
28
|
+
if (up.type === 'tag' && (up.name === 'template' || up.name === 'noscript')) return false
|
|
29
|
+
}
|
|
30
|
+
return true
|
|
31
|
+
}
|
|
32
|
+
|
|
24
33
|
function pageNodes($) {
|
|
25
|
-
const tags = $('script[merge][clay~="live"]').filter((_, node) => PAGE.test($(node).attr('merge')))
|
|
34
|
+
const tags = $('script[merge][clay~="live"]').filter((_, node) => PAGE.test($(node).attr('merge'))).filter(live)
|
|
26
35
|
if (!tags.length) return null
|
|
27
|
-
const articles = $('article[data-
|
|
36
|
+
const articles = $('article[data-blockeditor]').filter(live)
|
|
28
37
|
if (tags.length !== 1 || articles.length !== 1) refuse('Expected one page store and one page article')
|
|
29
38
|
return { tag: tags[0], article: articles[0] }
|
|
30
39
|
}
|
|
@@ -316,8 +325,9 @@ export async function writePageDocument(load, html, data, options = {}) {
|
|
|
316
325
|
const next$ = load(candidate.html, { sourceCodeLocationInfo: true })
|
|
317
326
|
const nextNodes = pageNodes(next$)
|
|
318
327
|
const aligned$ = load(aligned)
|
|
319
|
-
const
|
|
320
|
-
const
|
|
328
|
+
const alignedArticle = pageNodes(aligned$).article
|
|
329
|
+
const changes = pageChanges(next$, nextNodes.article, aligned$, alignedArticle, page.blocks, before.model, projection)
|
|
330
|
+
const unchangedAttrs = canon(next$(nextNodes.article).attr()) === canon(aligned$(alignedArticle).attr())
|
|
321
331
|
const proposed = projectionOf(load, proposedBlocks(page.blocks, changes), projection)
|
|
322
332
|
if (!unchangedAttrs || !sameArticle(next$, nextNodes.article, proposed)) {
|
|
323
333
|
refuse('The article edit cannot be represented in the page store')
|
package/src/site.js
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import cheerioAdapter from './adapters/cheerio.js'
|
|
2
|
+
import { extract } from './engine/extract.js'
|
|
3
|
+
import { findRulesIn } from './engine/rules-tag.js'
|
|
4
|
+
import { splitRule } from './engine/rule-syntax.js'
|
|
5
|
+
import { writeDocument } from './write.js'
|
|
6
|
+
|
|
7
|
+
export const SITE_KEY = 'site'
|
|
8
|
+
|
|
9
|
+
function isPlainObject(value) {
|
|
10
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// The part of `value` that `rule` declares. Keys the rule does not name are
|
|
14
|
+
// dropped at every level, because the writer refuses unknown keys and a store
|
|
15
|
+
// holds keys other pages use. A key the store does not hold stays absent, and
|
|
16
|
+
// the writer skips an absent key, so the page keeps what it has.
|
|
17
|
+
export function projectSite(rule, value) {
|
|
18
|
+
if (isPlainObject(rule)) {
|
|
19
|
+
if (!isPlainObject(value)) return value
|
|
20
|
+
const out = {}
|
|
21
|
+
for (const key of Object.keys(rule)) {
|
|
22
|
+
if (value[key] !== undefined) out[key] = projectSite(rule[key], value[key])
|
|
23
|
+
}
|
|
24
|
+
return out
|
|
25
|
+
}
|
|
26
|
+
if (Array.isArray(rule) && Array.isArray(value)) {
|
|
27
|
+
return value.map((item) => projectSite(rule[1], item))
|
|
28
|
+
}
|
|
29
|
+
return value
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Writes the store's values into a page through the page's own `site` rule.
|
|
33
|
+
// Never throws for a page problem: a key that cannot be written is listed in
|
|
34
|
+
// `failed` and the other keys still land. `declared` is false when the page has
|
|
35
|
+
// no rules tag or no `site` object in it, and then `html` is `src`.
|
|
36
|
+
export function writeSite(load, src, store, options = {}) {
|
|
37
|
+
const { token = 'api' } = options
|
|
38
|
+
const none = { html: src, changed: false, declared: false, applied: [], failed: [] }
|
|
39
|
+
if (!isPlainObject(store)) return none
|
|
40
|
+
let found
|
|
41
|
+
try {
|
|
42
|
+
found = findRulesIn(cheerioAdapter, load(src).root(), token)
|
|
43
|
+
} catch (error) {
|
|
44
|
+
return { ...none, declared: true, failed: [{ key: null, code: error.name, message: error.message }] }
|
|
45
|
+
}
|
|
46
|
+
if (!found || !isPlainObject(found.rules) || !isPlainObject(found.rules[SITE_KEY])) return none
|
|
47
|
+
const data = projectSite(found.rules[SITE_KEY], store)
|
|
48
|
+
const keys = Object.keys(data)
|
|
49
|
+
if (keys.length === 0) return { ...none, declared: true }
|
|
50
|
+
const write = (html, part) => writeDocument(load, html, { [SITE_KEY]: part }, { token, rules: found.rules }).html
|
|
51
|
+
try {
|
|
52
|
+
const html = write(src, data)
|
|
53
|
+
return { html, changed: html !== src, declared: true, applied: keys, failed: [] }
|
|
54
|
+
} catch {
|
|
55
|
+
// One key failed. Write them one at a time so the rest still land.
|
|
56
|
+
}
|
|
57
|
+
let html = src
|
|
58
|
+
const applied = []
|
|
59
|
+
const failed = []
|
|
60
|
+
for (const key of keys) {
|
|
61
|
+
try {
|
|
62
|
+
html = write(html, { [key]: data[key] })
|
|
63
|
+
applied.push(key)
|
|
64
|
+
} catch (error) {
|
|
65
|
+
failed.push({ key, code: error.name, message: error.message })
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return { html, changed: html !== src, declared: true, applied, failed }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// The same rule with each attribute read turned into a read of its element's text, which is
|
|
72
|
+
// null exactly where the element is missing: a missing element can then be told apart from
|
|
73
|
+
// an element without the attribute, which both read as a null attribute.
|
|
74
|
+
function presenceRule(rule) {
|
|
75
|
+
if (typeof rule === 'string') {
|
|
76
|
+
const { selector, prop } = splitRule(rule)
|
|
77
|
+
return prop && !prop.endsWith('[]') ? (selector || '.') : rule
|
|
78
|
+
}
|
|
79
|
+
if (Array.isArray(rule)) return rule.length < 2 ? rule : [rule[0], presenceRule(rule[1])]
|
|
80
|
+
if (isPlainObject(rule)) return Object.fromEntries(Object.entries(rule).map(([key, value]) => [key, presenceRule(value)]))
|
|
81
|
+
return rule
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// The page's own `site` rule and the values the page holds for it right now, read key by
|
|
85
|
+
// key: a key whose rule cannot be read (a broken selector) is listed in `broken` and left
|
|
86
|
+
// out of `data`, and the other keys still read. `present` mirrors `data` for
|
|
87
|
+
// siteValueProblem. Never throws: a page with no rules tag, or no `site` key, declares nothing.
|
|
88
|
+
export function readSite(load, src, options = {}) {
|
|
89
|
+
const { token = 'api' } = options
|
|
90
|
+
const none = { declared: false, rule: null, data: {}, present: {}, broken: [] }
|
|
91
|
+
let root
|
|
92
|
+
let found
|
|
93
|
+
try {
|
|
94
|
+
root = load(src).root()
|
|
95
|
+
found = findRulesIn(cheerioAdapter, root, token)
|
|
96
|
+
} catch {
|
|
97
|
+
return none
|
|
98
|
+
}
|
|
99
|
+
if (!found || !isPlainObject(found.rules) || !isPlainObject(found.rules[SITE_KEY])) return none
|
|
100
|
+
const rule = found.rules[SITE_KEY]
|
|
101
|
+
const read = (keyRule, key) => extract(cheerioAdapter, root, { [key]: keyRule }, { templateAttr: 'cms-template' })[key]
|
|
102
|
+
const data = {}
|
|
103
|
+
const present = {}
|
|
104
|
+
const broken = []
|
|
105
|
+
for (const key of Object.keys(rule)) {
|
|
106
|
+
try {
|
|
107
|
+
data[key] = read(rule[key], key)
|
|
108
|
+
present[key] = read(presenceRule(rule[key]), key)
|
|
109
|
+
} catch {
|
|
110
|
+
delete data[key]
|
|
111
|
+
broken.push(key)
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return { declared: true, rule, data, present, broken }
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Why a value read from a page must not be written to the store, or null when it may.
|
|
118
|
+
// A text rule that matched no element reads null, and an emptied list cannot be regrown
|
|
119
|
+
// by writeSite, so both would damage every other page. A null attribute is a real value:
|
|
120
|
+
// it means the attribute is absent, unless `present` (readSite's mirror of the value) shows
|
|
121
|
+
// that the attribute's element is missing too.
|
|
122
|
+
export function siteValueProblem(rule, value, present) {
|
|
123
|
+
if (typeof rule === 'string') {
|
|
124
|
+
if (rule.endsWith('[]')) {
|
|
125
|
+
if (!Array.isArray(value)) return 'unreadable'
|
|
126
|
+
return value.length === 0 ? 'empty-list' : null
|
|
127
|
+
}
|
|
128
|
+
if (value !== null) return null
|
|
129
|
+
return splitRule(rule).prop === null || present === null ? 'unreadable' : null
|
|
130
|
+
}
|
|
131
|
+
if (Array.isArray(rule)) {
|
|
132
|
+
if (!Array.isArray(value)) return 'unreadable'
|
|
133
|
+
if (value.length === 0) return 'empty-list'
|
|
134
|
+
if (rule.length < 2) return null
|
|
135
|
+
for (const [index, row] of value.entries()) {
|
|
136
|
+
const problem = siteValueProblem(rule[1], row, Array.isArray(present) ? present[index] : undefined)
|
|
137
|
+
if (problem) return problem
|
|
138
|
+
}
|
|
139
|
+
return null
|
|
140
|
+
}
|
|
141
|
+
if (isPlainObject(rule)) {
|
|
142
|
+
if (!isPlainObject(value)) return 'unreadable'
|
|
143
|
+
for (const key of Object.keys(rule)) {
|
|
144
|
+
const problem = siteValueProblem(rule[key], value[key], isPlainObject(present) ? present[key] : undefined)
|
|
145
|
+
if (problem) return problem
|
|
146
|
+
}
|
|
147
|
+
return null
|
|
148
|
+
}
|
|
149
|
+
return 'unreadable'
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// The store's next value for one key after a page edit. An object keeps the fields the
|
|
153
|
+
// page's rule does not name, because other pages use them. A list is taken whole from
|
|
154
|
+
// the page: rows have no identity to merge by.
|
|
155
|
+
export function mergeSiteEdit(rule, stored, edited) {
|
|
156
|
+
if (isPlainObject(rule) && isPlainObject(edited)) {
|
|
157
|
+
const out = isPlainObject(stored) ? { ...stored } : {}
|
|
158
|
+
for (const key of Object.keys(rule)) {
|
|
159
|
+
if (edited[key] !== undefined) out[key] = mergeSiteEdit(rule[key], out[key], edited[key])
|
|
160
|
+
}
|
|
161
|
+
return out
|
|
162
|
+
}
|
|
163
|
+
return edited
|
|
164
|
+
}
|
package/src/upgrade/migrate.js
CHANGED
|
@@ -21,7 +21,8 @@ export function migrateInto(adapter, root, dataByName) {
|
|
|
21
21
|
continue
|
|
22
22
|
}
|
|
23
23
|
const { data, summary } = shapeMatch(dataByName[name], rules)
|
|
24
|
-
|
|
24
|
+
// A row whose nested list is empty at that spot in v2 grows from the page's seeds, as a write does.
|
|
25
|
+
apply(adapter, root, rules, data, { templateAttr: 'cms-template' })
|
|
25
26
|
byName[name] = summary
|
|
26
27
|
totals.carriedOver += summary.carriedOver
|
|
27
28
|
totals.discarded += summary.discarded
|
package/src/upgrade/run.js
CHANGED
|
@@ -72,7 +72,37 @@ export async function run({ sourceUrl, dataByName, fromVersion, doc, loc, fetchF
|
|
|
72
72
|
}
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
+
// The engine writes form values as properties, which serialization never sees. The
|
|
76
|
+
// parsed copy has had no other input, so every property that differs from its
|
|
77
|
+
// attribute came from the data and goes into the markup.
|
|
78
|
+
function materializeFormState(doc) {
|
|
79
|
+
for (const input of doc.querySelectorAll('input')) {
|
|
80
|
+
const type = (input.getAttribute('type') || '').toLowerCase()
|
|
81
|
+
if (type === 'file') continue
|
|
82
|
+
if (type === 'checkbox' || type === 'radio') {
|
|
83
|
+
if (input.checked !== input.hasAttribute('checked')) input.toggleAttribute('checked', input.checked)
|
|
84
|
+
} else if (input.value !== (input.getAttribute('value') ?? '')) {
|
|
85
|
+
input.setAttribute('value', input.value)
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
for (const area of doc.querySelectorAll('textarea')) {
|
|
89
|
+
if (area.value !== area.textContent) area.textContent = area.value
|
|
90
|
+
}
|
|
91
|
+
for (const select of doc.querySelectorAll('select')) {
|
|
92
|
+
const options = [...select.options]
|
|
93
|
+
if (!select.multiple) {
|
|
94
|
+
const marked = options.findIndex((option) => option.hasAttribute('selected'))
|
|
95
|
+
// An unmarked select shows its first option without saying so.
|
|
96
|
+
if (select.selectedIndex === marked || (marked === -1 && select.selectedIndex <= 0)) continue
|
|
97
|
+
}
|
|
98
|
+
for (const option of options) {
|
|
99
|
+
if (option.selected !== option.hasAttribute('selected')) option.toggleAttribute('selected', option.selected)
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
75
104
|
function serializeDocument(doc) {
|
|
105
|
+
materializeFormState(doc)
|
|
76
106
|
const name = doc.doctype && doc.doctype.name ? doc.doctype.name : 'html'
|
|
77
107
|
return `<!DOCTYPE ${name}>\n` + doc.documentElement.outerHTML
|
|
78
108
|
}
|
|
@@ -1,14 +1,29 @@
|
|
|
1
|
+
import { DOM_PROPERTIES_READ_ONLY_SET } from '../engine/dom-properties.js'
|
|
2
|
+
import { ruleAttrIndex } from '../engine/rule-syntax.js'
|
|
3
|
+
|
|
1
4
|
/**
|
|
2
5
|
* Walk v2Rules pulling matching values from v1Data.
|
|
3
6
|
*
|
|
7
|
+
* Outside a list, what v1 has no value for keeps v2's template default:
|
|
8
|
+
*
|
|
4
9
|
* - Scalar leaves: carry the v1 value if defined, else leave undefined so
|
|
5
10
|
* the engine keeps v2's template default on apply.
|
|
6
|
-
* -
|
|
7
|
-
*
|
|
8
|
-
* in v1 are left undefined → template default.
|
|
9
|
-
* - Scalar arrays (`sel[]`): carry as-is.
|
|
11
|
+
* - Lists: carry v1's items, trimmed to v1's count; a list v1 never had
|
|
12
|
+
* keeps v2's rows.
|
|
10
13
|
* - Objects: recurse per key.
|
|
11
14
|
*
|
|
15
|
+
* Inside a list item nothing keeps a default. A row is the owner's own. It is
|
|
16
|
+
* written over a v2 sample row, or over a clone of one, so a field left
|
|
17
|
+
* undefined would take that sample's value (its label, its cover, its done
|
|
18
|
+
* mark, its id). So in a row:
|
|
19
|
+
*
|
|
20
|
+
* - A field v1 read as null, a field v1 never had (v2 added it), and a
|
|
21
|
+
* scalar field where v1 holds an object are all carried as null, and the
|
|
22
|
+
* write clears them.
|
|
23
|
+
* - A list v1 never had is carried as an empty list.
|
|
24
|
+
* - A property nothing can write (tagName, childElementCount) stays
|
|
25
|
+
* undefined, because writing null to it refuses the whole write.
|
|
26
|
+
*
|
|
12
27
|
* Returns { data, summary } where summary counts scalar leaves carried,
|
|
13
28
|
* dropped, and total list items preserved.
|
|
14
29
|
*/
|
|
@@ -19,31 +34,30 @@ export function shapeMatch(v1Data, v2Rules) {
|
|
|
19
34
|
return { data, summary }
|
|
20
35
|
}
|
|
21
36
|
|
|
22
|
-
function walk(rule, v1, summary) {
|
|
37
|
+
function walk(rule, v1, summary, inRow = false) {
|
|
23
38
|
if (typeof rule === 'string') {
|
|
24
39
|
if (rule.endsWith('[]')) {
|
|
25
|
-
if (!Array.isArray(v1)) return undefined
|
|
40
|
+
if (!Array.isArray(v1)) return inRow ? [] : undefined
|
|
26
41
|
summary.listItems += v1.length
|
|
27
42
|
summary.carriedOver += v1.length
|
|
28
43
|
return v1
|
|
29
44
|
}
|
|
30
|
-
|
|
31
|
-
//
|
|
32
|
-
|
|
33
|
-
if (typeof v1 === 'object') return undefined
|
|
45
|
+
// A scalar slot can't hold an object/array from v1 (collectDiscards counts
|
|
46
|
+
// the dropped leaves), so it is treated as a slot v1 has no value for.
|
|
47
|
+
if (v1 == null || typeof v1 === 'object') return inRow && !readOnly(rule) ? null : undefined
|
|
34
48
|
summary.carriedOver++
|
|
35
49
|
return v1
|
|
36
50
|
}
|
|
37
51
|
if (Array.isArray(rule)) {
|
|
38
52
|
const [, shape] = rule
|
|
39
|
-
if (!Array.isArray(v1)) return undefined
|
|
53
|
+
if (!Array.isArray(v1)) return inRow ? [] : undefined
|
|
40
54
|
summary.listItems += v1.length
|
|
41
|
-
return v1.map((item) => walk(shape, item, summary))
|
|
55
|
+
return v1.map((item) => walk(shape, item, summary, true))
|
|
42
56
|
}
|
|
43
57
|
if (typeof rule === 'object' && rule !== null) {
|
|
44
58
|
const out = {}
|
|
45
59
|
for (const [k, sub] of Object.entries(rule)) {
|
|
46
|
-
const v = walk(sub, v1 == null ? undefined : v1[k], summary)
|
|
60
|
+
const v = walk(sub, v1 == null ? undefined : v1[k], summary, inRow)
|
|
47
61
|
if (v !== undefined) out[k] = v
|
|
48
62
|
}
|
|
49
63
|
return out
|
|
@@ -51,6 +65,11 @@ function walk(rule, v1, summary) {
|
|
|
51
65
|
return undefined
|
|
52
66
|
}
|
|
53
67
|
|
|
68
|
+
function readOnly(rule) {
|
|
69
|
+
const at = ruleAttrIndex(rule)
|
|
70
|
+
return at !== -1 && DOM_PROPERTIES_READ_ONLY_SET.has(rule.slice(at + 1))
|
|
71
|
+
}
|
|
72
|
+
|
|
54
73
|
function collectDiscards(rule, v1, summary) {
|
|
55
74
|
if (v1 == null) return
|
|
56
75
|
if (typeof rule === 'object' && rule !== null && !Array.isArray(rule)) {
|
package/src/write.js
CHANGED
|
@@ -46,7 +46,7 @@ export function writeDocument(load, src, data, options = {}) {
|
|
|
46
46
|
// pays for the field it wrote, not for every rule in the document.
|
|
47
47
|
const written = writtenRules(rules, data)
|
|
48
48
|
const writtenRoot = load(html).root()
|
|
49
|
-
assertRoundTrip(written, data, extract(cheerioAdapter, writtenRoot, written, opts))
|
|
49
|
+
assertRoundTrip(written, data, extract(cheerioAdapter, writtenRoot, written, { ...opts, allMatches: true }))
|
|
50
50
|
return { html, changed: html !== src, spliced }
|
|
51
51
|
}
|
|
52
52
|
|