hyper-html-api 0.3.0 → 0.5.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.
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Load-time update check. Reads the page's hyper-source / hyper-version meta
3
+ * tags, fetches the source anonymously, and compares versions. Results are
4
+ * cached in localStorage for 24 hours, keyed by source + current version so
5
+ * a fresh check happens immediately after an upgrade.
6
+ *
7
+ * Returns null whenever the subsystem should stay inert: missing meta tags,
8
+ * the page IS the canonical source, an unreachable/private source, or
9
+ * unparseable versions. Callers (the popover UI) never need to distinguish
10
+ * these cases.
11
+ */
12
+
13
+ const CHECK_TTL_MS = 24 * 60 * 60 * 1000
14
+ const CHECK_KEY_PREFIX = 'hha:upgrade-check:'
15
+
16
+ export function readMeta(doc, name) {
17
+ const el = doc.querySelector(`meta[name="${name}"]`)
18
+ const content = el ? el.getAttribute('content') : null
19
+ return content && content.trim() ? content.trim() : null
20
+ }
21
+
22
+ export function parseVersion(v) {
23
+ if (typeof v !== 'string') return null
24
+ const cleaned = v.trim().replace(/^v/, '')
25
+ if (!/^\d+(\.\d+)*$/.test(cleaned)) return null
26
+ return cleaned.split('.').map(Number)
27
+ }
28
+
29
+ export function isNewerVersion(candidate, current) {
30
+ const a = parseVersion(candidate)
31
+ const b = parseVersion(current)
32
+ if (!a || !b) return false
33
+ const len = Math.max(a.length, b.length)
34
+ for (let i = 0; i < len; i++) {
35
+ const x = a[i] || 0
36
+ const y = b[i] || 0
37
+ if (x !== y) return x > y
38
+ }
39
+ return false
40
+ }
41
+
42
+ function storageGet(key) {
43
+ try {
44
+ const raw = globalThis.localStorage.getItem(key)
45
+ return raw ? JSON.parse(raw) : null
46
+ } catch {
47
+ return null
48
+ }
49
+ }
50
+
51
+ function storageSet(key, value) {
52
+ try {
53
+ globalThis.localStorage.setItem(key, JSON.stringify(value))
54
+ } catch {
55
+ /* storage unavailable (private mode): every check is uncached */
56
+ }
57
+ }
58
+
59
+ export async function checkForUpdate({
60
+ force = false,
61
+ doc,
62
+ loc,
63
+ fetchFn,
64
+ now,
65
+ ttlMs = CHECK_TTL_MS,
66
+ } = {}) {
67
+ doc = doc || (typeof document !== 'undefined' ? document : null)
68
+ loc = loc || (typeof location !== 'undefined' ? location : null)
69
+ fetchFn = fetchFn || ((url) => fetch(url))
70
+ now = now ?? Date.now()
71
+ if (!doc) return null
72
+
73
+ const sourceUrl = readMeta(doc, 'hyper-source')
74
+ if (!sourceUrl) return null
75
+ const currentVersion = readMeta(doc, 'hyper-version')
76
+
77
+ let source
78
+ try {
79
+ source = new URL(sourceUrl, loc ? loc.href : undefined)
80
+ } catch {
81
+ return null
82
+ }
83
+ // Self-source guard: the canonical page carries a self-pointing
84
+ // hyper-source; it must never offer to upgrade from itself.
85
+ if (loc && source.origin === loc.origin && source.pathname === loc.pathname) {
86
+ return null
87
+ }
88
+
89
+ const cacheKey = `${CHECK_KEY_PREFIX}${source.href}:${currentVersion || 'none'}`
90
+ let sourceVersion
91
+ const cached = force ? null : storageGet(cacheKey)
92
+ if (cached && typeof cached.checkedAt === 'number' && now - cached.checkedAt < ttlMs) {
93
+ sourceVersion = cached.sourceVersion
94
+ } else {
95
+ try {
96
+ const res = await fetchFn(source.href)
97
+ if (!res.ok) throw new Error(`HTTP ${res.status}`)
98
+ const text = await res.text()
99
+ const sourceDoc = new DOMParser().parseFromString(text, 'text/html')
100
+ sourceVersion = readMeta(sourceDoc, 'hyper-version')
101
+ } catch {
102
+ sourceVersion = null
103
+ }
104
+ storageSet(cacheKey, { checkedAt: now, sourceVersion })
105
+ }
106
+
107
+ if (!sourceVersion) return null
108
+ return {
109
+ // A fork without a hyper-version predates versioning: any source version
110
+ // counts as newer.
111
+ available: isNewerVersion(sourceVersion, currentVersion || '0.0.0'),
112
+ currentVersion,
113
+ sourceVersion,
114
+ sourceUrl: source.href,
115
+ }
116
+ }
@@ -0,0 +1,36 @@
1
+ export class UpgradeSourceUnreachable extends Error {
2
+ constructor(url, cause) {
3
+ super(`could not fetch upgrade source: ${url}`)
4
+ this.name = 'UpgradeSourceUnreachable'
5
+ this.sourceUrl = url
6
+ this.cause = cause
7
+ }
8
+ }
9
+
10
+ export class UpgradeSourceHasNoRules extends Error {
11
+ constructor(url) {
12
+ super(
13
+ `upgrade source has no rules tags; refusing to drop all existing data: ${url}`,
14
+ )
15
+ this.name = 'UpgradeSourceHasNoRules'
16
+ this.sourceUrl = url
17
+ }
18
+ }
19
+
20
+ export class UpgradeTransformInvalid extends Error {
21
+ constructor(message, cause) {
22
+ super(message)
23
+ this.name = 'UpgradeTransformInvalid'
24
+ this.cause = cause
25
+ }
26
+ }
27
+
28
+ export class UpgradeMultipleTransforms extends Error {
29
+ constructor(count) {
30
+ super(
31
+ `found ${count} script[type="text/hyper-upgrade"] tags; only one is allowed per document.`,
32
+ )
33
+ this.name = 'UpgradeMultipleTransforms'
34
+ this.count = count
35
+ }
36
+ }
@@ -0,0 +1,26 @@
1
+ import { extract } from '../engine/extract.js'
2
+ import domAdapter from '../adapters/dom.js'
3
+ import { collectRulesTags } from './rules-tags.js'
4
+ import { readMeta } from './check.js'
5
+
6
+ /**
7
+ * Keyed extraction: one entry per rules tag under `root`, keyed by the
8
+ * normalized data-rules-name value. This is the v1 side of an upgrade:
9
+ * pack the page's data into labeled boxes.
10
+ */
11
+ export function extractAllFrom(adapter, root) {
12
+ const dataByName = {}
13
+ for (const { name, rules } of collectRulesTags(adapter, root)) {
14
+ dataByName[name] = extract(adapter, root, rules)
15
+ }
16
+ return dataByName
17
+ }
18
+
19
+ export function extractAll(root) {
20
+ const r = root || document
21
+ const doc = r.nodeType === 9 ? r : r.ownerDocument || r
22
+ return {
23
+ dataByName: extractAllFrom(domAdapter, r),
24
+ version: readMeta(doc, 'hyper-version'),
25
+ }
26
+ }
@@ -1,4 +1,13 @@
1
- export { registerUpgrade, getRegisteredTransform, _resetRegistryForTests } from './registry.js'
2
- export { shapeMatch } from './shape-match.js'
3
- export { run } from './runner.js'
4
- export { isHelperMode, getParentOrigin, bootHelper } from './helper.js'
1
+ export { checkForUpdate, readMeta, parseVersion, isNewerVersion } from './check.js'
2
+ export { extractAll, extractAllFrom } from './extract-all.js'
3
+ export { findTransformTag, evaluateTransform, TRANSFORM_TYPE } from './transform.js'
4
+ export { migrateInto } from './migrate.js'
5
+ export { run } from './run.js'
6
+ export { shapeMatch, countScalarLeaves } from './shape-match.js'
7
+ export { collectRulesTags, normalizeName } from './rules-tags.js'
8
+ export {
9
+ UpgradeSourceUnreachable,
10
+ UpgradeSourceHasNoRules,
11
+ UpgradeTransformInvalid,
12
+ UpgradeMultipleTransforms,
13
+ } from './errors.js'
@@ -0,0 +1,36 @@
1
+ import { apply } from '../engine/apply.js'
2
+ import { collectRulesTags } from './rules-tags.js'
3
+ import { shapeMatch, countScalarLeaves } from './shape-match.js'
4
+
5
+ /**
6
+ * Pour keyed v1 data into the v2 document. Exact-name join: each v2 rules
7
+ * tag pulls the entry with the same normalized name; tags with no matching
8
+ * entry keep their template defaults; v1 entries with no matching tag are
9
+ * counted as discarded. Renames are the transform's job, not the join's.
10
+ */
11
+ export function migrateInto(adapter, root, dataByName) {
12
+ const tags = collectRulesTags(adapter, root)
13
+ const totals = { carriedOver: 0, discarded: 0, listItems: 0 }
14
+ const byName = {}
15
+ const v2Names = new Set()
16
+
17
+ for (const { name, rules } of tags) {
18
+ v2Names.add(name)
19
+ if (!Object.prototype.hasOwnProperty.call(dataByName, name)) {
20
+ byName[name] = { carriedOver: 0, discarded: 0, listItems: 0 }
21
+ continue
22
+ }
23
+ const { data, summary } = shapeMatch(dataByName[name], rules)
24
+ apply(adapter, root, rules, data)
25
+ byName[name] = summary
26
+ totals.carriedOver += summary.carriedOver
27
+ totals.discarded += summary.discarded
28
+ totals.listItems += summary.listItems
29
+ }
30
+
31
+ for (const key of Object.keys(dataByName)) {
32
+ if (!v2Names.has(key)) totals.discarded += countScalarLeaves(dataByName[key])
33
+ }
34
+
35
+ return { totals, byName, rulesTagCount: tags.length }
36
+ }
@@ -0,0 +1,37 @@
1
+ import { parseRelaxed } from '../engine/rules.js'
2
+ import { SUPPORTED_VERSION } from '../engine/rules-tag.js'
3
+ import { UnknownRulesVersion } from '../engine/errors.js'
4
+
5
+ const RULES_SELECTOR = 'script[data-rules-name]'
6
+
7
+ export function normalizeName(raw) {
8
+ return typeof raw === 'string' ? raw.trim().replace(/\s+/g, ' ') : ''
9
+ }
10
+
11
+ /**
12
+ * Collect every rules tag under `root` as [{ name, rules }], keyed by the
13
+ * whitespace-normalized data-rules-name attribute value. Duplicate names:
14
+ * first tag wins with a warning (mirrors findRulesIn). Unsupported
15
+ * data-rules-version throws so engine-format skew fails loudly instead of
16
+ * corrupting a migration.
17
+ */
18
+ export function collectRulesTags(adapter, root) {
19
+ const tags = adapter.find(root, RULES_SELECTOR, { includeRulesTag: true })
20
+ const seen = new Set()
21
+ const out = []
22
+ for (const tag of tags) {
23
+ const name = normalizeName(adapter.attr(tag, 'data-rules-name'))
24
+ if (!name) continue
25
+ if (seen.has(name)) {
26
+ console.warn(
27
+ `hyper-html-api: duplicate rules tag name "${name}"; using the first.`,
28
+ )
29
+ continue
30
+ }
31
+ const version = adapter.attr(tag, 'data-rules-version')
32
+ if (version !== SUPPORTED_VERSION) throw new UnknownRulesVersion(version)
33
+ seen.add(name)
34
+ out.push({ name, rules: parseRelaxed(adapter.text(tag)) })
35
+ }
36
+ return out
37
+ }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Parent-side upgrade runner (the whole flow, no iframe):
3
+ *
4
+ * 1. Pack: extract keyed data from the live page.
5
+ * 2. Download: fetch the source's pristine HTML, parse with DOMParser
6
+ * (scripts never execute, so no runtime DOM noise can leak into what
7
+ * gets saved).
8
+ * 3. Recipe: find the source's text/hyper-upgrade tag, evaluate it, and
9
+ * reshape the data ({ [name]: data } in, same shape out).
10
+ * 4. Unpack: exact-name join + apply into the parsed copy.
11
+ * 5. Serialize and return. The caller decides how to save — hyperclayjs
12
+ * uses saveHtml(); standalone users do their own thing.
13
+ */
14
+
15
+ import domAdapter from '../adapters/dom.js'
16
+ import { readMeta } from './check.js'
17
+ import { extractAllFrom } from './extract-all.js'
18
+ import { findTransformTag, evaluateTransform } from './transform.js'
19
+ import { migrateInto } from './migrate.js'
20
+ import { UpgradeSourceUnreachable, UpgradeSourceHasNoRules, UpgradeTransformInvalid } from './errors.js'
21
+
22
+ export async function run({ sourceUrl, dataByName, fromVersion, doc, loc, fetchFn } = {}) {
23
+ doc = doc || (typeof document !== 'undefined' ? document : null)
24
+ loc = loc || (typeof location !== 'undefined' ? location : null)
25
+ fetchFn = fetchFn || ((url) => fetch(url))
26
+ if (!doc) throw new Error('upgrade.run requires a document context')
27
+
28
+ sourceUrl = sourceUrl || readMeta(doc, 'hyper-source')
29
+ if (!sourceUrl) {
30
+ throw new Error('upgrade.run: no sourceUrl given and no hyper-source meta tag found')
31
+ }
32
+ const source = new URL(sourceUrl, loc ? loc.href : undefined)
33
+ fromVersion = fromVersion !== undefined ? fromVersion : readMeta(doc, 'hyper-version')
34
+ dataByName = dataByName || extractAllFrom(domAdapter, doc)
35
+
36
+ let res
37
+ try {
38
+ res = await fetchFn(source.href)
39
+ } catch (err) {
40
+ throw new UpgradeSourceUnreachable(source.href, err)
41
+ }
42
+ if (!res.ok) {
43
+ throw new UpgradeSourceUnreachable(source.href, new Error(`HTTP ${res.status}`))
44
+ }
45
+ const text = await res.text()
46
+ const sourceDoc = new DOMParser().parseFromString(text, 'text/html')
47
+ const toVersion = readMeta(sourceDoc, 'hyper-version')
48
+
49
+ let map = dataByName
50
+ let transformApplied = false
51
+ const code = findTransformTag(domAdapter, sourceDoc)
52
+ if (code) {
53
+ const transform = await evaluateTransform(code)
54
+ const out = await transform(map, { fromVersion, toVersion })
55
+ if (!out || typeof out !== 'object' || Array.isArray(out)) {
56
+ throw new UpgradeTransformInvalid('transform must return a plain object map of { rulesName: data }')
57
+ }
58
+ map = out
59
+ transformApplied = true
60
+ }
61
+
62
+ const { totals, byName, rulesTagCount } = migrateInto(domAdapter, sourceDoc, map)
63
+ if (rulesTagCount === 0 && Object.keys(map).length > 0) {
64
+ throw new UpgradeSourceHasNoRules(source.href)
65
+ }
66
+
67
+ return {
68
+ html: serializeDocument(sourceDoc),
69
+ fromVersion,
70
+ toVersion,
71
+ summary: { transformApplied, totals, byName },
72
+ }
73
+ }
74
+
75
+ function serializeDocument(doc) {
76
+ const name = doc.doctype && doc.doctype.name ? doc.doctype.name : 'html'
77
+ return `<!DOCTYPE ${name}>\n` + doc.documentElement.outerHTML
78
+ }
@@ -28,6 +28,9 @@ function walk(rule, v1, summary) {
28
28
  return v1
29
29
  }
