hyper-html-api 0.11.3 → 0.13.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.
Files changed (47) hide show
  1. package/conformance/MANIFEST.json +2 -2
  2. package/conformance/cases/stylevar-read.expected.json +12 -0
  3. package/conformance/cases/stylevar-read.html +7 -0
  4. package/conformance/cases/stylevar-read.meta +6 -0
  5. package/conformance/cases/stylevar-read.parsed.json +12 -0
  6. package/conformance/cases/stylevar-read.rules +1 -0
  7. package/conformance/cases/write-refused-raw-style.data.json +1 -0
  8. package/conformance/cases/write-refused-raw-style.error.json +11 -0
  9. package/conformance/cases/write-refused-raw-style.html +1 -0
  10. package/conformance/cases/write-refused-raw-style.meta +4 -0
  11. package/conformance/cases/write-refused-stylevar-values.data.json +1 -0
  12. package/conformance/cases/write-refused-stylevar-values.error.json +43 -0
  13. package/conformance/cases/write-refused-stylevar-values.html +1 -0
  14. package/conformance/cases/write-refused-stylevar-values.meta +5 -0
  15. package/conformance/cases/write-stylevar-clear.after.html +1 -0
  16. package/conformance/cases/write-stylevar-clear.after.json +4 -0
  17. package/conformance/cases/write-stylevar-clear.data.json +1 -0
  18. package/conformance/cases/write-stylevar-clear.html +1 -0
  19. package/conformance/cases/write-stylevar-clear.meta +5 -0
  20. package/conformance/cases/write-stylevar-clear.write.json +4 -0
  21. package/conformance/cases/write-stylevar-set.after.html +2 -0
  22. package/conformance/cases/write-stylevar-set.after.json +5 -0
  23. package/conformance/cases/write-stylevar-set.data.json +1 -0
  24. package/conformance/cases/write-stylevar-set.html +1 -0
  25. package/conformance/cases/write-stylevar-set.meta +6 -0
  26. package/conformance/cases/write-stylevar-set.write.json +4 -0
  27. package/conformance/cases/write-stylevar-unchanged.after.html +1 -0
  28. package/conformance/cases/write-stylevar-unchanged.after.json +3 -0
  29. package/conformance/cases/write-stylevar-unchanged.data.json +1 -0
  30. package/conformance/cases/write-stylevar-unchanged.html +1 -0
  31. package/conformance/cases/write-stylevar-unchanged.meta +5 -0
  32. package/conformance/cases/write-stylevar-unchanged.write.json +4 -0
  33. package/conformance/write-policy.json +23 -1
  34. package/dist/hyper-html-api.engine.esm.js +1 -1
  35. package/dist/hyper-html-api.engine.min.js +1 -1
  36. package/dist/hyper-html-api.min.js +8 -8
  37. package/dist/hyper-html-api.upgrade.min.js +2 -2
  38. package/package.json +4 -2
  39. package/packed-contract.json +1 -1
  40. package/src/engine/apply.js +16 -0
  41. package/src/engine/extract.js +2 -0
  42. package/src/engine/index.js +2 -1
  43. package/src/engine/round-trip.js +5 -0
  44. package/src/engine/style-vars.js +141 -0
  45. package/src/engine/write-policy.js +60 -0
  46. package/src/page.js +343 -0
  47. package/src/upgrade/run.js +30 -0
