hyper-html-api 0.11.3 → 0.12.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 +3 -1
- package/packed-contract.json +1 -1
- package/src/page.js +343 -0
package/package.json
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"apply",
|
|
10
10
|
"CMS"
|
|
11
11
|
],
|
|
12
|
-
"version": "0.
|
|
12
|
+
"version": "0.12.0",
|
|
13
13
|
"homepage": "https://github.com/panphora/hyper-html-api",
|
|
14
14
|
"bugs": {
|
|
15
15
|
"url": "https://github.com/panphora/hyper-html-api/issues"
|
|
@@ -34,6 +34,7 @@
|
|
|
34
34
|
"./dom": "./src/adapters/dom.js",
|
|
35
35
|
"./data": "./src/data.js",
|
|
36
36
|
"./upgrade": "./src/upgrade.js",
|
|
37
|
+
"./page": "./src/page.js",
|
|
37
38
|
"./write": "./src/write.js"
|
|
38
39
|
},
|
|
39
40
|
"scripts": {
|
|
@@ -67,6 +68,7 @@
|
|
|
67
68
|
"hyper-morph": "^1.6.3"
|
|
68
69
|
},
|
|
69
70
|
"devDependencies": {
|
|
71
|
+
"@panphora/clayjs": "^1.13.1",
|
|
70
72
|
"@web/test-runner": "^0.20.0",
|
|
71
73
|
"@web/test-runner-playwright": "^0.11.0",
|
|
72
74
|
"chai": "^4.3.6",
|
package/packed-contract.json
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"load":["hyper-html-api","hyper-html-api/cheerio","hyper-html-api/cms","hyper-html-api/data","hyper-html-api/dom","hyper-html-api/engine","hyper-html-api/upgrade","hyper-html-api/write"]}
|
|
1
|
+
{"load":["hyper-html-api","hyper-html-api/cheerio","hyper-html-api/cms","hyper-html-api/data","hyper-html-api/dom","hyper-html-api/engine","hyper-html-api/page","hyper-html-api/upgrade","hyper-html-api/write"]}
|
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
|
+
}
|