@peripheries/engine 0.22.2 → 0.23.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 +5 -1
- package/src/commands/check-a11y.mjs +9 -0
- package/src/index.d.mts +9 -0
- package/src/lib/config.mjs +1 -1
- package/src/lib/run.mjs +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@peripheries/engine",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.23.0",
|
|
4
4
|
"description": "Build, check, knowledge and deploy for peripheries component repos",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "UNLICENSED",
|
|
@@ -39,6 +39,7 @@
|
|
|
39
39
|
"tsx": "4.23.5"
|
|
40
40
|
},
|
|
41
41
|
"peerDependencies": {
|
|
42
|
+
"axe-core": ">=4.10 <5",
|
|
42
43
|
"@peripheries/renderer": ">=0.6.2 <1",
|
|
43
44
|
"@peripheries/runtime": ">=0.6.2 <1",
|
|
44
45
|
"@vitejs/plugin-react": ">=6 <7",
|
|
@@ -54,6 +55,9 @@
|
|
|
54
55
|
},
|
|
55
56
|
"playwright": {
|
|
56
57
|
"optional": true
|
|
58
|
+
},
|
|
59
|
+
"axe-core": {
|
|
60
|
+
"optional": true
|
|
57
61
|
}
|
|
58
62
|
},
|
|
59
63
|
"publishConfig": {
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import{readFileSync,writeFileSync}from"node:fs";import{resolve}from"node:path";import{createRequire}from"node:module";import{pathToFileURL}from"node:url";const{loadConfig}=await import("../lib/config.mjs");const{REPO_ROOT}=await import("../lib/root.mjs");const{buildAndServeHarness}=await import("../harness/server.mjs");const fromRepo=createRequire(resolve(REPO_ROOT,"package.json"));const{chromium}=await import(pathToFileURL(fromRepo.resolve("playwright")).href).then(m=>m.chromium?m:m.default);const axeSource=readFileSync(fromRepo.resolve("axe-core/axe.min.js"),"utf8");const{a11y}=await loadConfig();const LINKS=[".core-link",".core-reference",...a11y.linkSelectors].join(", ");const argv=process.argv.slice(2);const flag=name=>argv.includes(`--${name}`)?argv[argv.indexOf(`--${name}`)+1]:void 0;const only=flag("only");const rectsOut=flag("rects");const rects={};const out=resolve(REPO_ROOT,flag("json")??process.env.A11Y_OUT??"a11y-sweep.json");const control=argv.includes("--control");const CONTROL_HTML=`<!doctype html><html lang="en"><head><meta charset="utf-8"><title>control</title></head><body style="margin:0;background:#fff">
|
|
3
|
+
<main><h1 style="font:16px sans-serif">Control</h1>
|
|
4
|
+
<p style="font:13px sans-serif;color:#ccc">Faint text: #ccc on white is about 1.6:1.</p>
|
|
5
|
+
<p style="font:10px sans-serif;color:#000">Ten pixel text.</p>
|
|
6
|
+
<button style="width:16px;height:16px;padding:0"></button>
|
|
7
|
+
<input style="font:16px sans-serif" type="text">
|
|
8
|
+
<p style="font:16px sans-serif;color:#222">Read the <a href="#" style="color:#333;text-decoration:none">policy</a> before you go.<style>a::after{content:'';position:absolute}</style></p>
|
|
9
|
+
</main></body></html>`;const CONTROL_EXPECTS=["axe:color-contrast","axe:button-name","axe:label","small_target","small_text","link_in_text"];const PAGE_RULES=Object.fromEntries(["document-title","landmark-one-main","page-has-heading-one","region"].map(id=>[id,{enabled:false}]));const PROFILES=[{width:390,ctx:{viewport:{width:390,height:844},hasTouch:true,isMobile:true,deviceScaleFactor:2}},{width:1280,ctx:{viewport:{width:1280,height:900}}}];const TARGET_MIN=a11y.targetTouch;const TARGET_MIN_FINE=a11y.targetMouse;const TEXT_MIN=a11y.textMin;const CONTROL_MIN=a11y.controlMin;const TAB_MAX=a11y.tabMax;const served=control?null:await buildAndServeHarness();const url=(q="")=>`${served.origin}${served.path}${q}`;const browser=await chromium.launch();let targets;if(control){targets=[{fixture:"(control)",scenario:"a deliberately bad page",control:true}]}else{const listPage=await browser.newPage();await listPage.goto(url());await listPage.waitForSelector('a[href*="fixture="]');const scenarios=await listPage.$$eval('a[href*="fixture="]',as=>as.map(a=>a.getAttribute("href")).filter(h=>h&&!h.includes("container=")));await listPage.close();targets=scenarios.map(href=>{const q=new URL(href,"file:///").searchParams;return{fixture:q.get("fixture"),scenario:q.get("scenario"),href}}).filter(t=>!only||t.fixture===only)}const measure=({coarse,targetMin,textMin,controlMin,links})=>{const visible=el=>{const r=el.getBoundingClientRect();const s=getComputedStyle(el);return r.width>0&&r.height>0&&s.visibility!=="hidden"&&s.display!=="none"&&Number(s.opacity)!==0};const name=el=>`${el.tagName.toLowerCase()}${el.id?`#${el.id}`:""}.${String(el.getAttribute("class")??"").split(/\s+/).filter(Boolean).slice(0,2).join(".")}`;const effective=el=>{const s=getComputedStyle(el);const box=el.getBoundingClientRect();let[x0,y0,x1,y1]=[box.left,box.top,box.right,box.bottom];const cb=s.position!=="static"?el:el.offsetParent;if(cb){const c=cb.getBoundingClientRect();const cs=getComputedStyle(cb);const[ox,oy]=[c.left+parseFloat(cs.borderLeftWidth),c.top+parseFloat(cs.borderTopWidth)];for(const which of["::before","::after"]){const p=getComputedStyle(el,which);if(p.content==="none"||p.position!=="absolute")continue;const[l,t,pw,ph]=[p.left,p.top,p.width,p.height].map(parseFloat);if([l,t,pw,ph].some(Number.isNaN))continue;[x0,y0,x1,y1]=[Math.min(x0,ox+l),Math.min(y0,oy+t),Math.max(x1,ox+l+pw),Math.max(y1,oy+t+ph)]}}for(const child of el.children){if(getComputedStyle(child).position!=="absolute"||child.getAttribute("aria-hidden")!=="true")continue;const c=child.getBoundingClientRect();if(c.width>0&&c.height>0)[x0,y0,x1,y1]=[Math.min(x0,c.left),Math.min(y0,c.top),Math.max(x1,c.right),Math.max(y1,c.bottom)]}return[x1-x0,y1-y0]};const found={small_target:[],small_text:[],small_control:[],link_in_text:[],no_focus_ring:[]};const interactive=[...document.querySelectorAll('a[href], button, input:not([type=hidden]), select, textarea, summary, [role=button], [role=link], [role=tab], [role=menuitem], [role=option], [role=checkbox], [role=radio], [role=switch], [role=treeitem], [tabindex]:not([tabindex="-1"])')].filter(visible);for(const el of interactive){const r=el.getBoundingClientRect();const s=getComputedStyle(el);const alone=(el.parentElement?.textContent??"").trim()===(el.textContent??"").trim()&&!el.matches(links);const inline=s.display==="inline"&&!alone;const wrapped=Boolean(el.closest("label"))&&effective(el.closest("label")).every(n=>n>=targetMin-.5);const words=(el.textContent??"").trim().length>0&&!(el instanceof HTMLInputElement);const short=r.height<targetMin-.5||(words?r.width<24:r.width<targetMin-.5);const scroller=el.matches("[tabindex]")&&!el.matches("a, button, input, select, textarea, summary, [role=button], [role=link], [role=tab], [role=menuitem], [role=option], [role=checkbox], [role=radio], [role=switch], [role=treeitem]");const hidden=s.clipPath!=="none"&&s.overflow==="hidden"||el.getAttribute("tabindex")==="-1"||Boolean(el.closest('[aria-hidden="true"]'));const disabled=el.matches(":disabled")||el.getAttribute("aria-disabled")==="true";if(inline||wrapped||scroller||hidden||disabled||!short)continue;const[w,h]=effective(el);const stillShort=h<targetMin-.5||(words?w<24:w<targetMin-.5);if(stillShort)found.small_target.push(`${name(el)} ${Math.round(w)}x${Math.round(h)} :: ${el.outerHTML.replace(/\s+/g," ").slice(0,130)}`)}const rgb=c=>{const m=/rgba?\(([^)]+)\)/.exec(c);return m?m[1].split(/[ ,/]+/).slice(0,3).map(Number):null};const lum=c=>{const[r,g,b]=c.map(v=>{const x=v/255;return x<=.03928?x/12.92:((x+.055)/1.055)**2.4});return .2126*r+.7152*g+.0722*b};for(const el of document.querySelectorAll("a[href]")){if(!visible(el)||el.closest('[aria-hidden="true"]'))continue;const parent=el.parentElement;if(!parent)continue;const others=(parent.textContent??"").replace(el.textContent??"","").trim();if(!others||getComputedStyle(el).display!=="inline"||getComputedStyle(parent).display==="flex"||getComputedStyle(parent).display==="grid")continue;const s=getComputedStyle(el);if(s.textDecorationLine.includes("underline")||s.borderBottomStyle!=="none"&&parseFloat(s.borderBottomWidth)>0)continue;const a=rgb(s.color);const b=rgb(getComputedStyle(parent).color);if(!a||!b)continue;const[hi,lo]=[lum(a),lum(b)].sort((x,y)=>y-x);const ratio=(hi+.05)/(lo+.05);if(ratio<3)found.link_in_text.push(`${name(el)} ${Math.round(ratio*100)/100}:1 against its words and not underlined :: ${el.outerHTML.replace(/\s+/g," ").slice(0,100)}`)}if(coarse){for(const el of document.querySelectorAll('input, select, textarea, [contenteditable=""], [contenteditable="true"]')){if(el instanceof HTMLInputElement&&["checkbox","radio","range","hidden","file","submit","button","reset","image","color"].includes(el.type))continue;if(!visible(el)||el.getAttribute("tabindex")==="-1"||el.closest('[aria-hidden="true"]'))continue;const px=parseFloat(getComputedStyle(el).fontSize);if(px<controlMin)found.small_control.push(`${name(el)} ${px}px`)}}const walker=document.createTreeWalker(document.body,NodeFilter.SHOW_TEXT);const seen=new Set;for(let n=walker.nextNode();n;n=walker.nextNode()){if(!n.nodeValue?.trim())continue;const el=n.parentElement;if(!el||seen.has(el)||el.closest('script, style, noscript, svg, [aria-hidden="true"], [role="img"]')||!visible(el))continue;seen.add(el);const px=parseFloat(getComputedStyle(el).fontSize);if(px>0&&px<textMin-.01)found.small_text.push(`${name(el)} ${Math.round(px*100)/100}px "${n.nodeValue.trim().slice(0,24)}"`)}return found};const focusRing=async()=>{const el=document.activeElement;if(!el||el===document.body)return null;const drawn=n=>{const s=getComputedStyle(n);return s.outlineStyle!=="none"&&parseFloat(s.outlineWidth)>0&&s.outlineColor!=="rgba(0, 0, 0, 0)"||Boolean(s.boxShadow&&s.boxShadow!=="none")||n instanceof SVGElement&&s.stroke!=="none"&&parseFloat(s.strokeWidth)>0};let ring=[el,...el.children].some(drawn);if(!ring){const sign=n=>{const c=getComputedStyle(n);return[c.outlineStyle,c.outlineWidth,c.outlineColor,c.boxShadow,c.borderTopColor,c.borderBottomColor,c.backgroundColor].join("|")};const up=[];for(let n=el.parentElement,i=0;n&&n!==document.body&&i<3;n=n.parentElement,i++)up.push(n);const on=up.map(sign);const settle=()=>new Promise(r=>setTimeout(r,60));el.blur();await settle();const off=up.map(sign);el.focus();await settle();ring=on.some((v,i)=>v!==off[i])}const name=`${el.tagName.toLowerCase()}${el.id?`#${el.id}`:""}.${String(el.getAttribute("class")??"").split(/\s+/).filter(Boolean).slice(0,2).join(".")}`;return{name,ring:Boolean(ring),matches:el.matches(":focus-visible")}};const findings=[];const add=(t,width,rule,detail)=>findings.push({component:t.fixture,scenario:t.scenario,width,rule,detail});let index=0;const run=async(t,p)=>{const context=await browser.newContext(p.ctx);const page=await context.newPage();try{if(t.control)await page.setContent(CONTROL_HTML);else await page.goto(url(`?${t.href.split("?")[1]}`));await page.waitForTimeout(600);if(rectsOut){rects[`${t.fixture}/${t.scenario}${p.width===1280?"":`@${p.width}`}`]=await page.evaluate(()=>[document.documentElement.scrollHeight,[...document.querySelectorAll("body *")].map(e=>{const r=e.getBoundingClientRect();return`${e.tagName}.${String(e.getAttribute("class")??"").split(" ")[0]}:${Math.round(r.width)}x${Math.round(r.height)}@${Math.round(r.left)},${Math.round(r.top)}`})])}await page.addScriptTag({content:axeSource});const axe=await page.evaluate(async RULES=>{const r=await window.axe.run(document,{runOnly:{type:"tag",values:["wcag2a","wcag2aa","wcag21a","wcag21aa","wcag22aa","best-practice"]},resultTypes:["violations"],rules:RULES});return r.violations.map(v=>{const d=v.nodes[0]?.any?.[0]?.data;const colours=d&&d.contrastRatio?` ${d.fgColor} on ${d.bgColor} = ${d.contrastRatio} (needs ${d.expectedContrastRatio}, ${d.fontSize})`:"";return{id:v.id,count:v.nodes.length,sample:`${String(v.nodes[0]?.target?.[0]??"")} :: ${(v.nodes[0]?.html??"").slice(0,100)}${colours}`}})},PAGE_RULES);for(const v of axe)add(t,p.width,`axe:${v.id}`,`x${v.count} ${v.sample}`);const m=await page.evaluate(measure,{coarse:p.width<600,targetMin:p.width<600?TARGET_MIN:TARGET_MIN_FINE,textMin:TEXT_MIN,controlMin:CONTROL_MIN,links:LINKS});for(const[rule,list]of Object.entries(m))if(rule!=="no_focus_ring")for(const d of list)add(t,p.width,rule,d);const stray=await page.evaluate(()=>[...document.querySelectorAll("[role=dialog][aria-modal=true]")].filter(d=>d.getBoundingClientRect().width>0&&!d.contains(document.activeElement)).map(d=>`${d.getAttribute("aria-label")??"dialog"}: focus is on ${document.activeElement?.tagName.toLowerCase()}`));for(const d of stray)add(t,p.width,"dialog_focus",d);const seen=new Set;for(let i=0;i<TAB_MAX;i++){await page.keyboard.press("Tab");const f=await page.evaluate(focusRing);if(!f||seen.has(f.name+i%1)){if(!f)break}if(seen.has(f.name))continue;seen.add(f.name);if(!f.ring&&!f.name.startsWith("iframe"))add(t,p.width,"no_focus_ring",f.name)}}catch(e){add(t,p.width,"error",String(e).slice(0,160))}finally{await context.close();if(++index%100===0)console.log(` ${index}/${targets.length*PROFILES.length}`)}};const jobs=targets.flatMap(t=>PROFILES.map(p=>({t,p})));const workers=Array.from({length:4},async()=>{for(let j=jobs.shift();j;j=jobs.shift())await run(j.t,j.p)});await Promise.all(workers);await browser.close();await served?.close();if(!control)writeFileSync(out,JSON.stringify({scenarios:targets.length,findings},null,1));if(rectsOut)writeFileSync(rectsOut,JSON.stringify(rects));const by=new Map;for(const f of findings)by.set(f.rule,(by.get(f.rule)??0)+1);const comps=rule=>new Set(findings.filter(f=>f.rule===rule).map(f=>f.component)).size;console.log(`${targets.length} scenarios at ${PROFILES.map(p=>p.width).join(" and ")}px: ${findings.length} findings`);for(const[rule,n]of[...by].sort((a,b)=>b[1]-a[1]))console.log(` ${rule.padEnd(28)} ${String(n).padStart(5)} in ${comps(rule)} components`);if(findings.length&&argv.includes("--fail")){for(const f of findings.slice(0,60))console.log(` ${f.component} \xB7 ${f.scenario} @${f.width}: ${f.rule} ${f.detail}`);process.exitCode=1}if(control){const seen=new Set(findings.map(f=>f.rule));const missing=CONTROL_EXPECTS.filter(r=>!seen.has(r));if(missing.length){console.log(`NEGATIVE CONTROL FAILED: the sweep did not report ${missing.join(", ")} on a page that has them; a green sweep means nothing.`);process.exitCode=1}else{console.log(`negative control ok: the sweep reports every known fault (${CONTROL_EXPECTS.join(", ")}).`);process.exitCode=0}}
|
package/src/index.d.mts
CHANGED
|
@@ -23,6 +23,14 @@ export type EngineConfig = {
|
|
|
23
23
|
hostScreens?: Record<string, string>;
|
|
24
24
|
externalComponents?: Record<string, string>;
|
|
25
25
|
audit?: { allowNames?: Record<string, string> };
|
|
26
|
+
a11y?: {
|
|
27
|
+
linkSelectors?: string[];
|
|
28
|
+
targetTouch?: number;
|
|
29
|
+
targetMouse?: number;
|
|
30
|
+
textMin?: number;
|
|
31
|
+
controlMin?: number;
|
|
32
|
+
tabMax?: number;
|
|
33
|
+
};
|
|
26
34
|
harness?: {
|
|
27
35
|
wrapper?: string;
|
|
28
36
|
publicWrapper?: string;
|
|
@@ -83,6 +91,7 @@ export type LoadedConfig = {
|
|
|
83
91
|
shadows: Record<string, string>;
|
|
84
92
|
hostScreens: Record<string, string>;
|
|
85
93
|
externalComponents: Record<string, string>;
|
|
94
|
+
a11y: { linkSelectors: string[]; targetTouch: number; targetMouse: number; textMin: number; controlMin: number; tabMax: number };
|
|
86
95
|
harness: { wrapper: string | null; publicWrapper: string | null; publicAssets: string; widths: number[]; container: number; overflow: 'warn' | 'error'; output: string; alias: Record<string, string> };
|
|
87
96
|
assets: { maxAssetBytes: number; maxTotalBytes: number };
|
|
88
97
|
audit: { allowNames: Record<string, string> };
|
package/src/lib/config.mjs
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{existsSync,readFileSync}from"node:fs";import{resolve}from"node:path";import{pathToFileURL}from"node:url";import{DEFAULT_ASSET_LIMITS}from"./assets.mjs";import{REPO_ROOT}from"./root.mjs";const MAIN_LINEAGE="main";const DEFAULT_CHECKS=["validate-externals","check-wired","check-routes","check-design-refs","check-skills","check-work-orders","check-cutover-ownership","check-fixtures","check-tokens","check-css-scope","check-inline-styles","check-shadowing","check-theme","check-inline-assets","check-page-reads","knowledge"];function budgetVerdict(name,count,budget){if(budget===void 0||budget===null)return{ok:true,message:null};if(count>budget)return{ok:false,message:`${count} finding(s), over the budget of ${budget}: the count may only fall (checks.budgets['${name}'])`};if(count<budget)return{ok:true,message:`${count} finding(s), under the budget of ${budget}: lower checks.budgets['${name}'] to ${count}`};return{ok:true,message:`${count} finding(s), at the budget`}}const PUBLIC_LINEAGE="public";const CONFIG_FILES=["engine.config.ts","engine.config.mts","engine.config.mjs","engine.config.js"];function defineConfig(config){return config}function lineageDefaults(name,kind){if(name===MAIN_LINEAGE){return{name,kind:"main",entry:"src/index.ts",sourceRoot:null,dist:"dist"}}if(name===PUBLIC_LINEAGE){return{name,kind:"public",entry:"src/index.public.ts",sourceRoot:"components/public/",dist:"dist-public"}}const k=kind??"page";return{name,kind:k,entry:`src/index.${name}.ts`,sourceRoot:k==="form"?`components/form/${name}/`:k==="shared"?`${name}/`:`components/project_page/${name}/`,dist:`dist-bundles/${name}`}}function normalize(raw,origin){if(!raw||typeof raw!=="object")throw new Error(`[engine] ${origin} must export a config object`);if(typeof raw.org!=="string"||!raw.org)throw new Error(`[engine] ${origin}: \`org\` (the org slug) is required`);const list=raw.lineages??[MAIN_LINEAGE];const lineages=list.map(l=>{const spec=typeof l==="string"?{name:l}:l;if(!spec?.name)throw new Error(`[engine] ${origin}: every lineage needs a name`);return{...lineageDefaults(spec.name,spec.kind),...spec}});const mainAt=lineages.findIndex(l=>l.name===MAIN_LINEAGE);if(mainAt>0)throw new Error(`[engine] ${origin}: main must be the first lineage`);if(mainAt===-1&&!lineages.every(l=>l.kind==="shared")){throw new Error(`[engine] ${origin}: lineages must start with "main", unless every lineage is shared`)}for(const l of lineages){if(l.kind==="shared"&&!/^@[a-z][a-z0-9-]*\/[a-z][a-z0-9-]*$/.test(l.specifier??"")){throw new Error(`[engine] ${origin}: shared lineage "${l.name}" needs a specifier like "@stack/${l.name}"`)}}const gated=raw.checks?.gated??{};const checks={run:raw.checks?.run??DEFAULT_CHECKS,peerRepos:raw.checks?.peerRepos??[],gated:{projectTemplates:gated.projectTemplates??[],orgTemplates:gated.orgTemplates??[]},budgets:raw.checks?.budgets??{},propsSchema:{families:raw.checks?.propsSchema?.families??null,names:raw.checks?.propsSchema?.names??[],floor:raw.checks?.propsSchema?.floor??1,propless:raw.checks?.propsSchema?.propless??[]}};const shadows=raw.shadows??{};const hostScreens=raw.hostScreens??{};if(hostScreens===null||typeof hostScreens!=="object"||Array.isArray(hostScreens))throw new Error(`[engine] ${origin}: hostScreens must be an object of name \u2192 reason`);for(const[name,reason]of Object.entries(hostScreens)){if(!/^[A-Za-z][A-Za-z0-9_]*$/.test(name))throw new Error(`[engine] ${origin}: hostScreens["${name}"] is not a component name`);if(typeof reason!=="string"||!reason.trim())throw new Error(`[engine] ${origin}: hostScreens["${name}"] needs a reason`)}const externalComponents=raw.externalComponents??{};if(externalComponents===null||typeof externalComponents!=="object"||Array.isArray(externalComponents))throw new Error(`[engine] ${origin}: externalComponents must be an object of specifier \u2192 file`);for(const[specifier,file]of Object.entries(externalComponents)){if(!/^@[a-z][a-z0-9-]*\/[a-z][a-z0-9-]*$/.test(specifier))throw new Error(`[engine] ${origin}: externalComponents["${specifier}"] is not a specifier like "@stack/platform"`);if(typeof file!=="string"||!file.trim())throw new Error(`[engine] ${origin}: externalComponents["${specifier}"] needs the path of a JSON file of names`)}const shared=raw.shared??[];const harness={wrapper:raw.harness?.wrapper??null,publicWrapper:raw.harness?.publicWrapper??null,publicAssets:raw.harness?.publicAssets??"template/data/storage/public-assets",widths:raw.harness?.widths??[390,768,1280],container:raw.harness?.container??320,overflow:raw.harness?.overflow??"warn",output:raw.harness?.output??"test/visual/__output__",alias:raw.harness?.alias??{}};const assets={maxAssetBytes:raw.assets?.maxAssetBytes??DEFAULT_ASSET_LIMITS.maxAssetBytes,maxTotalBytes:raw.assets?.maxTotalBytes??DEFAULT_ASSET_LIMITS.maxTotalBytes};for(const[k,v]of Object.entries(assets)){if(!Number.isInteger(v)||v<=0)throw new Error(`[engine] ${origin}: assets.${k} must be a positive number of bytes`)}const audit={allowNames:raw.audit?.allowNames??{}};for(const[name,reason]of Object.entries(audit.allowNames)){if(typeof reason!=="string"||!reason.trim())throw new Error(`[engine] ${origin}: audit.allowNames["${name}"] needs a reason`)}return{org:raw.org,lineages,checks,shared,shadows,hostScreens,externalComponents,harness,assets,audit}}const normalizeForTest=raw=>normalize(raw,"test");let cached;async function loadConfig(){if(cached)return cached;const file=CONFIG_FILES.map(f=>resolve(REPO_ROOT,f)).find(f=>existsSync(f));if(file){const mod=await import(pathToFileURL(file).href);cached=normalize(mod.default??mod,file);return cached}const pkg=JSON.parse(readFileSync(resolve(REPO_ROOT,"package.json"),"utf8"));let org=pkg.oaBundle?.name;const orgJson=resolve(REPO_ROOT,"template/config/org.json");if(existsSync(orgJson))org=JSON.parse(readFileSync(orgJson,"utf8")).slug??org;const lineages=(pkg.oaBundle?.lineages??[MAIN_LINEAGE]).map(n=>n==="workbooks"?{name:n,kind:"form"}:n);cached=normalize({org,lineages},"package.json oaBundle");return cached}async function lineageConfig(name){const{lineages}=await loadConfig();return lineages.find(l=>l.name===name)??lineageDefaults(name)}export{DEFAULT_CHECKS,MAIN_LINEAGE,PUBLIC_LINEAGE,budgetVerdict,defineConfig,lineageConfig,lineageDefaults,loadConfig,normalizeForTest};
|
|
1
|
+
import{existsSync,readFileSync}from"node:fs";import{resolve}from"node:path";import{pathToFileURL}from"node:url";import{DEFAULT_ASSET_LIMITS}from"./assets.mjs";import{REPO_ROOT}from"./root.mjs";const MAIN_LINEAGE="main";const DEFAULT_CHECKS=["validate-externals","check-wired","check-routes","check-design-refs","check-skills","check-work-orders","check-cutover-ownership","check-fixtures","check-tokens","check-css-scope","check-inline-styles","check-shadowing","check-theme","check-inline-assets","check-page-reads","knowledge"];function budgetVerdict(name,count,budget){if(budget===void 0||budget===null)return{ok:true,message:null};if(count>budget)return{ok:false,message:`${count} finding(s), over the budget of ${budget}: the count may only fall (checks.budgets['${name}'])`};if(count<budget)return{ok:true,message:`${count} finding(s), under the budget of ${budget}: lower checks.budgets['${name}'] to ${count}`};return{ok:true,message:`${count} finding(s), at the budget`}}const PUBLIC_LINEAGE="public";const CONFIG_FILES=["engine.config.ts","engine.config.mts","engine.config.mjs","engine.config.js"];function defineConfig(config){return config}function lineageDefaults(name,kind){if(name===MAIN_LINEAGE){return{name,kind:"main",entry:"src/index.ts",sourceRoot:null,dist:"dist"}}if(name===PUBLIC_LINEAGE){return{name,kind:"public",entry:"src/index.public.ts",sourceRoot:"components/public/",dist:"dist-public"}}const k=kind??"page";return{name,kind:k,entry:`src/index.${name}.ts`,sourceRoot:k==="form"?`components/form/${name}/`:k==="shared"?`${name}/`:`components/project_page/${name}/`,dist:`dist-bundles/${name}`}}function normalize(raw,origin){if(!raw||typeof raw!=="object")throw new Error(`[engine] ${origin} must export a config object`);if(typeof raw.org!=="string"||!raw.org)throw new Error(`[engine] ${origin}: \`org\` (the org slug) is required`);const list=raw.lineages??[MAIN_LINEAGE];const lineages=list.map(l=>{const spec=typeof l==="string"?{name:l}:l;if(!spec?.name)throw new Error(`[engine] ${origin}: every lineage needs a name`);return{...lineageDefaults(spec.name,spec.kind),...spec}});const mainAt=lineages.findIndex(l=>l.name===MAIN_LINEAGE);if(mainAt>0)throw new Error(`[engine] ${origin}: main must be the first lineage`);if(mainAt===-1&&!lineages.every(l=>l.kind==="shared")){throw new Error(`[engine] ${origin}: lineages must start with "main", unless every lineage is shared`)}for(const l of lineages){if(l.kind==="shared"&&!/^@[a-z][a-z0-9-]*\/[a-z][a-z0-9-]*$/.test(l.specifier??"")){throw new Error(`[engine] ${origin}: shared lineage "${l.name}" needs a specifier like "@stack/${l.name}"`)}}const gated=raw.checks?.gated??{};const checks={run:raw.checks?.run??DEFAULT_CHECKS,peerRepos:raw.checks?.peerRepos??[],gated:{projectTemplates:gated.projectTemplates??[],orgTemplates:gated.orgTemplates??[]},budgets:raw.checks?.budgets??{},propsSchema:{families:raw.checks?.propsSchema?.families??null,names:raw.checks?.propsSchema?.names??[],floor:raw.checks?.propsSchema?.floor??1,propless:raw.checks?.propsSchema?.propless??[]}};const shadows=raw.shadows??{};const hostScreens=raw.hostScreens??{};if(hostScreens===null||typeof hostScreens!=="object"||Array.isArray(hostScreens))throw new Error(`[engine] ${origin}: hostScreens must be an object of name \u2192 reason`);for(const[name,reason]of Object.entries(hostScreens)){if(!/^[A-Za-z][A-Za-z0-9_]*$/.test(name))throw new Error(`[engine] ${origin}: hostScreens["${name}"] is not a component name`);if(typeof reason!=="string"||!reason.trim())throw new Error(`[engine] ${origin}: hostScreens["${name}"] needs a reason`)}const externalComponents=raw.externalComponents??{};if(externalComponents===null||typeof externalComponents!=="object"||Array.isArray(externalComponents))throw new Error(`[engine] ${origin}: externalComponents must be an object of specifier \u2192 file`);for(const[specifier,file]of Object.entries(externalComponents)){if(!/^@[a-z][a-z0-9-]*\/[a-z][a-z0-9-]*$/.test(specifier))throw new Error(`[engine] ${origin}: externalComponents["${specifier}"] is not a specifier like "@stack/platform"`);if(typeof file!=="string"||!file.trim())throw new Error(`[engine] ${origin}: externalComponents["${specifier}"] needs the path of a JSON file of names`)}const shared=raw.shared??[];const harness={wrapper:raw.harness?.wrapper??null,publicWrapper:raw.harness?.publicWrapper??null,publicAssets:raw.harness?.publicAssets??"template/data/storage/public-assets",widths:raw.harness?.widths??[390,768,1280],container:raw.harness?.container??320,overflow:raw.harness?.overflow??"warn",output:raw.harness?.output??"test/visual/__output__",alias:raw.harness?.alias??{}};const assets={maxAssetBytes:raw.assets?.maxAssetBytes??DEFAULT_ASSET_LIMITS.maxAssetBytes,maxTotalBytes:raw.assets?.maxTotalBytes??DEFAULT_ASSET_LIMITS.maxTotalBytes};for(const[k,v]of Object.entries(assets)){if(!Number.isInteger(v)||v<=0)throw new Error(`[engine] ${origin}: assets.${k} must be a positive number of bytes`)}const audit={allowNames:raw.audit?.allowNames??{}};for(const[name,reason]of Object.entries(audit.allowNames)){if(typeof reason!=="string"||!reason.trim())throw new Error(`[engine] ${origin}: audit.allowNames["${name}"] needs a reason`)}const a11y={linkSelectors:raw.a11y?.linkSelectors??[],targetTouch:raw.a11y?.targetTouch??44,targetMouse:raw.a11y?.targetMouse??24,textMin:raw.a11y?.textMin??12,controlMin:raw.a11y?.controlMin??16,tabMax:raw.a11y?.tabMax??40};if(!Array.isArray(a11y.linkSelectors)||a11y.linkSelectors.some(s=>typeof s!=="string"||!s.trim()))throw new Error(`[engine] ${origin}: a11y.linkSelectors must be a list of CSS selectors`);for(const k of["targetTouch","targetMouse","textMin","controlMin","tabMax"]){if(!Number.isFinite(a11y[k])||a11y[k]<=0)throw new Error(`[engine] ${origin}: a11y.${k} must be a positive number`)}return{org:raw.org,lineages,checks,shared,shadows,hostScreens,externalComponents,harness,assets,audit,a11y}}const normalizeForTest=raw=>normalize(raw,"test");let cached;async function loadConfig(){if(cached)return cached;const file=CONFIG_FILES.map(f=>resolve(REPO_ROOT,f)).find(f=>existsSync(f));if(file){const mod=await import(pathToFileURL(file).href);cached=normalize(mod.default??mod,file);return cached}const pkg=JSON.parse(readFileSync(resolve(REPO_ROOT,"package.json"),"utf8"));let org=pkg.oaBundle?.name;const orgJson=resolve(REPO_ROOT,"template/config/org.json");if(existsSync(orgJson))org=JSON.parse(readFileSync(orgJson,"utf8")).slug??org;const lineages=(pkg.oaBundle?.lineages??[MAIN_LINEAGE]).map(n=>n==="workbooks"?{name:n,kind:"form"}:n);cached=normalize({org,lineages},"package.json oaBundle");return cached}async function lineageConfig(name){const{lineages}=await loadConfig();return lineages.find(l=>l.name===name)??lineageDefaults(name)}export{DEFAULT_CHECKS,MAIN_LINEAGE,PUBLIC_LINEAGE,budgetVerdict,defineConfig,lineageConfig,lineageDefaults,loadConfig,normalizeForTest};
|
package/src/lib/run.mjs
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
import{spawnSync}from"node:child_process";import{existsSync,readFileSync}from"node:fs";import{dirname,resolve}from"node:path";import{fileURLToPath}from"node:url";import{REPO_ROOT}from"./root.mjs";const COMMANDS_DIR=resolve(dirname(fileURLToPath(import.meta.url)),"..","commands");const TSX=import.meta.resolve("tsx/esm");const COMMANDS={build:"build.mjs",deploy:"deploy.mjs",check:"check.mjs",harness:"harness.mjs","test-visual":"test-visual.mjs",template:"template.mjs",upload:"upload-bundle.mjs",register:"register-components.mjs",cutover:"cutover.mjs",knowledge:"emit-knowledge.mjs",contracts:"emit-contracts.mjs","bundle-size":"bundle-size.mjs","check-bundle-leak":"check-bundle-leak.mjs","validate-externals":"validate-externals.mjs","check-wired":"check-wired-bindings.mjs","check-routes":"check-template-routes.mjs","check-design-refs":"check-design-refs.mjs","check-skills":"check-skills.mjs","check-work-orders":"check-work-orders.mjs","check-cutover-ownership":"check-cutover-ownership.mjs","check-fixtures":"check-fixtures.mjs","check-props-schema":"check-props-schema.mjs","check-tokens":"check-tokens.mjs","check-css-scope":"check-css-scope.mjs","check-inline-styles":"check-inline-styles.mjs","check-shadowing":"check-shadowing.mjs","check-theme":"check-theme.mjs","check-inline-assets":"check-inline-assets.mjs","check-page-reads":"check-page-reads.mjs"};function commandFile(name){if(!COMMANDS[name])return null;const file=resolve(COMMANDS_DIR,COMMANDS[name]);const ts=file.replace(/\.mjs$/,".mts");return existsSync(file)?file:existsSync(ts)?ts:null}function runCommand(name,args=[],env={}){const file=commandFile(name);if(!file)throw new Error(`[engine] unknown command "${name}"`);const r=spawnSync(process.execPath,["--import",TSX,file,...args],{cwd:REPO_ROOT,stdio:"inherit",env:{...process.env,ENGINE_ROOT:REPO_ROOT,...env}});return r.status??1}function commandUsage(name){const fallback=`usage: engine ${name} [args]`;const file=commandFile(name);if(!file)return fallback;const lines=readFileSync(file,"utf8").split("\n");let i=lines[0]?.startsWith("#!")?1:0;const out=[];if(lines[i]?.trim().startsWith("/**")){for(i+=1;i<lines.length&&!lines[i].includes("*/");i++)out.push(lines[i].replace(/^\s*\*\s?/,""))}else{for(;i<lines.length&&lines[i].startsWith("//");i++)out.push(lines[i].replace(/^\/\/\s?/,""))}const text=out.join("\n").trim();return text?`engine ${name}
|
|
1
|
+
import{spawnSync}from"node:child_process";import{existsSync,readFileSync}from"node:fs";import{dirname,resolve}from"node:path";import{fileURLToPath}from"node:url";import{REPO_ROOT}from"./root.mjs";const COMMANDS_DIR=resolve(dirname(fileURLToPath(import.meta.url)),"..","commands");const TSX=import.meta.resolve("tsx/esm");const COMMANDS={build:"build.mjs",deploy:"deploy.mjs",check:"check.mjs",harness:"harness.mjs","test-visual":"test-visual.mjs",template:"template.mjs",upload:"upload-bundle.mjs",register:"register-components.mjs",cutover:"cutover.mjs",knowledge:"emit-knowledge.mjs",contracts:"emit-contracts.mjs","bundle-size":"bundle-size.mjs","check-bundle-leak":"check-bundle-leak.mjs","validate-externals":"validate-externals.mjs","check-wired":"check-wired-bindings.mjs","check-routes":"check-template-routes.mjs","check-design-refs":"check-design-refs.mjs","check-skills":"check-skills.mjs","check-work-orders":"check-work-orders.mjs","check-cutover-ownership":"check-cutover-ownership.mjs","check-fixtures":"check-fixtures.mjs","check-props-schema":"check-props-schema.mjs","check-tokens":"check-tokens.mjs","check-css-scope":"check-css-scope.mjs","check-inline-styles":"check-inline-styles.mjs","check-shadowing":"check-shadowing.mjs","check-theme":"check-theme.mjs","check-inline-assets":"check-inline-assets.mjs","check-page-reads":"check-page-reads.mjs","check-a11y":"check-a11y.mjs"};function commandFile(name){if(!COMMANDS[name])return null;const file=resolve(COMMANDS_DIR,COMMANDS[name]);const ts=file.replace(/\.mjs$/,".mts");return existsSync(file)?file:existsSync(ts)?ts:null}function runCommand(name,args=[],env={}){const file=commandFile(name);if(!file)throw new Error(`[engine] unknown command "${name}"`);const r=spawnSync(process.execPath,["--import",TSX,file,...args],{cwd:REPO_ROOT,stdio:"inherit",env:{...process.env,ENGINE_ROOT:REPO_ROOT,...env}});return r.status??1}function commandUsage(name){const fallback=`usage: engine ${name} [args]`;const file=commandFile(name);if(!file)return fallback;const lines=readFileSync(file,"utf8").split("\n");let i=lines[0]?.startsWith("#!")?1:0;const out=[];if(lines[i]?.trim().startsWith("/**")){for(i+=1;i<lines.length&&!lines[i].includes("*/");i++)out.push(lines[i].replace(/^\s*\*\s?/,""))}else{for(;i<lines.length&&lines[i].startsWith("//");i++)out.push(lines[i].replace(/^\/\/\s?/,""))}const text=out.join("\n").trim();return text?`engine ${name}
|
|
2
2
|
|
|
3
3
|
${text}`:fallback}export{COMMANDS,commandFile,commandUsage,runCommand};
|