package/src/page.js ADDED
@@ -0,0 +1,343 @@
1
+ import cheerioAdapter from './adapters/cheerio.js'
2
+ import { extract } from './engine/extract.js'
3
+ import { findRulesIn } from './engine/rules-tag.js'
4
+ import { NoRulesTag, RulesParseError } from './engine/errors.js'
5
+ import { parseRelaxed } from './engine/rules.js'
6
+ import { writeDocument } from './write.js'
7
+
8
+ const PAGE = /^page(?:\.[0-9]+)?$/
9
+ const TEXT = new Set(['paragraph', 'heading', 'list_item', 'callout', 'code_block', 'toggle'])
10
+ const MARKS = ['b', 'i', 's', 'c', 'a']
11
+
12
+ export class PageWriteRefused extends Error {
13
+ constructor(code, message) {
14
+ super(message)
15
+ this.name = 'PageWriteRefused'
16
+ this.code = code
17
+ }
18
+ }
19
+
20
+ function refuse(message) {
21
+ throw new PageWriteRefused('invalid-content', message)
22
+ }
23
+
24
+ function pageNodes($) {
25
+ const tags = $('script[merge][clay~="live"]').filter((_, node) => PAGE.test($(node).attr('merge')))
26
+ if (!tags.length) return null
27
+ const articles = $('article[data-page]')
28
+ if (tags.length !== 1 || articles.length !== 1) refuse('Expected one page store and one page article')
29
+ return { tag: tags[0], article: articles[0] }
30
+ }
31
+
32
+ function valueOf($, tag) {
33
+ let value
34
+ try {
35
+ value = parseRelaxed($(tag).text())
36
+ } catch {
37
+ refuse('Cannot parse the page store')
38
+ }
39
+ if (!value || typeof value !== 'object' || Array.isArray(value) || !value.y) {
40
+ refuse('Expected a Yjs page store')
41
+ }
42
+ return value
43
+ }
44
+
45
+ function projectionOf(load, blocks, projection) {
46
+ const clean = (html, strip) => {
47
+ const $ = load(`<template>${html}</template>`)
48
+ const body = $('template')
49
+ const content = body.contents()
50
+ if (strip) {
51
+ content.find('script').remove()
52
+ content.find('*').each((_, node) => {
53
+ for (const [name, value] of Object.entries(node.attribs || {})) {
54
+ if (/^on/i.test(name) || (/^(href|src|action|formaction|xlink:href)$/i.test(name) && /^(javascript|vbscript|data):/i.test(value.replace(/[\u0000-\u0020]/g, '')))) {
55
+ $(node).removeAttr(name)
56
+ }
57
+ }
58
+ })
59
+ }
60
+ return body.html()
61
+ }
62
+ return projection.renderBlocks(blocks, {
63
+ fallbackOf: id => typeof blocks[id]?.artifact?.fallbackHtml === 'string' ? blocks[id].artifact.fallbackHtml : '',
64
+ clean: { preserved: html => clean(html, false), fallback: html => clean(html, true) },
65
+ })
66
+ }
67
+
68
+ function splice(src, replacements) {
69
+ let html = src
70
+ for (const { node, text } of replacements.sort((a, b) => (b.node.sourceCodeLocation?.startOffset ?? -1) - (a.node.sourceCodeLocation?.startOffset ?? -1))) {
71
+ const location = node.sourceCodeLocation
72
+ if (location?.startOffset == null || location.endOffset == null) refuse('Page source has no stable location')
73
+ html = html.slice(0, location.startOffset) + text + html.slice(location.endOffset)
74
+ }
75
+ return html
76
+ }
77
+
78
+ function withArticle(load, src, projection) {
79
+ const $ = load(src, { sourceCodeLocationInfo: true })
80
+ const nodes = pageNodes($)
81
+ const article = $(nodes.article)
82
+ article.html(projection.html)
83
+ article.attr('data-rev', projection.rev)
84
+ return splice(src, [{ node: nodes.article, text: $.html(nodes.article) }])
85
+ }
86
+
87
+ function rulesOf($, options) {
88
+ if (Object.prototype.hasOwnProperty.call(options, 'rules')) {
89
+ const rules = options.rules
90
+ if (rules === null || !['string', 'object'].includes(typeof rules)) {
91
+ throw new RulesParseError('Invalid extraction rules: expected a selector, array, or object.')
92
+ }
93
+ return rules
94
+ }
95
+ const token = options.token ?? 'api'
96
+ const found = findRulesIn(cheerioAdapter, $.root(), token)
97
+ if (!found) throw new NoRulesTag(token)
98
+ return found.rules
99
+ }
100
+
101
+ function projectData(load, html, options) {
102
+ const $ = load(html)
103
+ return extract(cheerioAdapter, $.root(), rulesOf($, options))
104
+ }
105
+
106
+ function markKey(marks) {
107
+ return MARKS.map(code => marks[code] ? code + (Array.isArray(marks[code]) ? JSON.stringify(marks[code]) : '') : '').join('')
108
+ }
109
+
110
+ function contentOf(node, type, projection) {
111
+ const { ATOM, BREAK, normalizeText, runsOf } = projection
112
+ if (type === 'code_block') {
113
+ const code = node.children?.find(child => child.name === 'code')
114
+ const text = (code || node).children?.map(child => child.data ?? '').join('') ?? ''
115
+ return { c: text }
116
+ }
117
+ const root = type === 'toggle' ? node.children?.find(child => child.name === 'summary') : node
118
+ if (!root) refuse('A page text block lost its content element')
119
+ let c = ''
120
+ const units = []
121
+ const walk = (parent, marks) => {
122
+ for (const child of parent.children || []) {
123
+ if (type === 'list_item' && (child.name === 'input' || child.name === 'ul' || child.name === 'ol')) continue
124
+ if (child.type === 'text') {
125
+ c += child.data
126
+ units.push(...Array(child.data.length).fill(markKey(marks)))
127
+ continue
128
+ }
129
+ if (child.type !== 'tag') continue
130
+ if (child.name === 'br' || child.name === 'span' && child.attribs?.['data-atom']) {
131
+ c += ATOM
132
+ units.push(child.name === 'br' ? BREAK : child.attribs['data-atom'])
133
+ continue
134
+ }
135
+ const next = { ...marks }
136
+ if (child.name === 'strong' || child.name === 'b') next.b = true
137
+ else if (child.name === 'em' || child.name === 'i') next.i = true
138
+ else if (['s', 'del', 'strike'].includes(child.name)) next.s = true
139
+ else if (child.name === 'code') next.c = true
140
+ else if (child.name === 'a' && child.attribs?.href) next.a = [child.attribs.href]
141
+ walk(child, next)
142
+ }
143
+ }
144
+ walk(root, {})
145
+ const normalized = normalizeText(c, units)
146
+ const content = { c: normalized.c }
147
+ if (normalized.units.some(Boolean)) content.m = runsOf(normalized.units)
148
+ return content
149
+ }
150
+
151
+ function attrsOf($, node, type, stored) {
152
+ const el = $(node)
153
+ const attr = name => el.attr(name) ?? null
154
+ if (type === 'heading') return { level: Number(node.name.slice(1)) }
155
+ if (type === 'code_block') return { lang: attr('data-lang') ?? '' }
156
+ if (type === 'toggle') return { open: attr('open') !== null }
157
+ if (type === 'list_item') return {
158
+ checked: el.children('input[type="checkbox"]').attr('checked') !== undefined,
159
+ owner: attr('data-owner'), due: attr('data-due'),
160
+ }
161
+ if (type === 'image') {
162
+ const image = el.find('img').first()
163
+ const width = image.attr('width')
164
+ return {
165
+ src: image.attr('src') ?? '', alt: image.attr('alt') ?? '',
166
+ caption: el.children('figcaption').text(),
167
+ width: width === undefined ? null : Number(width), align: attr('data-align'),
168
+ }
169
+ }
170
+ if (type === 'file') {
171
+ const size = attr('data-size')
172
+ return { src: attr('data-src') ?? '', name: attr('data-name') ?? '', size: size === null ? null : Number(size), mime: attr('data-mime') }
173
+ }
174
+ if (type === 'chart') {
175
+ let series
176
+ try { series = JSON.parse(attr('data-series') ?? '[]') } catch { refuse('Invalid chart series') }
177
+ return {
178
+ source: attr('data-source'), kind: attr('data-kind') ?? 'line', x: attr('data-x'),
179
+ series, title: el.children('figcaption').text(), highlight: attr('data-highlight'),
180
+ }
181
+ }
182
+ if (type === 'table') {
183
+ let sort
184
+ try { sort = JSON.parse(attr('data-sort') ?? 'null') } catch { refuse('Invalid table sort') }
185
+ const cols = { ...stored.cols }
186
+ const headers = el.find('thead tr').first().children('th')
187
+ headers.each((_, th) => {
188
+ const id = $(th).attr('data-col')
189
+ if (!Object.hasOwn(cols, id)) return
190
+ const column = { ...cols[id], name: $(th).text() }
191
+ if ($(th).attr('data-kind') === undefined) delete column.kind
192
+ else column.kind = $(th).attr('data-kind')
193
+ cols[id] = column
194
+ })
195
+ const rows = { ...stored.rows }
196
+ el.find('tbody tr').each((_, tr) => {
197
+ const id = $(tr).attr('data-id')
198
+ if (!Object.hasOwn(rows, id)) return
199
+ const cells = { ...rows[id].cells }
200
+ $(tr).children('td').each((index, td) => {
201
+ const col = headers.eq(index).attr('data-col')
202
+ if (Object.hasOwn(cols, col)) cells[col] = $(td).text()
203
+ })
204
+ rows[id] = { ...rows[id], cells }
205
+ })
206
+ return { cols, rows, sort }
207
+ }
208
+ if (type === 'board') {
209
+ const columns = { ...stored.columns }
210
+ const cards = { ...stored.cards }
211
+ el.children('div[data-col]').each((_, div) => {
212
+ const col = $(div).attr('data-col')
213
+ if (Object.hasOwn(columns, col)) columns[col] = { ...columns[col], name: $(div).children('h4').text() }
214
+ $(div).find('ul > li[data-id]').each((_, li) => {
215
+ const id = $(li).attr('data-id')
216
+ if (!Object.hasOwn(cards, id)) return
217
+ cards[id] = {
218
+ ...cards[id], col, title: $(li).text(),
219
+ owner: $(li).attr('data-owner') ?? null, due: $(li).attr('data-due') ?? null,
220
+ }
221
+ })
222
+ })
223
+ return { columns, cards }
224
+ }
225
+ return {}
226
+ }
227
+
228
+ function blockNode($, article, id) {
229
+ const matches = $(article).find('[data-id]').filter((_, node) => $(node).attr('data-id') === id)
230
+ if (matches.length !== 1) refuse(`Page block ${id} is missing or duplicated`)
231
+ return matches[0]
232
+ }
233
+
234
+ function pageChanges($, article, baseline$, baselineArticle, blocks, model, projection) {
235
+ const { canon } = projection
236
+ const changes = []
237
+ for (const block of model) {
238
+ const node = blockNode($, article, block.id)
239
+ const old = blockNode(baseline$, baselineArticle, block.id)
240
+ const change = { id: block.id }
241
+ if (TEXT.has(block.t)) {
242
+ const content = contentOf(node, block.t, projection)
243
+ if (canon(content) !== canon(contentOf(old, block.t, projection))) change.content = content
244
+ }
245
+ const attrs = attrsOf($, node, block.t, blocks[block.id])
246
+ const previous = attrsOf(baseline$, old, block.t, blocks[block.id])
247
+ const changedAttrs = Object.fromEntries(Object.keys(attrs).filter(key => canon(attrs[key]) !== canon(previous[key])).map(key => [key, attrs[key]]))
248
+ if (Object.keys(changedAttrs).length) change.attrs = changedAttrs
249
+ if (change.content || change.attrs) changes.push(change)
250
+ }
251
+ return changes
252
+ }
253
+
254
+ function sameArticle($, article, projected) {
255
+ const expected = $(article).clone()
256
+ expected.html(projected.html)
257
+ return $(article).html() === expected.html()
258
+ }
259
+
260
+ function proposedBlocks(blocks, changes) {
261
+ const proposed = { ...blocks }
262
+ for (const { id, content, attrs } of changes) {
263
+ const block = { ...blocks[id], ...attrs }
264
+ if (content) {
265
+ block.c = content.c
266
+ delete block.m
267
+ if (content.m) block.m = content.m
268
+ }
269
+ proposed[id] = block
270
+ }
271
+ return proposed
272
+ }
273
+
274
+ function codecError(error) {
275
+ if (error?.code === 'missing-revision') return new PageWriteRefused('revision-required', error.message)
276
+ if (error?.code === 'stale-revision') return new PageWriteRefused('revision-stale', error.message)
277
+ if (error?.name === 'YDecodeError') {
278
+ return new PageWriteRefused('invalid-content', error.message)
279
+ }
280
+ return error
281
+ }
282
+
283
+ export async function readPageDocument(load, html, options = {}) {
284
+ const $ = load(html, { sourceCodeLocationInfo: true })
285
+ const nodes = pageNodes($)
286
+ if (!nodes) return { data: projectData(load, html, options), blocks: null, revision: null }
287
+ const projection = options.codec?.projection
288
+ if (!projection) throw new TypeError('A page codec.projection is required')
289
+ if (typeof options.codec?.readYValue !== 'function') refuse('A page codec is required')
290
+ let page
291
+ try { page = await options.codec.readYValue(valueOf($, nodes.tag)) } catch (error) { throw codecError(error) }
292
+ const aligned = withArticle(load, html, projectionOf(load, page.blocks, projection))
293
+ return { data: projectData(load, aligned, options), blocks: page.blocks, revision: page.revision }
294
+ }
295
+
296
+ export async function writePageDocument(load, html, data, options = {}) {
297
+ const $ = load(html, { sourceCodeLocationInfo: true })
298
+ const nodes = pageNodes($)
299
+ if (!nodes) return writeDocument(load, html, data, options)
300
+ const projection = options.codec?.projection
301
+ if (!projection) throw new TypeError('A page codec.projection is required')
302
+ if (typeof options.codec?.readYValue !== 'function' || typeof options.codec?.writeYValue !== 'function') refuse('A page codec is required')
303
+ const { canon } = projection
304
+ if (typeof options.expectedRevision !== 'string' || !options.expectedRevision) {
305
+ throw new PageWriteRefused('revision-required', 'A page revision is required')
306
+ }
307
+ const value = valueOf($, nodes.tag)
308
+ let page
309
+ try { page = await options.codec.readYValue(value) } catch (error) { throw codecError(error) }
310
+ if (page.revision !== options.expectedRevision) {
311
+ throw new PageWriteRefused('revision-stale', 'The page revision has changed')
312
+ }
313
+ const before = projectionOf(load, page.blocks, projection)
314
+ const aligned = withArticle(load, html, before)
315
+ const candidate = writeDocument(load, aligned, data, options)
316
+ const next$ = load(candidate.html, { sourceCodeLocationInfo: true })
317
+ const nextNodes = pageNodes(next$)
318
+ const aligned$ = load(aligned)
319
+ const changes = pageChanges(next$, nextNodes.article, aligned$, aligned$('article[data-page]')[0], page.blocks, before.model, projection)
320
+ const unchangedAttrs = canon(next$(nextNodes.article).attr()) === canon(aligned$('article[data-page]').attr())
321
+ const proposed = projectionOf(load, proposedBlocks(page.blocks, changes), projection)
322
+ if (!unchangedAttrs || !sameArticle(next$, nextNodes.article, proposed)) {
323
+ refuse('The article edit cannot be represented in the page store')
324
+ }
325
+ let written = { value, blocks: page.blocks, revision: page.revision }
326
+ if (changes.length) {
327
+ try {
328
+ written = await options.codec.writeYValue(value, { expectedRevision: page.revision, changes })
329
+ } catch (error) { throw codecError(error) }
330
+ }
331
+ const after = projectionOf(load, written.blocks, projection)
332
+ if (!sameArticle(next$, nextNodes.article, after)) {
333
+ refuse('The article edit cannot be represented in the page store')
334
+ }
335
+ const article = next$(nextNodes.article)
336
+ article.html(after.html)
337
+ article.attr('data-rev', after.rev)
338
+ const tag = next$(nextNodes.tag)
339
+ if (changes.length) tag.text(JSON.stringify(written.value).replace(/</g, '\\u003c'))
340
+ const replacements = [{ node: nextNodes.article, text: next$.html(nextNodes.article) }]
341
+ if (changes.length) replacements.push({ node: nextNodes.tag, text: next$.html(nextNodes.tag) })
342
+ return { html: splice(candidate.html, replacements), revision: written.revision }
343
+ }
@@ -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
  }