hyper-html-api 0.1.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/LICENSE +13 -0
- package/README.md +51 -0
- package/dist/hyper-html-api.min.js +2 -0
- package/package.json +68 -0
- package/src/adapters/cheerio.js +93 -0
- package/src/adapters/dom.js +83 -0
- package/src/adapters/index.js +2 -0
- package/src/engine/apply.js +139 -0
- package/src/engine/diff.js +129 -0
- package/src/engine/dom-properties.js +52 -0
- package/src/engine/errors.js +48 -0
- package/src/engine/extract.js +70 -0
- package/src/engine/index.js +6 -0
- package/src/engine/rules-tag.js +18 -0
- package/src/engine/rules.js +245 -0
- package/src/hyper-html-api.js +39 -0
- package/src/upgrade/helper.js +143 -0
- package/src/upgrade/index.js +4 -0
- package/src/upgrade/registry.js +19 -0
- package/src/upgrade/runner.js +92 -0
- package/src/upgrade/shape-match.js +87 -0
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
export class RulesParseError extends Error {
|
|
2
|
+
constructor(message, originalError) {
|
|
3
|
+
super(message)
|
|
4
|
+
this.name = 'RulesParseError'
|
|
5
|
+
this.cause = originalError
|
|
6
|
+
}
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export class UnknownRulesVersion extends Error {
|
|
10
|
+
constructor(version) {
|
|
11
|
+
super(`unknown rules version: ${version}. Library supports "1".`)
|
|
12
|
+
this.name = 'UnknownRulesVersion'
|
|
13
|
+
this.version = version
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export class MaxRuleDepthExceeded extends Error {
|
|
18
|
+
constructor(path) {
|
|
19
|
+
super(`rule depth exceeded 20 at path: ${path.join('.')}`)
|
|
20
|
+
this.name = 'MaxRuleDepthExceeded'
|
|
21
|
+
this.path = path
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export class ShapeMismatch extends Error {
|
|
26
|
+
constructor(mismatches) {
|
|
27
|
+
super(`shape mismatch: ${mismatches.length} field(s) failed validation`)
|
|
28
|
+
this.name = 'ShapeMismatch'
|
|
29
|
+
this.mismatches = mismatches
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export class EmptyListInsert extends Error {
|
|
34
|
+
constructor(path) {
|
|
35
|
+
super(
|
|
36
|
+
`cannot add items to empty list at "${path.join('.')}" — no sibling to clone as template. Seed the list with a hidden item first.`,
|
|
37
|
+
)
|
|
38
|
+
this.name = 'EmptyListInsert'
|
|
39
|
+
this.path = path
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export class UpgradeAlreadyRegistered extends Error {
|
|
44
|
+
constructor() {
|
|
45
|
+
super('upgrade transform already registered; only one registration is allowed per page.')
|
|
46
|
+
this.name = 'UpgradeAlreadyRegistered'
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { DOM_PROPERTIES_SET } from './dom-properties.js'
|
|
2
|
+
import { MaxRuleDepthExceeded } from './errors.js'
|
|
3
|
+
|
|
4
|
+
const MAX_DEPTH = 20
|
|
5
|
+
|
|
6
|
+
export function extract(adapter, root, rules) {
|
|
7
|
+
return extractAt(adapter, root, rules, { depth: 0, path: [] })
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function extractAt(adapter, ctx, rule, trace) {
|
|
11
|
+
if (trace.depth > MAX_DEPTH) throw new MaxRuleDepthExceeded(trace.path)
|
|
12
|
+
|
|
13
|
+
if (typeof rule === 'string') return extractScalar(adapter, ctx, rule)
|
|
14
|
+
|
|
15
|
+
if (Array.isArray(rule)) {
|
|
16
|
+
const [selector, shape] = rule
|
|
17
|
+
const matches = adapter.find(ctx, selector)
|
|
18
|
+
return matches.map((node, i) =>
|
|
19
|
+
extractAt(adapter, node, shape, {
|
|
20
|
+
depth: trace.depth + 1,
|
|
21
|
+
path: [...trace.path, i],
|
|
22
|
+
}),
|
|
23
|
+
)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (typeof rule === 'object' && rule !== null) {
|
|
27
|
+
const result = {}
|
|
28
|
+
for (const [key, sub] of Object.entries(rule)) {
|
|
29
|
+
result[key] = extractAt(adapter, ctx, sub, {
|
|
30
|
+
depth: trace.depth + 1,
|
|
31
|
+
path: [...trace.path, key],
|
|
32
|
+
})
|
|
33
|
+
}
|
|
34
|
+
return result
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return null
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function extractScalar(adapter, ctx, rule) {
|
|
41
|
+
if (rule.endsWith('[]')) {
|
|
42
|
+
const selector = rule.slice(0, -2)
|
|
43
|
+
return adapter.find(ctx, selector).map((n) => adapter.text(n))
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (rule.startsWith('@')) {
|
|
47
|
+
return readPropOrAttr(adapter, ctx, rule.slice(1))
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (rule.includes('@')) {
|
|
51
|
+
const [selector, name] = rule.split('@')
|
|
52
|
+
const matches = selector ? adapter.find(ctx, selector) : [ctx]
|
|
53
|
+
if (matches.length === 0) return null
|
|
54
|
+
return readPropOrAttr(adapter, matches[0], name)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (rule === '.') return adapter.text(ctx)
|
|
58
|
+
|
|
59
|
+
const matches = adapter.find(ctx, rule)
|
|
60
|
+
return matches.length === 0 ? null : adapter.text(matches[0])
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function readPropOrAttr(adapter, node, name) {
|
|
64
|
+
if (DOM_PROPERTIES_SET.has(name)) {
|
|
65
|
+
const v = adapter.prop(node, name)
|
|
66
|
+
return v == null ? null : String(v)
|
|
67
|
+
}
|
|
68
|
+
const v = adapter.attr(node, name)
|
|
69
|
+
return v ? v : null
|
|
70
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { extract } from './extract.js'
|
|
2
|
+
export { apply } from './apply.js'
|
|
3
|
+
export { findRulesIn } from './rules-tag.js'
|
|
4
|
+
export { parseStrict, parseRelaxed } from './rules.js'
|
|
5
|
+
export * as errors from './errors.js'
|
|
6
|
+
export { DOM_PROPERTIES, DOM_PROPERTIES_SET } from './dom-properties.js'
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { parseStrict } from './rules.js'
|
|
2
|
+
import { UnknownRulesVersion } from './errors.js'
|
|
3
|
+
|
|
4
|
+
const RULES_TAG_ID = 'hyper-html-api'
|
|
5
|
+
const SUPPORTED_VERSION = '1'
|
|
6
|
+
|
|
7
|
+
export function findRulesIn(adapter, root) {
|
|
8
|
+
const candidates = adapter.find(root, `script#${RULES_TAG_ID}`, { includeRulesTag: true })
|
|
9
|
+
if (candidates.length === 0) return null
|
|
10
|
+
|
|
11
|
+
const tagNode = candidates[0]
|
|
12
|
+
const version = adapter.attr(tagNode, 'data-rules-version')
|
|
13
|
+
if (version !== SUPPORTED_VERSION) throw new UnknownRulesVersion(version)
|
|
14
|
+
|
|
15
|
+
const body = adapter.text(tagNode)
|
|
16
|
+
const rules = parseStrict(body)
|
|
17
|
+
return { rules, tagNode }
|
|
18
|
+
}
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import { RulesParseError } from './errors.js'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Strict JSON parser for script-tag rules bodies.
|
|
5
|
+
*/
|
|
6
|
+
export function parseStrict(body) {
|
|
7
|
+
try {
|
|
8
|
+
return JSON.parse(body)
|
|
9
|
+
} catch (e) {
|
|
10
|
+
throw new RulesParseError(
|
|
11
|
+
'hyper-html-api script tags require strict JSON. ' +
|
|
12
|
+
'Relaxed JSON (unquoted keys, single quotes, trailing commas) is only supported in the ?data= URL parameter. ' +
|
|
13
|
+
`Original error: ${e.message}`,
|
|
14
|
+
e,
|
|
15
|
+
)
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Relaxed JSON parser for the ?data= URL parameter. Ported verbatim from
|
|
21
|
+
* the legacy hyperclay/server-lib/data-extractor.js parseExtractionRules()
|
|
22
|
+
* so existing URLs continue to parse identically.
|
|
23
|
+
*/
|
|
24
|
+
export function parseRelaxed(queryString) {
|
|
25
|
+
try {
|
|
26
|
+
return JSON.parse(queryString)
|
|
27
|
+
} catch (_) {
|
|
28
|
+
/* fall through to tokenizer */
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const TokenType = {
|
|
32
|
+
BRACE_OPEN: '{',
|
|
33
|
+
BRACE_CLOSE: '}',
|
|
34
|
+
BRACKET_OPEN: '[',
|
|
35
|
+
BRACKET_CLOSE: ']',
|
|
36
|
+
COLON: ':',
|
|
37
|
+
COMMA: ',',
|
|
38
|
+
STRING: 'STRING',
|
|
39
|
+
SELECTOR: 'SELECTOR',
|
|
40
|
+
IDENTIFIER: 'IDENTIFIER',
|
|
41
|
+
NUMBER: 'NUMBER',
|
|
42
|
+
BOOLEAN: 'BOOLEAN',
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function tokenize(input) {
|
|
46
|
+
const tokens = []
|
|
47
|
+
let i = 0
|
|
48
|
+
|
|
49
|
+
while (i < input.length) {
|
|
50
|
+
const char = input[i]
|
|
51
|
+
|
|
52
|
+
if (/\s/.test(char)) {
|
|
53
|
+
i++
|
|
54
|
+
continue
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if ('{}'.includes(char)) {
|
|
58
|
+
tokens.push({ type: char, value: char })
|
|
59
|
+
i++
|
|
60
|
+
continue
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (char === '[') {
|
|
64
|
+
let isAttributeSelector = false
|
|
65
|
+
let j = i + 1
|
|
66
|
+
|
|
67
|
+
while (j < input.length && /\s/.test(input[j])) j++
|
|
68
|
+
|
|
69
|
+
if (j < input.length && /[a-zA-Z_]/.test(input[j])) {
|
|
70
|
+
isAttributeSelector = true
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (!isAttributeSelector) {
|
|
74
|
+
tokens.push({ type: char, value: char })
|
|
75
|
+
i++
|
|
76
|
+
continue
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (char === ']') {
|
|
81
|
+
tokens.push({ type: char, value: char })
|
|
82
|
+
i++
|
|
83
|
+
continue
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (char === ':') {
|
|
87
|
+
tokens.push({ type: TokenType.COLON, value: char })
|
|
88
|
+
i++
|
|
89
|
+
continue
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (char === ',') {
|
|
93
|
+
tokens.push({ type: TokenType.COMMA, value: char })
|
|
94
|
+
i++
|
|
95
|
+
continue
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (char === '"' || char === "'") {
|
|
99
|
+
const quote = char
|
|
100
|
+
let j = i + 1
|
|
101
|
+
while (j < input.length && input[j] !== quote) {
|
|
102
|
+
if (input[j] === '\\') j++
|
|
103
|
+
j++
|
|
104
|
+
}
|
|
105
|
+
tokens.push({
|
|
106
|
+
type: TokenType.STRING,
|
|
107
|
+
value: input.substring(i + 1, j),
|
|
108
|
+
quoted: true,
|
|
109
|
+
})
|
|
110
|
+
i = j + 1
|
|
111
|
+
continue
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
let j = i
|
|
115
|
+
let value
|
|
116
|
+
|
|
117
|
+
while (j < input.length && !/[{},]/.test(input[j])) {
|
|
118
|
+
if (input[j] === ':') {
|
|
119
|
+
const pseudoSelectors = [
|
|
120
|
+
':first',
|
|
121
|
+
':last',
|
|
122
|
+
':nth-child',
|
|
123
|
+
':nth-of-type',
|
|
124
|
+
':first-child',
|
|
125
|
+
':last-child',
|
|
126
|
+
':first-of-type',
|
|
127
|
+
':last-of-type',
|
|
128
|
+
':only-child',
|
|
129
|
+
':only-of-type',
|
|
130
|
+
':hover',
|
|
131
|
+
':focus',
|
|
132
|
+
':active',
|
|
133
|
+
':visited',
|
|
134
|
+
':disabled',
|
|
135
|
+
':enabled',
|
|
136
|
+
':checked',
|
|
137
|
+
':empty',
|
|
138
|
+
':root',
|
|
139
|
+
':target',
|
|
140
|
+
':not',
|
|
141
|
+
':before',
|
|
142
|
+
':after',
|
|
143
|
+
':nth-last-child',
|
|
144
|
+
':nth-last-of-type',
|
|
145
|
+
]
|
|
146
|
+
|
|
147
|
+
let isPseudoSelector = false
|
|
148
|
+
for (const pseudo of pseudoSelectors) {
|
|
149
|
+
const pseudoName = pseudo.substring(1)
|
|
150
|
+
const afterColon = input.substring(j + 1, j + 1 + pseudoName.length)
|
|
151
|
+
if (afterColon === pseudoName) {
|
|
152
|
+
isPseudoSelector = true
|
|
153
|
+
j += pseudoName.length
|
|
154
|
+
break
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (!isPseudoSelector) break
|
|
159
|
+
} else if (input[j] === '[') {
|
|
160
|
+
j++
|
|
161
|
+
while (j < input.length && input[j] !== ']') {
|
|
162
|
+
if (input[j] === '"' || input[j] === "'") {
|
|
163
|
+
const quote = input[j]
|
|
164
|
+
j++
|
|
165
|
+
while (j < input.length && input[j] !== quote) {
|
|
166
|
+
if (input[j] === '\\') j++
|
|
167
|
+
j++
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
j++
|
|
171
|
+
}
|
|
172
|
+
if (j < input.length && input[j] === ']') j++
|
|
173
|
+
} else {
|
|
174
|
+
j++
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
value = input.substring(i, j)
|
|
178
|
+
|
|
179
|
+
let type = TokenType.IDENTIFIER
|
|
180
|
+
|
|
181
|
+
if (/^-?\d+(\.\d+)?$/.test(value)) {
|
|
182
|
+
type = TokenType.NUMBER
|
|
183
|
+
} else if (value === 'true' || value === 'false' || value === 'null') {
|
|
184
|
+
type = TokenType.BOOLEAN
|
|
185
|
+
} else if (/^[.#@\[]|[.#@\[]| /.test(value)) {
|
|
186
|
+
type = TokenType.SELECTOR
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
tokens.push({ type, value, quoted: false })
|
|
190
|
+
i = j
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return tokens
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function tokensToJSON(tokens) {
|
|
197
|
+
let result = ''
|
|
198
|
+
|
|
199
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
200
|
+
const token = tokens[i]
|
|
201
|
+
|
|
202
|
+
if ('{}'.includes(token.type) || '[]'.includes(token.type)) {
|
|
203
|
+
result += token.value
|
|
204
|
+
continue
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (token.type === TokenType.COLON) {
|
|
208
|
+
result += token.value
|
|
209
|
+
continue
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (token.type === TokenType.COMMA) {
|
|
213
|
+
result += token.value
|
|
214
|
+
continue
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (token.type === TokenType.STRING && token.quoted) {
|
|
218
|
+
result += `"${token.value}"`
|
|
219
|
+
continue
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
if (token.type === TokenType.NUMBER || token.type === TokenType.BOOLEAN) {
|
|
223
|
+
result += token.value
|
|
224
|
+
continue
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (token.type === TokenType.SELECTOR || token.type === TokenType.IDENTIFIER) {
|
|
228
|
+
result += `"${token.value}"`
|
|
229
|
+
continue
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
result += `"${token.value}"`
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
return result
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
try {
|
|
239
|
+
const tokens = tokenize(queryString)
|
|
240
|
+
const jsonString = tokensToJSON(tokens)
|
|
241
|
+
return JSON.parse(jsonString)
|
|
242
|
+
} catch (error) {
|
|
243
|
+
throw new RulesParseError('Invalid extraction rules syntax: ' + error.message, error)
|
|
244
|
+
}
|
|
245
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import * as engineMod from './engine/index.js'
|
|
2
|
+
import * as upgradeMod from './upgrade/index.js'
|
|
3
|
+
import domAdapter from './adapters/dom.js'
|
|
4
|
+
|
|
5
|
+
const engine = {
|
|
6
|
+
extract: (root, rules) => engineMod.extract(domAdapter, root, rules),
|
|
7
|
+
apply: (root, rules, data) => engineMod.apply(domAdapter, root, rules, data),
|
|
8
|
+
findRulesIn: (root) => engineMod.findRulesIn(domAdapter, root),
|
|
9
|
+
parseStrict: engineMod.parseStrict,
|
|
10
|
+
parseRelaxed: engineMod.parseRelaxed,
|
|
11
|
+
errors: engineMod.errors,
|
|
12
|
+
DOM_PROPERTIES: engineMod.DOM_PROPERTIES,
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const cms = {}
|
|
16
|
+
|
|
17
|
+
const upgrade = {
|
|
18
|
+
registerUpgrade: upgradeMod.registerUpgrade,
|
|
19
|
+
run: upgradeMod.run,
|
|
20
|
+
shapeMatch: upgradeMod.shapeMatch,
|
|
21
|
+
isHelperMode: upgradeMod.isHelperMode,
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Helper-mode auto-boot: when the library loads inside an iframe whose URL
|
|
25
|
+
// carries `?_hyperHtmlApi=upgrade-helper`, we wire up the postMessage handshake
|
|
26
|
+
// immediately so authors don't have to add any boot code themselves. Their
|
|
27
|
+
// `registerUpgrade(...)` calls (which run inline as v2's scripts execute) are
|
|
28
|
+
// captured by the registry before the handshake message fires.
|
|
29
|
+
if (typeof window !== 'undefined' && upgradeMod.isHelperMode(window.location)) {
|
|
30
|
+
upgradeMod.bootHelper({ win: window, doc: document })
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Named exports ensure the IIFE namespace exposes engine/cms/upgrade as direct
|
|
34
|
+
// properties on the `HyperHtmlApi` global, rather than nesting them under a
|
|
35
|
+
// `.default` key the way `export default` alone would.
|
|
36
|
+
export { engine, cms, upgrade }
|
|
37
|
+
|
|
38
|
+
const HyperHtmlApi = { engine, cms, upgrade }
|
|
39
|
+
export default HyperHtmlApi
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Helper-mode boot path. When the library loads in an iframe whose URL
|
|
3
|
+
* includes `?_hyperHtmlApi=upgrade-helper&parentOrigin=...`, it skips its
|
|
4
|
+
* normal idle state and instead participates in the postMessage protocol
|
|
5
|
+
* defined in upgrade-plan.md:
|
|
6
|
+
*
|
|
7
|
+
* 1. Once the iframe document is ready, post `hha:upgrade-ready` to
|
|
8
|
+
* `parentOrigin` with the v2 rules + version + whether a transform
|
|
9
|
+
* was registered.
|
|
10
|
+
* 2. Wait for the parent's `hha:upgrade-data` message carrying v1Data.
|
|
11
|
+
* 3. Run the registered transform (if any), shape-match against v2's
|
|
12
|
+
* rules, then `engine.apply` to the iframe DOM.
|
|
13
|
+
* 4. Serialize `documentElement.outerHTML` and post `hha:upgrade-result`
|
|
14
|
+
* back to the parent.
|
|
15
|
+
*
|
|
16
|
+
* Errors at any step bubble out as `hha:upgrade-error`.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { findRulesIn } from '../engine/rules-tag.js'
|
|
20
|
+
import { extract } from '../engine/extract.js'
|
|
21
|
+
import { apply } from '../engine/apply.js'
|
|
22
|
+
import domAdapter from '../adapters/dom.js'
|
|
23
|
+
import { getRegisteredTransform } from './registry.js'
|
|
24
|
+
import { shapeMatch } from './shape-match.js'
|
|
25
|
+
|
|
26
|
+
const HELPER_QUERY = '_hyperHtmlApi'
|
|
27
|
+
const HELPER_VALUE = 'upgrade-helper'
|
|
28
|
+
const PARENT_ORIGIN_QUERY = 'parentOrigin'
|
|
29
|
+
|
|
30
|
+
export function isHelperMode(loc = typeof location !== 'undefined' ? location : null) {
|
|
31
|
+
if (!loc) return false
|
|
32
|
+
try {
|
|
33
|
+
const params = new URLSearchParams(loc.search)
|
|
34
|
+
return params.get(HELPER_QUERY) === HELPER_VALUE
|
|
35
|
+
} catch {
|
|
36
|
+
return false
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function getParentOrigin(loc = typeof location !== 'undefined' ? location : null) {
|
|
41
|
+
if (!loc) return null
|
|
42
|
+
const params = new URLSearchParams(loc.search)
|
|
43
|
+
return params.get(PARENT_ORIGIN_QUERY)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function bootHelper({ win, doc, parentOrigin } = {}) {
|
|
47
|
+
win = win || (typeof window !== 'undefined' ? window : null)
|
|
48
|
+
doc = doc || (typeof document !== 'undefined' ? document : null)
|
|
49
|
+
if (!win || !doc) return
|
|
50
|
+
parentOrigin = parentOrigin || getParentOrigin(win.location)
|
|
51
|
+
if (!parentOrigin) return
|
|
52
|
+
|
|
53
|
+
const start = () => runHandshake({ win, doc, parentOrigin })
|
|
54
|
+
|
|
55
|
+
if (doc.readyState === 'loading') {
|
|
56
|
+
doc.addEventListener('DOMContentLoaded', start, { once: true })
|
|
57
|
+
} else {
|
|
58
|
+
start()
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function runHandshake({ win, doc, parentOrigin }) {
|
|
63
|
+
let found
|
|
64
|
+
try {
|
|
65
|
+
found = findRulesIn(domAdapter, doc.body)
|
|
66
|
+
} catch (e) {
|
|
67
|
+
return postError(win, parentOrigin, e)
|
|
68
|
+
}
|
|
69
|
+
if (!found) {
|
|
70
|
+
return postError(
|
|
71
|
+
win,
|
|
72
|
+
parentOrigin,
|
|
73
|
+
new Error('helper-mode: no rules tag in v2 document'),
|
|
74
|
+
)
|
|
75
|
+
}
|
|
76
|
+
const rules = found.rules
|
|
77
|
+
const version = readMeta(doc, 'hyper-version')
|
|
78
|
+
const hasTransform = !!getRegisteredTransform()
|
|
79
|
+
|
|
80
|
+
const onMessage = (e) => {
|
|
81
|
+
if (e.source !== win.parent) return
|
|
82
|
+
if (e.origin !== parentOrigin) return
|
|
83
|
+
const msg = e.data
|
|
84
|
+
if (!msg || msg.type !== 'hha:upgrade-data') return
|
|
85
|
+
win.removeEventListener('message', onMessage)
|
|
86
|
+
try {
|
|
87
|
+
const result = runUpgrade({ doc, rules, v1Data: msg.v1Data })
|
|
88
|
+
win.parent.postMessage(
|
|
89
|
+
{ type: 'hha:upgrade-result', html: result.html, summary: result.summary },
|
|
90
|
+
parentOrigin,
|
|
91
|
+
)
|
|
92
|
+
} catch (err) {
|
|
93
|
+
postError(win, parentOrigin, err)
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
win.addEventListener('message', onMessage)
|
|
97
|
+
|
|
98
|
+
win.parent.postMessage(
|
|
99
|
+
{ type: 'hha:upgrade-ready', rules, version, hasTransform },
|
|
100
|
+
parentOrigin,
|
|
101
|
+
)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function runUpgrade({ doc, rules, v1Data }) {
|
|
105
|
+
const transform = getRegisteredTransform()
|
|
106
|
+
let transformed = v1Data
|
|
107
|
+
let didTransform = false
|
|
108
|
+
if (transform) {
|
|
109
|
+
transformed = transform(v1Data)
|
|
110
|
+
didTransform = true
|
|
111
|
+
}
|
|
112
|
+
const { data, summary } = shapeMatch(transformed, rules)
|
|
113
|
+
apply(domAdapter, doc.body, rules, data)
|
|
114
|
+
// Re-extract to confirm the apply landed; surface as part of summary.
|
|
115
|
+
const applied = extract(domAdapter, doc.body, rules)
|
|
116
|
+
const html =
|
|
117
|
+
'<!DOCTYPE html>\n' + doc.documentElement.outerHTML
|
|
118
|
+
return {
|
|
119
|
+
html,
|
|
120
|
+
summary: { ...summary, transformApplied: didTransform, appliedFieldCount: countLeaves(applied) },
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function postError(win, parentOrigin, err) {
|
|
125
|
+
win.parent.postMessage(
|
|
126
|
+
{ type: 'hha:upgrade-error', name: err?.name || 'Error', message: err?.message || String(err) },
|
|
127
|
+
parentOrigin,
|
|
128
|
+
)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function readMeta(doc, name) {
|
|
132
|
+
const el = doc.querySelector(`meta[name="${name}"]`)
|
|
133
|
+
return el ? el.getAttribute('content') : null
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function countLeaves(v) {
|
|
137
|
+
if (v == null) return 0
|
|
138
|
+
if (Array.isArray(v)) return v.reduce((n, x) => n + countLeaves(x), 0)
|
|
139
|
+
if (typeof v === 'object') {
|
|
140
|
+
return Object.values(v).reduce((n, x) => n + countLeaves(x), 0)
|
|
141
|
+
}
|
|
142
|
+
return 1
|
|
143
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { UpgradeAlreadyRegistered } from '../engine/errors.js'
|
|
2
|
+
|
|
3
|
+
let registered = null
|
|
4
|
+
|
|
5
|
+
export function registerUpgrade(fn) {
|
|
6
|
+
if (typeof fn !== 'function') {
|
|
7
|
+
throw new TypeError('registerUpgrade expects a function (v1Data) => v2Data')
|
|
8
|
+
}
|
|
9
|
+
if (registered) throw new UpgradeAlreadyRegistered()
|
|
10
|
+
registered = fn
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function getRegisteredTransform() {
|
|
14
|
+
return registered
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function _resetRegistryForTests() {
|
|
18
|
+
registered = null
|
|
19
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Iframe-based upgrade runner.
|
|
3
|
+
*
|
|
4
|
+
* Loads the v2 source URL in a hidden iframe with the `_hyperHtmlApi=upgrade-helper`
|
|
5
|
+
* query param. The helper inside the iframe handles transform + apply +
|
|
6
|
+
* serialize; the parent only orchestrates the postMessage handshake and
|
|
7
|
+
* returns the resulting HTML + summary to the caller.
|
|
8
|
+
*
|
|
9
|
+
* const { html, summary } = await run({ sourceUrl, v1Data })
|
|
10
|
+
*
|
|
11
|
+
* The caller decides what to do with `html` (e.g. POST to `/save`, render
|
|
12
|
+
* in a preview pane, etc.). The runner deliberately doesn't touch the
|
|
13
|
+
* network beyond the iframe load.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const DEFAULT_TIMEOUT_MS = 15000
|
|
17
|
+
|
|
18
|
+
export async function run({
|
|
19
|
+
sourceUrl,
|
|
20
|
+
v1Data,
|
|
21
|
+
v1Version = null,
|
|
22
|
+
parent = typeof window !== 'undefined' ? window : null,
|
|
23
|
+
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
24
|
+
hidden = true,
|
|
25
|
+
} = {}) {
|
|
26
|
+
if (!parent) throw new Error('upgrade.run requires a window context')
|
|
27
|
+
if (!sourceUrl) throw new Error('upgrade.run requires sourceUrl')
|
|
28
|
+
|
|
29
|
+
const helperUrl = buildHelperUrl(sourceUrl, parent.location.origin)
|
|
30
|
+
const expectedOrigin = new URL(helperUrl, parent.location.href).origin
|
|
31
|
+
|
|
32
|
+
const iframe = parent.document.createElement('iframe')
|
|
33
|
+
if (hidden) {
|
|
34
|
+
iframe.style.cssText =
|
|
35
|
+
'position:absolute;left:-9999px;top:-9999px;width:1px;height:1px;border:0;'
|
|
36
|
+
}
|
|
37
|
+
iframe.setAttribute('aria-hidden', 'true')
|
|
38
|
+
iframe.src = helperUrl
|
|
39
|
+
parent.document.body.appendChild(iframe)
|
|
40
|
+
|
|
41
|
+
let cleanup = () => {}
|
|
42
|
+
try {
|
|
43
|
+
return await new Promise((resolve, reject) => {
|
|
44
|
+
const timer = setTimeout(() => {
|
|
45
|
+
reject(new Error(`upgrade.run: helper iframe timed out after ${timeoutMs}ms`))
|
|
46
|
+
}, timeoutMs)
|
|
47
|
+
|
|
48
|
+
const onMessage = (e) => {
|
|
49
|
+
if (e.source !== iframe.contentWindow) return
|
|
50
|
+
if (e.origin !== expectedOrigin) return
|
|
51
|
+
const msg = e.data
|
|
52
|
+
if (!msg || typeof msg !== 'object') return
|
|
53
|
+
|
|
54
|
+
if (msg.type === 'hha:upgrade-ready') {
|
|
55
|
+
iframe.contentWindow.postMessage(
|
|
56
|
+
{ type: 'hha:upgrade-data', v1Data, v1Version },
|
|
57
|
+
expectedOrigin,
|
|
58
|
+
)
|
|
59
|
+
return
|
|
60
|
+
}
|
|
61
|
+
if (msg.type === 'hha:upgrade-result') {
|
|
62
|
+
clearTimeout(timer)
|
|
63
|
+
resolve({ html: msg.html, summary: msg.summary })
|
|
64
|
+
return
|
|
65
|
+
}
|
|
66
|
+
if (msg.type === 'hha:upgrade-error') {
|
|
67
|
+
clearTimeout(timer)
|
|
68
|
+
const err = new Error(msg.message || 'helper error')
|
|
69
|
+
err.name = msg.name || 'UpgradeHelperError'
|
|
70
|
+
reject(err)
|
|
71
|
+
return
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
parent.addEventListener('message', onMessage)
|
|
76
|
+
cleanup = () => {
|
|
77
|
+
parent.removeEventListener('message', onMessage)
|
|
78
|
+
clearTimeout(timer)
|
|
79
|
+
if (iframe.parentNode) iframe.parentNode.removeChild(iframe)
|
|
80
|
+
}
|
|
81
|
+
})
|
|
82
|
+
} finally {
|
|
83
|
+
cleanup()
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function buildHelperUrl(sourceUrl, parentOrigin) {
|
|
88
|
+
const url = new URL(sourceUrl, typeof location !== 'undefined' ? location.href : undefined)
|
|
89
|
+
url.searchParams.set('_hyperHtmlApi', 'upgrade-helper')
|
|
90
|
+
url.searchParams.set('parentOrigin', parentOrigin)
|
|
91
|
+
return url.toString()
|
|
92
|
+
}
|