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 ADDED
@@ -0,0 +1,13 @@
1
+ Zero-Clause BSD
2
+ =============
3
+
4
+ Permission to use, copy, modify, and/or distribute this software for
5
+ any purpose with or without fee is hereby granted.
6
+
7
+ THE SOFTWARE IS PROVIDED “AS IS” AND THE AUTHOR DISCLAIMS ALL
8
+ WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES
9
+ OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE
10
+ FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY
11
+ DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN
12
+ AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
13
+ OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,51 @@
1
+ # hyper-html-api
2
+
3
+ Self-describing data layer for HTML files — a bidirectional engine that reads structured data out of an HTML page and writes structured data back into it, driven by a single declarative rules tag.
4
+
5
+ Status: **early development** (Phase 0 — repo bootstrap). API shape and behavior are being ported from `hyperclay/server-lib/data-extractor.js`. See `plans/hyperclay/hyper-html-api/` in the workspace for the full specification.
6
+
7
+ ## Install
8
+
9
+ ```sh
10
+ npm install hyper-html-api
11
+ ```
12
+
13
+ ## Rules tag
14
+
15
+ Pages describe their data layer with a single script tag:
16
+
17
+ ```html
18
+ <script type="application/hyper-html-api" id="hyper-html-api" data-rules-version="1">
19
+ {
20
+ "title": "h1",
21
+ "user": { "name": ".user-name", "role": ".user-role" },
22
+ "products": [".product", { "name": ".name", "price": ".price" }],
23
+ "tags": ".tag[]"
24
+ }
25
+ </script>
26
+ ```
27
+
28
+ ## Browser API
29
+
30
+ ```js
31
+ HyperHtmlApi.engine.extract(root, rules) // → JSON
32
+ HyperHtmlApi.engine.apply(root, rules, data) // mutates the DOM
33
+ HyperHtmlApi.engine.findRulesIn(root) // → { rules, tagNode } | null
34
+ HyperHtmlApi.engine.errors // error class refs
35
+ ```
36
+
37
+ ## Node API
38
+
39
+ ```js
40
+ import { extract, apply, findRulesIn, errors } from 'hyper-html-api/engine'
41
+ import cheerioAdapter from 'hyper-html-api/cheerio'
42
+ import * as cheerio from 'cheerio'
43
+
44
+ const $ = cheerio.load(html)
45
+ const { rules } = findRulesIn(cheerioAdapter, $.root())
46
+ const data = extract(cheerioAdapter, $.root(), rules)
47
+ ```
48
+
49
+ ## License
50
+
51
+ 0BSD
@@ -0,0 +1,2 @@
1
+ var HyperHtmlApi=(()=>{var B=Object.defineProperty;var ce=Object.getOwnPropertyDescriptor;var le=Object.getOwnPropertyNames;var ae=Object.prototype.hasOwnProperty;var ee=(e,t)=>{for(var r in t)B(e,r,{get:t[r],enumerable:!0})},ue=(e,t,r,o)=>{if(t&&typeof t=="object"||typeof t=="function")for(let n of le(t))!ae.call(e,n)&&n!==r&&B(e,n,{get:()=>t[n],enumerable:!(o=ce(t,n))||o.enumerable});return e};var de=e=>ue(B({},"__esModule",{value:!0}),e);var Be={};ee(Be,{cms:()=>ie,default:()=>qe,engine:()=>se,upgrade:()=>fe});var C=["textContent","innerText","innerHTML","outerHTML","value","checked","selected","disabled","readOnly","type","tagName","nodeName","nodeType","nodeValue","childElementCount","id","className","classList","href","src","action","baseURI","offsetWidth","offsetHeight","clientWidth","clientHeight","scrollWidth","scrollHeight","dataset","currentSrc","duration","paused","title","documentURI","contentType"],O=new Set(C);var D={};ee(D,{EmptyListInsert:()=>b,MaxRuleDepthExceeded:()=>E,RulesParseError:()=>x,ShapeMismatch:()=>M,UnknownRulesVersion:()=>T,UpgradeAlreadyRegistered:()=>w});var x=class extends Error{constructor(t,r){super(t),this.name="RulesParseError",this.cause=r}},T=class extends Error{constructor(t){super(`unknown rules version: ${t}. Library supports "1".`),this.name="UnknownRulesVersion",this.version=t}},E=class extends Error{constructor(t){super(`rule depth exceeded 20 at path: ${t.join(".")}`),this.name="MaxRuleDepthExceeded",this.path=t}},M=class extends Error{constructor(t){super(`shape mismatch: ${t.length} field(s) failed validation`),this.name="ShapeMismatch",this.mismatches=t}},b=class extends Error{constructor(t){super(`cannot add items to empty list at "${t.join(".")}" \u2014 no sibling to clone as template. Seed the list with a hidden item first.`),this.name="EmptyListInsert",this.path=t}},w=class extends Error{constructor(){super("upgrade transform already registered; only one registration is allowed per page."),this.name="UpgradeAlreadyRegistered"}};var pe=20;function A(e,t,r){return $(e,t,r,{depth:0,path:[]})}function $(e,t,r,o){if(o.depth>pe)throw new E(o.path);if(typeof r=="string")return he(e,t,r);if(Array.isArray(r)){let[n,s]=r;return e.find(t,n).map((i,f)=>$(e,i,s,{depth:o.depth+1,path:[...o.path,f]}))}if(typeof r=="object"&&r!==null){let n={};for(let[s,c]of Object.entries(r))n[s]=$(e,t,c,{depth:o.depth+1,path:[...o.path,s]});return n}return null}function he(e,t,r){if(r.endsWith("[]")){let n=r.slice(0,-2);return e.find(t,n).map(s=>e.text(s))}if(r.startsWith("@"))return te(e,t,r.slice(1));if(r.includes("@")){let[n,s]=r.split("@"),c=n?e.find(t,n):[t];return c.length===0?null:te(e,c[0],s)}if(r===".")return e.text(t);let o=e.find(t,r);return o.length===0?null:e.text(o[0])}function te(e,t,r){if(O.has(r)){let n=e.prop(t,r);return n==null?null:String(n)}let o=e.attr(t,r);return o||null}var ye=.5;function W(e,t,r,o,n,s,c){let i=e.find(t,r);if(n.length===0){i.forEach(d=>e.remove(d));return}if(n.length>i.length&&i.length===0)throw new b(s.path);let a=i.map(d=>me(e,d,o)),u=null;if(i.length>0){u=e.clone(i[0]);let d=e.stripIds(u);d>0&&console.warn(`[hyper-html-api] stripped ${d} id attribute(s) from cloned template at "${s.path.join(".")||"(root)"}"`)}let p=ge(n,a,o),l=e.parent(i[0]),R=Ae(e,l,i[0]),y=new Set,h=n.map((d,m)=>{let S=p[m];if(S>=0)return y.add(S),i[S];let v=e.clone(u);return e.stripIds(v),v});i.forEach((d,m)=>{y.has(m)||e.remove(d)}),y.forEach(d=>e.remove(i[d])),h.forEach((d,m)=>{e.insertAt(l,d,R+m)}),h.forEach((d,m)=>{if(o===null){let S=n[m];e.text(d,S==null?"":String(S))}else c(e,d,o,n[m],{depth:s.depth+1,path:[...s.path,m]})})}function me(e,t,r){return r===null?e.text(t):A(e,t,r)}function ge(e,t,r){let o=new Array(e.length).fill(-1),n=new Set;return e.forEach((s,c)=>{let i=-1,f=-1;t.forEach((a,u)=>{if(n.has(u))return;let p=Ee(s,a,r),l=p===f&&i>=0?Math.abs(u-c)<Math.abs(i-c):!1;(p>f||l)&&(f=p,i=u)}),f>=ye&&(o[c]=i,n.add(i))}),o}function Ee(e,t,r){if(r===null)return e===t?1:0;let o=Object.keys(r||{});if(o.length===0)return 0;let n=0;for(let s of o)JSON.stringify(e?.[s])===JSON.stringify(t?.[s])&&n++;return n/o.length}function Ae(e,t,r){let o=e.children(t);for(let n=0;n<o.length;n++)if(e.sameNode(o[n],r))return n;return-1}var Re=20,re=new Set(["checked","selected","disabled","readOnly","paused"]);function L(e,t,r,o){let n=[];if(F(r,o,[],n),n.length)throw new M(n);j(e,t,r,o,{depth:0,path:[]})}function j(e,t,r,o,n){if(n.depth>Re)throw new E(n.path);if(o!==void 0){if(typeof r=="string")return Se(e,t,r,o,n);if(Array.isArray(r)){let[s,c]=r;return W(e,t,s,c,o,n,j)}if(typeof r=="object"&&r!==null)for(let[s,c]of Object.entries(r))j(e,t,c,o==null?o:o[s],{depth:n.depth+1,path:[...n.path,s]})}}function Se(e,t,r,o,n){if(r.endsWith("[]")){let c=r.slice(0,-2);return W(e,t,c,null,o,n,j)}if(r.startsWith("@"))return ne(e,t,r.slice(1),o);if(r.includes("@")){let[c,i]=r.split("@"),f=c?e.find(t,c):[t];return f.length===0?void 0:ne(e,f[0],i,o)}if(r==="."){e.text(t,o==null?"":String(o));return}let s=e.find(t,r);s.length!==0&&e.text(s[0],o==null?"":String(o))}function ne(e,t,r,o){O.has(r)?e.prop(t,r,xe(r,o)):e.attr(t,r,o==null?"":String(o))}function xe(e,t){return t==null?re.has(e)?!1:"":re.has(e)?!!t:t}function F(e,t,r,o){if(!(t==null||t==="")){if(typeof e=="string"){if(e.endsWith("[]")){Array.isArray(t)?t.forEach((n,s)=>{typeof n=="object"&&n!==null&&o.push({path:N([...r,s]),expected:"scalar",got:I(n)})}):o.push({path:N(r),expected:"array",got:I(t)});return}typeof t=="object"&&o.push({path:N(r),expected:"scalar",got:I(t)});return}if(Array.isArray(e)){if(!Array.isArray(t)){o.push({path:N(r),expected:"array",got:I(t)});return}let n=e[1];t.forEach((s,c)=>F(n,s,[...r,c],o));return}if(typeof e=="object"&&e!==null){if(Array.isArray(t)||typeof t!="object"){o.push({path:N(r),expected:"object",got:I(t)});return}for(let[n,s]of Object.entries(e))F(s,t[n],[...r,n],o)}}}function I(e){return e===null?"null":Array.isArray(e)?"array":typeof e}function N(e){return e.join(".")}function P(e){try{return JSON.parse(e)}catch(t){throw new x(`hyper-html-api script tags require strict JSON. Relaxed JSON (unquoted keys, single quotes, trailing commas) is only supported in the ?data= URL parameter. Original error: ${t.message}`,t)}}function J(e){try{return JSON.parse(e)}catch{}let t={BRACE_OPEN:"{",BRACE_CLOSE:"}",BRACKET_OPEN:"[",BRACKET_CLOSE:"]",COLON:":",COMMA:",",STRING:"STRING",SELECTOR:"SELECTOR",IDENTIFIER:"IDENTIFIER",NUMBER:"NUMBER",BOOLEAN:"BOOLEAN"};function r(n){let s=[],c=0;for(;c<n.length;){let i=n[c];if(/\s/.test(i)){c++;continue}if("{}".includes(i)){s.push({type:i,value:i}),c++;continue}if(i==="["){let p=!1,l=c+1;for(;l<n.length&&/\s/.test(n[l]);)l++;if(l<n.length&&/[a-zA-Z_]/.test(n[l])&&(p=!0),!p){s.push({type:i,value:i}),c++;continue}}if(i==="]"){s.push({type:i,value:i}),c++;continue}if(i===":"){s.push({type:t.COLON,value:i}),c++;continue}if(i===","){s.push({type:t.COMMA,value:i}),c++;continue}if(i==='"'||i==="'"){let p=i,l=c+1;for(;l<n.length&&n[l]!==p;)n[l]==="\\"&&l++,l++;s.push({type:t.STRING,value:n.substring(c+1,l),quoted:!0}),c=l+1;continue}let f=c,a;for(;f<n.length&&!/[{},]/.test(n[f]);)if(n[f]===":"){let p=[":first",":last",":nth-child",":nth-of-type",":first-child",":last-child",":first-of-type",":last-of-type",":only-child",":only-of-type",":hover",":focus",":active",":visited",":disabled",":enabled",":checked",":empty",":root",":target",":not",":before",":after",":nth-last-child",":nth-last-of-type"],l=!1;for(let R of p){let y=R.substring(1);if(n.substring(f+1,f+1+y.length)===y){l=!0,f+=y.length;break}}if(!l)break}else if(n[f]==="["){for(f++;f<n.length&&n[f]!=="]";){if(n[f]==='"'||n[f]==="'"){let p=n[f];for(f++;f<n.length&&n[f]!==p;)n[f]==="\\"&&f++,f++}f++}f<n.length&&n[f]==="]"&&f++}else f++;a=n.substring(c,f);let u=t.IDENTIFIER;/^-?\d+(\.\d+)?$/.test(a)?u=t.NUMBER:a==="true"||a==="false"||a==="null"?u=t.BOOLEAN:/^[.#@\[]|[.#@\[]| /.test(a)&&(u=t.SELECTOR),s.push({type:u,value:a,quoted:!1}),c=f}return s}function o(n){let s="";for(let c=0;c<n.length;c++){let i=n[c];if("{}".includes(i.type)||"[]".includes(i.type)){s+=i.value;continue}if(i.type===t.COLON){s+=i.value;continue}if(i.type===t.COMMA){s+=i.value;continue}if(i.type===t.STRING&&i.quoted){s+=`"${i.value}"`;continue}if(i.type===t.NUMBER||i.type===t.BOOLEAN){s+=i.value;continue}if(i.type===t.SELECTOR||i.type===t.IDENTIFIER){s+=`"${i.value}"`;continue}s+=`"${i.value}"`}return s}try{let n=r(e),s=o(n);return JSON.parse(s)}catch(n){throw new x("Invalid extraction rules syntax: "+n.message,n)}}var Oe="hyper-html-api",Te="1";function _(e,t){let r=e.find(t,`script#${Oe}`,{includeRulesTag:!0});if(r.length===0)return null;let o=r[0],n=e.attr(o,"data-rules-version");if(n!==Te)throw new T(n);let s=e.text(o);return{rules:P(s),tagNode:o}}var G=null;function V(e){if(typeof e!="function")throw new TypeError("registerUpgrade expects a function (v1Data) => v2Data");if(G)throw new w;G=e}function H(){return G}function U(e,t){let r={carriedOver:0,discarded:0,listItems:0},o=Y(t,e,r);return X(t,e,r),{data:o,summary:r}}function Y(e,t,r){if(typeof e=="string")return e.endsWith("[]")?Array.isArray(t)?(r.listItems+=t.length,r.carriedOver+=t.length,t):void 0:t==null?void 0:(r.carriedOver++,t);if(Array.isArray(e)){let[,o]=e;return Array.isArray(t)?(r.listItems+=t.length,t.map(n=>Y(o,n,r))):void 0}if(typeof e=="object"&&e!==null){let o={};for(let[n,s]of Object.entries(e)){let c=Y(s,t?.[n],r);c!==void 0&&(o[n]=c)}return o}}function X(e,t,r){if(t!=null){if(typeof e=="object"&&e!==null&&!Array.isArray(e)){if(typeof t!="object"||Array.isArray(t))return;let o=new Set(Object.keys(e));for(let n of Object.keys(t))o.has(n)?X(e[n],t[n],r):r.discarded+=k(t[n]);return}if(Array.isArray(e)&&Array.isArray(t)){let o=e[1];t.forEach(n=>X(o,n,r));return}typeof e=="string"&&!e.endsWith("[]")&&typeof t=="object"&&t!==null&&(r.discarded+=k(t))}}function k(e){return e==null?0:Array.isArray(e)?e.reduce((t,r)=>t+k(r),0):typeof e=="object"?Object.values(e).reduce((t,r)=>t+k(r),0):1}async function z({sourceUrl:e,v1Data:t,v1Version:r=null,parent:o=typeof window<"u"?window:null,timeoutMs:n=15e3,hidden:s=!0}={}){if(!o)throw new Error("upgrade.run requires a window context");if(!e)throw new Error("upgrade.run requires sourceUrl");let c=be(e,o.location.origin),i=new URL(c,o.location.href).origin,f=o.document.createElement("iframe");s&&(f.style.cssText="position:absolute;left:-9999px;top:-9999px;width:1px;height:1px;border:0;"),f.setAttribute("aria-hidden","true"),f.src=c,o.document.body.appendChild(f);let a=()=>{};try{return await new Promise((u,p)=>{let l=setTimeout(()=>{p(new Error(`upgrade.run: helper iframe timed out after ${n}ms`))},n),R=y=>{if(y.source!==f.contentWindow||y.origin!==i)return;let h=y.data;if(!(!h||typeof h!="object")){if(h.type==="hha:upgrade-ready"){f.contentWindow.postMessage({type:"hha:upgrade-data",v1Data:t,v1Version:r},i);return}if(h.type==="hha:upgrade-result"){clearTimeout(l),u({html:h.html,summary:h.summary});return}if(h.type==="hha:upgrade-error"){clearTimeout(l);let d=new Error(h.message||"helper error");d.name=h.name||"UpgradeHelperError",p(d);return}}};o.addEventListener("message",R),a=()=>{o.removeEventListener("message",R),clearTimeout(l),f.parentNode&&f.parentNode.removeChild(f)}})}finally{a()}}function be(e,t){let r=new URL(e,typeof location<"u"?location.href:void 0);return r.searchParams.set("_hyperHtmlApi","upgrade-helper"),r.searchParams.set("parentOrigin",t),r.toString()}var we="hyper-html-api";function Ie(e){return e&&e.nodeType===1&&e.id===we&&e.tagName==="SCRIPT"}function Ne(e){return e?(e.nodeType===9||e.nodeType===11,e):null}var Le={find(e,t,r={}){let o=Ne(e);if(!o||!o.querySelectorAll)return[];let n=Array.from(o.querySelectorAll(t));return r.includeRulesTag?n:n.filter(s=>!Ie(s))},parent(e){return e?e.parentElement:null},children(e){return e?Array.from(e.children):[]},text(e,t){if(t===void 0)return(e.textContent||"").trim();e.textContent=t},attr(e,t,r){if(r===void 0)return e.hasAttribute&&e.hasAttribute(t)?e.getAttribute(t):null;e.setAttribute(t,r)},prop(e,t,r){if(r===void 0){let o=e?e[t]:void 0;return o!==void 0?o:null}e[t]=r},clone(e){return e.cloneNode(!0)},insertAt(e,t,r){let o=e.children[r]||null;e.insertBefore(t,o)},remove(e){e&&e.parentNode&&e.parentNode.removeChild(e)},stripIds(e){let t=0;return e.id&&(e.removeAttribute("id"),t++),(e.querySelectorAll?e.querySelectorAll("[id]"):[]).forEach(o=>{o.removeAttribute("id"),t++}),t},sameNode(e,t){return e===t}},g=Le;var Pe="_hyperHtmlApi",_e="upgrade-helper",Ue="parentOrigin";function q(e=typeof location<"u"?location:null){if(!e)return!1;try{return new URLSearchParams(e.search).get(Pe)===_e}catch{return!1}}function oe(e=typeof location<"u"?location:null){return e?new URLSearchParams(e.search).get(Ue):null}function Z({win:e,doc:t,parentOrigin:r}={}){if(e=e||(typeof window<"u"?window:null),t=t||(typeof document<"u"?document:null),!e||!t||(r=r||oe(e.location),!r))return;let o=()=>Ce({win:e,doc:t,parentOrigin:r});t.readyState==="loading"?t.addEventListener("DOMContentLoaded",o,{once:!0}):o()}function Ce({win:e,doc:t,parentOrigin:r}){let o;try{o=_(g,t.body)}catch(f){return K(e,r,f)}if(!o)return K(e,r,new Error("helper-mode: no rules tag in v2 document"));let n=o.rules,s=je(t,"hyper-version"),c=!!H(),i=f=>{if(f.source!==e.parent||f.origin!==r)return;let a=f.data;if(!(!a||a.type!=="hha:upgrade-data")){e.removeEventListener("message",i);try{let u=De({doc:t,rules:n,v1Data:a.v1Data});e.parent.postMessage({type:"hha:upgrade-result",html:u.html,summary:u.summary},r)}catch(u){K(e,r,u)}}};e.addEventListener("message",i),e.parent.postMessage({type:"hha:upgrade-ready",rules:n,version:s,hasTransform:c},r)}function De({doc:e,rules:t,v1Data:r}){let o=H(),n=r,s=!1;o&&(n=o(r),s=!0);let{data:c,summary:i}=U(n,t);L(g,e.body,t,c);let f=A(g,e.body,t);return{html:`<!DOCTYPE html>
2
+ `+e.documentElement.outerHTML,summary:{...i,transformApplied:s,appliedFieldCount:Q(f)}}}function K(e,t,r){e.parent.postMessage({type:"hha:upgrade-error",name:r?.name||"Error",message:r?.message||String(r)},t)}function je(e,t){let r=e.querySelector(`meta[name="${t}"]`);return r?r.getAttribute("content"):null}function Q(e){return e==null?0:Array.isArray(e)?e.reduce((t,r)=>t+Q(r),0):typeof e=="object"?Object.values(e).reduce((t,r)=>t+Q(r),0):1}var se={extract:(e,t)=>A(g,e,t),apply:(e,t,r)=>L(g,e,t,r),findRulesIn:e=>_(g,e),parseStrict:P,parseRelaxed:J,errors:D,DOM_PROPERTIES:C},ie={},fe={registerUpgrade:V,run:z,shapeMatch:U,isHelperMode:q};typeof window<"u"&&q(window.location)&&Z({win:window,doc:document});var ke={engine:se,cms:ie,upgrade:fe},qe=ke;return de(Be);})();
package/package.json ADDED
@@ -0,0 +1,68 @@
1
+ {
2
+ "name": "hyper-html-api",
3
+ "description": "self-describing data layer for HTML files — extract, apply, CMS, upgrade",
4
+ "keywords": [
5
+ "HTML",
6
+ "DOM",
7
+ "data",
8
+ "extract",
9
+ "apply",
10
+ "CMS"
11
+ ],
12
+ "version": "0.1.0",
13
+ "homepage": "https://github.com/panphora/hyper-html-api",
14
+ "bugs": {
15
+ "url": "https://github.com/panphora/hyper-html-api/issues"
16
+ },
17
+ "license": "0BSD",
18
+ "files": [
19
+ "LICENSE",
20
+ "README.md",
21
+ "src/**/*.js",
22
+ "dist/*.js"
23
+ ],
24
+ "main": "src/hyper-html-api.js",
25
+ "module": "src/hyper-html-api.js",
26
+ "type": "module",
27
+ "exports": {
28
+ ".": "./src/hyper-html-api.js",
29
+ "./engine": "./src/engine/index.js",
30
+ "./cheerio": "./src/adapters/cheerio.js",
31
+ "./dom": "./src/adapters/dom.js"
32
+ },
33
+ "scripts": {
34
+ "build": "npx esbuild src/hyper-html-api.js --bundle --minify --format=iife --global-name=HyperHtmlApi --outfile=dist/hyper-html-api.min.js",
35
+ "prepublishOnly": "npm run build",
36
+ "copy-to-hyperclayjs": "npm run build && node scripts/copy-to-hyperclayjs.js",
37
+ "demo": "http-server . -p 5693 -c-1 -o /demo/",
38
+ "test": "web-test-runner",
39
+ "test:debug": "web-test-runner --manual --open",
40
+ "test:chrome": "web-test-runner --playwright --browsers chromium",
41
+ "test:firefox": "web-test-runner --concurrency 1 --playwright --browsers firefox",
42
+ "test:webkit": "web-test-runner --playwright --browsers webkit",
43
+ "test:all": "web-test-runner --concurrency 1 --playwright --browsers chromium firefox webkit",
44
+ "test:node": "node --test 'test-node/**/*.test.js'",
45
+ "test:ci": "npm run test:node && npm run test:all -- --fail-only && node test/lib/ensure-full-coverage.js",
46
+ "test:coverage": "npm run test:chrome && node test/lib/ensure-full-coverage.js",
47
+ "format": "prettier --write .",
48
+ "format:check": "prettier --check ."
49
+ },
50
+ "repository": {
51
+ "type": "git",
52
+ "url": "git+https://github.com/panphora/hyper-html-api.git"
53
+ },
54
+ "devDependencies": {
55
+ "@web/test-runner": "^0.20.0",
56
+ "@web/test-runner-playwright": "^0.11.0",
57
+ "chai": "^4.3.6",
58
+ "chai-dom": "^1.11.0",
59
+ "cheerio": "^1.0.0-rc.12",
60
+ "esbuild": "^0.24.0",
61
+ "http-server": "^14.1.1",
62
+ "jsdom": "^29.0.2",
63
+ "lcov-parse": "^1.0.0",
64
+ "mocha": "^11.0.1",
65
+ "prettier": "^3.4.2",
66
+ "sinon": "^9.2.4"
67
+ }
68
+ }
@@ -0,0 +1,93 @@
1
+ const RULES_TAG_ID = 'hyper-html-api'
2
+
3
+ function isRulesTag(node) {
4
+ if (!node || !node.attr) return false
5
+ if (node.attr('id') !== RULES_TAG_ID) return false
6
+ const tag = node.prop ? node.prop('tagName') : ''
7
+ return String(tag || '').toUpperCase() === 'SCRIPT'
8
+ }
9
+
10
+ function toWrappers(cheerioSet) {
11
+ const out = []
12
+ for (let i = 0; i < cheerioSet.length; i++) out.push(cheerioSet.eq(i))
13
+ return out
14
+ }
15
+
16
+ const cheerioAdapter = {
17
+ find(ctx, selector, opts = {}) {
18
+ if (!ctx || !ctx.find) return []
19
+ const matches = toWrappers(ctx.find(selector))
20
+ if (opts.includeRulesTag) return matches
21
+ return matches.filter((n) => !isRulesTag(n))
22
+ },
23
+
24
+ parent(node) {
25
+ if (!node || !node.parent) return null
26
+ const p = node.parent()
27
+ return p && p.length ? p : null
28
+ },
29
+
30
+ children(node) {
31
+ if (!node || !node.children) return []
32
+ return toWrappers(node.children())
33
+ },
34
+
35
+ text(node, value) {
36
+ if (value === undefined) return node.text().trim()
37
+ node.text(value)
38
+ },
39
+
40
+ attr(node, name, value) {
41
+ if (value === undefined) {
42
+ const v = node.attr(name)
43
+ return v !== undefined ? v : null
44
+ }
45
+ node.attr(name, value)
46
+ },
47
+
48
+ prop(node, name, value) {
49
+ if (value === undefined) {
50
+ const v = node.prop(name)
51
+ return v !== undefined ? v : null
52
+ }
53
+ node.prop(name, value)
54
+ },
55
+
56
+ clone(node) {
57
+ return node.clone()
58
+ },
59
+
60
+ insertAt(parent, node, index) {
61
+ const siblings = parent.children()
62
+ if (index >= siblings.length) parent.append(node)
63
+ else siblings.eq(index).before(node)
64
+ },
65
+
66
+ remove(node) {
67
+ if (node) node.remove()
68
+ },
69
+
70
+ stripIds(node) {
71
+ let count = 0
72
+ if (node.attr('id')) {
73
+ node.removeAttr('id')
74
+ count++
75
+ }
76
+ node.find('[id]').each(function () {
77
+ if (this.attribs && this.attribs.id !== undefined) {
78
+ delete this.attribs.id
79
+ count++
80
+ }
81
+ })
82
+ return count
83
+ },
84
+
85
+ sameNode(a, b) {
86
+ if (!a || !b) return false
87
+ const aEl = a[0]
88
+ const bEl = b[0]
89
+ return !!aEl && aEl === bEl
90
+ },
91
+ }
92
+
93
+ export default cheerioAdapter
@@ -0,0 +1,83 @@
1
+ const RULES_TAG_ID = 'hyper-html-api'
2
+
3
+ function isRulesTag(node) {
4
+ return node && node.nodeType === 1 && node.id === RULES_TAG_ID && node.tagName === 'SCRIPT'
5
+ }
6
+
7
+ function resolveSearchRoot(ctx) {
8
+ if (!ctx) return null
9
+ if (ctx.nodeType === 9) return ctx
10
+ if (ctx.nodeType === 11) return ctx
11
+ return ctx
12
+ }
13
+
14
+ const dom = {
15
+ find(ctx, selector, opts = {}) {
16
+ const root = resolveSearchRoot(ctx)
17
+ if (!root || !root.querySelectorAll) return []
18
+ const all = Array.from(root.querySelectorAll(selector))
19
+ if (opts.includeRulesTag) return all
20
+ return all.filter((n) => !isRulesTag(n))
21
+ },
22
+
23
+ parent(node) {
24
+ return node ? node.parentElement : null
25
+ },
26
+
27
+ children(node) {
28
+ return node ? Array.from(node.children) : []
29
+ },
30
+
31
+ text(node, value) {
32
+ if (value === undefined) return (node.textContent || '').trim()
33
+ node.textContent = value
34
+ },
35
+
36
+ attr(node, name, value) {
37
+ if (value === undefined) {
38
+ return node.hasAttribute && node.hasAttribute(name) ? node.getAttribute(name) : null
39
+ }
40
+ node.setAttribute(name, value)
41
+ },
42
+
43
+ prop(node, name, value) {
44
+ if (value === undefined) {
45
+ const v = node ? node[name] : undefined
46
+ return v !== undefined ? v : null
47
+ }
48
+ node[name] = value
49
+ },
50
+
51
+ clone(node) {
52
+ return node.cloneNode(true)
53
+ },
54
+
55
+ insertAt(parent, node, index) {
56
+ const ref = parent.children[index] || null
57
+ parent.insertBefore(node, ref)
58
+ },
59
+
60
+ remove(node) {
61
+ if (node && node.parentNode) node.parentNode.removeChild(node)
62
+ },
63
+
64
+ stripIds(node) {
65
+ let count = 0
66
+ if (node.id) {
67
+ node.removeAttribute('id')
68
+ count++
69
+ }
70
+ const inner = node.querySelectorAll ? node.querySelectorAll('[id]') : []
71
+ inner.forEach((el) => {
72
+ el.removeAttribute('id')
73
+ count++
74
+ })
75
+ return count
76
+ },
77
+
78
+ sameNode(a, b) {
79
+ return a === b
80
+ },
81
+ }
82
+
83
+ export default dom
@@ -0,0 +1,2 @@
1
+ export { default as dom } from './dom.js'
2
+ export { default as cheerio } from './cheerio.js'
@@ -0,0 +1,139 @@
1
+ import { DOM_PROPERTIES_SET } from './dom-properties.js'
2
+ import { MaxRuleDepthExceeded, ShapeMismatch } from './errors.js'
3
+ import { listDiff } from './diff.js'
4
+
5
+ const MAX_DEPTH = 20
6
+
7
+ const BOOLEAN_PROPS = new Set(['checked', 'selected', 'disabled', 'readOnly', 'paused'])
8
+
9
+ export function apply(adapter, root, rules, data) {
10
+ const mismatches = []
11
+ validateShape(rules, data, [], mismatches)
12
+ if (mismatches.length) throw new ShapeMismatch(mismatches)
13
+
14
+ applyAt(adapter, root, rules, data, { depth: 0, path: [] })
15
+ }
16
+
17
+ export function applyAt(adapter, ctx, rule, value, trace) {
18
+ if (trace.depth > MAX_DEPTH) throw new MaxRuleDepthExceeded(trace.path)
19
+ if (value === undefined) return
20
+
21
+ if (typeof rule === 'string') return applyScalar(adapter, ctx, rule, value, trace)
22
+
23
+ if (Array.isArray(rule)) {
24
+ const [selector, shape] = rule
25
+ return listDiff(adapter, ctx, selector, shape, value, trace, applyAt)
26
+ }
27
+
28
+ if (typeof rule === 'object' && rule !== null) {
29
+ for (const [key, sub] of Object.entries(rule)) {
30
+ applyAt(
31
+ adapter,
32
+ ctx,
33
+ sub,
34
+ value == null ? value : value[key],
35
+ { depth: trace.depth + 1, path: [...trace.path, key] },
36
+ )
37
+ }
38
+ }
39
+ }
40
+
41
+ function applyScalar(adapter, ctx, rule, value, trace) {
42
+ if (rule.endsWith('[]')) {
43
+ const selector = rule.slice(0, -2)
44
+ return listDiff(adapter, ctx, selector, null, value, trace, applyAt)
45
+ }
46
+
47
+ if (rule.startsWith('@')) {
48
+ return writePropOrAttr(adapter, ctx, rule.slice(1), value)
49
+ }
50
+
51
+ if (rule.includes('@')) {
52
+ const [selector, name] = rule.split('@')
53
+ const matches = selector ? adapter.find(ctx, selector) : [ctx]
54
+ if (matches.length === 0) return
55
+ return writePropOrAttr(adapter, matches[0], name, value)
56
+ }
57
+
58
+ if (rule === '.') {
59
+ adapter.text(ctx, value == null ? '' : String(value))
60
+ return
61
+ }
62
+
63
+ const matches = adapter.find(ctx, rule)
64
+ if (matches.length === 0) return
65
+ adapter.text(matches[0], value == null ? '' : String(value))
66
+ }
67
+
68
+ function writePropOrAttr(adapter, node, name, value) {
69
+ if (DOM_PROPERTIES_SET.has(name)) {
70
+ adapter.prop(node, name, coercePropValue(name, value))
71
+ } else {
72
+ adapter.attr(node, name, value == null ? '' : String(value))
73
+ }
74
+ }
75
+
76
+ function coercePropValue(name, value) {
77
+ if (value === null || value === undefined) {
78
+ return BOOLEAN_PROPS.has(name) ? false : ''
79
+ }
80
+ if (BOOLEAN_PROPS.has(name)) return Boolean(value)
81
+ return value
82
+ }
83
+
84
+ function validateShape(rule, value, path, mismatches) {
85
+ if (value === undefined || value === null || value === '') return
86
+
87
+ if (typeof rule === 'string') {
88
+ if (rule.endsWith('[]')) {
89
+ if (!Array.isArray(value)) {
90
+ mismatches.push({ path: pathStr(path), expected: 'array', got: typeofX(value) })
91
+ } else {
92
+ value.forEach((v, i) => {
93
+ if (typeof v === 'object' && v !== null) {
94
+ mismatches.push({
95
+ path: pathStr([...path, i]),
96
+ expected: 'scalar',
97
+ got: typeofX(v),
98
+ })
99
+ }
100
+ })
101
+ }
102
+ return
103
+ }
104
+ if (typeof value === 'object') {
105
+ mismatches.push({ path: pathStr(path), expected: 'scalar', got: typeofX(value) })
106
+ }
107
+ return
108
+ }
109
+
110
+ if (Array.isArray(rule)) {
111
+ if (!Array.isArray(value)) {
112
+ mismatches.push({ path: pathStr(path), expected: 'array', got: typeofX(value) })
113
+ return
114
+ }
115
+ const shape = rule[1]
116
+ value.forEach((item, i) => validateShape(shape, item, [...path, i], mismatches))
117
+ return
118
+ }
119
+
120
+ if (typeof rule === 'object' && rule !== null) {
121
+ if (Array.isArray(value) || typeof value !== 'object') {
122
+ mismatches.push({ path: pathStr(path), expected: 'object', got: typeofX(value) })
123
+ return
124
+ }
125
+ for (const [k, sub] of Object.entries(rule)) {
126
+ validateShape(sub, value[k], [...path, k], mismatches)
127
+ }
128
+ }
129
+ }
130
+
131
+ function typeofX(v) {
132
+ if (v === null) return 'null'
133
+ if (Array.isArray(v)) return 'array'
134
+ return typeof v
135
+ }
136
+
137
+ function pathStr(path) {
138
+ return path.join('.')
139
+ }
@@ -0,0 +1,129 @@
1
+ import { EmptyListInsert } from './errors.js'
2
+ import { extract } from './extract.js'
3
+
4
+ const SIMILARITY_THRESHOLD = 0.5
5
+
6
+ /**
7
+ * Reconcile `newItems` (array of incoming items) into the DOM subtree matched
8
+ * by `parentCtx.find(selector)`. `shape` is null for scalar lists (items are
9
+ * strings) or the rule shape for object lists.
10
+ *
11
+ * applyItem is passed in (normally `applyAt` from apply.js) to avoid a
12
+ * circular import; object-list items use it to recursively apply their shape.
13
+ */
14
+ export function listDiff(adapter, parentCtx, selector, shape, newItems, trace, applyItem) {
15
+ const oldNodes = adapter.find(parentCtx, selector)
16
+
17
+ if (newItems.length === 0) {
18
+ oldNodes.forEach((n) => adapter.remove(n))
19
+ return
20
+ }
21
+
22
+ const needsTemplate = newItems.length > oldNodes.length
23
+ if (needsTemplate && oldNodes.length === 0) throw new EmptyListInsert(trace.path)
24
+
25
+ const oldValues = oldNodes.map((n) => extractItem(adapter, n, shape))
26
+
27
+ let template = null
28
+ if (oldNodes.length > 0) {
29
+ template = adapter.clone(oldNodes[0])
30
+ const stripped = adapter.stripIds(template)
31
+ if (stripped > 0) {
32
+ // eslint-disable-next-line no-console
33
+ console.warn(
34
+ `[hyper-html-api] stripped ${stripped} id attribute(s) from cloned template at "${trace.path.join('.') || '(root)'}"`,
35
+ )
36
+ }
37
+ }
38
+
39
+ const matches = greedyMatch(newItems, oldValues, shape)
40
+
41
+ const parent = adapter.parent(oldNodes[0])
42
+ const anchorIdx = indexInParent(adapter, parent, oldNodes[0])
43
+
44
+ const used = new Set()
45
+ const finalNodes = newItems.map((_, i) => {
46
+ const oldIdx = matches[i]
47
+ if (oldIdx >= 0) {
48
+ used.add(oldIdx)
49
+ return oldNodes[oldIdx]
50
+ }
51
+ const cloned = adapter.clone(template)
52
+ adapter.stripIds(cloned)
53
+ return cloned
54
+ })
55
+
56
+ oldNodes.forEach((n, i) => {
57
+ if (!used.has(i)) adapter.remove(n)
58
+ })
59
+
60
+ used.forEach((i) => adapter.remove(oldNodes[i]))
61
+
62
+ finalNodes.forEach((node, i) => {
63
+ adapter.insertAt(parent, node, anchorIdx + i)
64
+ })
65
+
66
+ finalNodes.forEach((node, i) => {
67
+ if (shape === null) {
68
+ const v = newItems[i]
69
+ adapter.text(node, v == null ? '' : String(v))
70
+ } else {
71
+ applyItem(adapter, node, shape, newItems[i], {
72
+ depth: trace.depth + 1,
73
+ path: [...trace.path, i],
74
+ })
75
+ }
76
+ })
77
+ }
78
+
79
+ function extractItem(adapter, node, shape) {
80
+ if (shape === null) return adapter.text(node)
81
+ return extract(adapter, node, shape)
82
+ }
83
+
84
+ function greedyMatch(newItems, oldValues, shape) {
85
+ const matches = new Array(newItems.length).fill(-1)
86
+ const taken = new Set()
87
+ newItems.forEach((newItem, i) => {
88
+ let bestIdx = -1
89
+ let bestScore = -1
90
+ oldValues.forEach((oldVal, j) => {
91
+ if (taken.has(j)) return
92
+ const score = similarity(newItem, oldVal, shape)
93
+ const closerIdx =
94
+ score === bestScore && bestIdx >= 0
95
+ ? Math.abs(j - i) < Math.abs(bestIdx - i)
96
+ : false
97
+ if (score > bestScore || closerIdx) {
98
+ bestScore = score
99
+ bestIdx = j
100
+ }
101
+ })
102
+ if (bestScore >= SIMILARITY_THRESHOLD) {
103
+ matches[i] = bestIdx
104
+ taken.add(bestIdx)
105
+ }
106
+ })
107
+ return matches
108
+ }
109
+
110
+ function similarity(a, b, shape) {
111
+ if (shape === null) return a === b ? 1 : 0
112
+ const fields = Object.keys(shape || {})
113
+ if (fields.length === 0) return 0
114
+ let equal = 0
115
+ for (const f of fields) {
116
+ if (JSON.stringify(a == null ? undefined : a[f]) === JSON.stringify(b == null ? undefined : b[f])) {
117
+ equal++
118
+ }
119
+ }
120
+ return equal / fields.length
121
+ }
122
+
123
+ function indexInParent(adapter, parent, targetNode) {
124
+ const siblings = adapter.children(parent)
125
+ for (let i = 0; i < siblings.length; i++) {
126
+ if (adapter.sameNode(siblings[i], targetNode)) return i
127
+ }
128
+ return -1
129
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Names that are read/written via the DOM/cheerio .prop() interface rather
3
+ * than .attr(). Copied verbatim from hyperclay/server-lib/data-extractor.js
4
+ * so the new engine preserves byte-identical output.
5
+ */
6
+ export const DOM_PROPERTIES = [
7
+ 'textContent',
8
+ 'innerText',
9
+ 'innerHTML',
10
+ 'outerHTML',
11
+
12
+ 'value',
13
+ 'checked',
14
+ 'selected',
15
+ 'disabled',
16
+ 'readOnly',
17
+ 'type',
18
+
19
+ 'tagName',
20
+ 'nodeName',
21
+ 'nodeType',
22
+ 'nodeValue',
23
+ 'childElementCount',
24
+
25
+ 'id',
26
+ 'className',
27
+ 'classList',
28
+
29
+ 'href',
30
+ 'src',
31
+ 'action',
32
+ 'baseURI',
33
+
34
+ 'offsetWidth',
35
+ 'offsetHeight',
36
+ 'clientWidth',
37
+ 'clientHeight',
38
+ 'scrollWidth',
39
+ 'scrollHeight',
40
+
41
+ 'dataset',
42
+
43
+ 'currentSrc',
44
+ 'duration',
45
+ 'paused',
46
+
47
+ 'title',
48
+ 'documentURI',
49
+ 'contentType',
50
+ ]
51
+
52
+ export const DOM_PROPERTIES_SET = new Set(DOM_PROPERTIES)