30
30
  if (v1 === undefined || v1 === null) return undefined
31
+ // A scalar slot can't hold an object/array from v1; leave the template
32
+ // default (collectDiscards counts the dropped leaves).
33
+ if (typeof v1 === 'object') return undefined
31
34
  summary.carriedOver++
32
35
  return v1
33
36
  }
@@ -77,7 +80,7 @@ function collectDiscards(rule, v1, summary) {
77
80
  }
78
81
  }
79
82
 
80
- function countScalarLeaves(v) {
83
+ export function countScalarLeaves(v) {
81
84
  if (v === null || v === undefined) return 0
82
85
  if (Array.isArray(v)) return v.reduce((n, x) => n + countScalarLeaves(x), 0)
83
86
  if (typeof v === 'object') {
@@ -0,0 +1,51 @@
1
+ import { UpgradeMultipleTransforms, UpgradeTransformInvalid } from './errors.js'
2
+
3
+ export const TRANSFORM_TYPE = 'text/hyper-upgrade'
4
+
5
+ /**
6
+ * The migration recipe is a non-executing script tag in the source HTML:
7
+ *
8
+ * <script type="text/hyper-upgrade">
9
+ * export default (dataByName, { fromVersion, toVersion }) => dataByName
10
+ * </script>
11
+ *
12
+ * Browsers ignore unknown script types, so it never runs on the source page
13
+ * itself; the upgrading fork reads it out of the fetched pristine copy and
14
+ * evaluates it on demand. Returns the tag's code, or null when absent.
15
+ */
16
+ export function findTransformTag(adapter, root) {
17
+ const tags = adapter.find(root, `script[type="${TRANSFORM_TYPE}"]`)
18
+ if (tags.length === 0) return null
19
+ if (tags.length > 1) throw new UpgradeMultipleTransforms(tags.length)
20
+ const code = adapter.text(tags[0])
21
+ return code || null
22
+ }
23
+
24
+ // Bundlers rewrite a bare dynamic import() when emitting non-ESM formats; the
25
+ // Function indirection keeps it a native browser import() at runtime.
26
+ const dynamicImport = new Function('url', 'return import(url)')
27
+
28
+ /**
29
+ * Evaluate transform code as a real ES module (Blob URL import). The module
30
+ * must `export default` a function; it may be async. Relative imports won't
31
+ * resolve from a Blob URL; absolute (CDN) imports work.
32
+ */
33
+ export async function evaluateTransform(code) {
34
+ const blob = new Blob([code], { type: 'text/javascript' })
35
+ const url = URL.createObjectURL(blob)
36
+ let mod
37
+ try {
38
+ mod = await dynamicImport(url)
39
+ } catch (err) {
40
+ throw new UpgradeTransformInvalid(
41
+ `transform failed to load: ${err && err.message ? err.message : err}`,
42
+ err,
43
+ )
44
+ } finally {
45
+ URL.revokeObjectURL(url)
46
+ }
47
+ if (typeof mod.default !== 'function') {
48
+ throw new UpgradeTransformInvalid('transform must `export default` a function')
49
+ }
50
+ return mod.default
51
+ }
package/src/upgrade.js ADDED
@@ -0,0 +1,14 @@
1
+ // Lean upgrade entry: the DOM-bound engine + the template-upgrade flow,
2
+ // WITHOUT cms. This is what `hyper-html-api/upgrade` exports and what
3
+ // hyperclayjs vendors for its `upgrade` module.
4
+ import { engine } from './dom-api.js'
5
+ import { checkForUpdate } from './upgrade/check.js'
6
+ import { run } from './upgrade/run.js'
7
+ import { extractAll } from './upgrade/extract-all.js'
8
+ import { shapeMatch } from './upgrade/shape-match.js'
9
+
10
+ export const upgrade = { checkForUpdate, run, extractAll, shapeMatch }
11
+ export { engine }
12
+
13
+ const HyperHtmlApiUpgrade = { engine, upgrade }
14
+ export default HyperHtmlApiUpgrade
@@ -1,143 +0,0 @@
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
- }
@@ -1,19 +0,0 @@
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
- }