@symbo.ls/brender 3.14.678 → 3.14.679

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/CHANGELOG.md CHANGED
@@ -1,5 +1,27 @@
1
1
  # @symbo.ls/brender
2
2
 
3
+ ## 3.14.679
4
+
5
+ ### Patch Changes
6
+
7
+ - c80283f: The faces a built page takes are the faces it draws (XMA r12 results, item 2). `window.__smblsFacesReady` and `brender.faces.inline: true` took every face of a family the page's styles name when any character of the page was in its range: a page that draws a family only in bold headings asked for its regular face too, and a page that draws TBCContractica 300/400/500/700 loaded 100 and 900 as well.
8
+
9
+ `@symbo.ls/brender` exports `drawnFaces(html, { readSheet })` (also `@symbo.ls/brender/drawnFaces`): which `@font-face` rules of the page's faces sheet its elements draw, read as the browser picks them — each element's family, weight and style from the cascade (`!important`, specificity, order, inheritance, `var()`, the `font` shorthand, the browser's bold headings, italic emphasis and system-font form controls), each character of its own text in the face of its family list that holds it (style, then weight, then `unicode-range`). Rules under `@media` / `@supports` / `@container` / `@layer` and for states (`:hover`, `:focus`, …) count as possibilities where they would win; `::placeholder` and the strings of `::before` / `::after` count. It returns `Map<rule index, characters>`, or `null` when a stylesheet of the page cannot be read.
10
+
11
+ `smbls build` uses it: `inline: true` inlines the faces the page draws, the list form the drawn faces of the listed families, and the function form gets `face.drawn` (the characters drawn in that face; `null` when the page was not read). The faces promise loads the drawn faces by address, each with a character drawn in it. The page's `<link rel="stylesheet">` files and their `@import`s are read from the build output; a page with a stylesheet the build cannot read, or a build with a brender that predates the reader, keeps the earlier rule. The log says on how many pages the drawn faces were read (`Faces drawn: read on N of M pages`), and the report says it per page (`facesRead`).
12
+
13
+ - Updated dependencies [bfef7bf]
14
+ - Updated dependencies [0e57128]
15
+ - Updated dependencies [9f253fc]
16
+ - Updated dependencies [9f253fc]
17
+ - Updated dependencies [de2f734]
18
+ - Updated dependencies [d9194ce]
19
+ - @symbo.ls/element@3.14.819
20
+ - @symbo.ls/utils@3.14.855
21
+ - smbls@3.14.862
22
+ - @symbo.ls/fetch@3.14.675
23
+ - @symbo.ls/helmet@3.14.674
24
+
3
25
  ## 3.14.678
4
26
 
5
27
  ### Patch Changes
package/README.md CHANGED
@@ -209,6 +209,8 @@ No inline script — a strict Content-Security-Policy (`script-src 'self'`, no `
209
209
 
210
210
  The marks describe the faces by address. `smbls build` marks each page again after its `file:` URLs point into the build output, so the version follows the files the page ships (every page of a build carries the same one when the font URLs are site-root or absolute; a relative URL differs by the page's directory, and so does the version). Its `brender.faces.inline` step moves files into the sheet as `data:` URLs and keeps both marks as they were: an inlined face still names its file. A tool that changes the addresses in a page itself calls `markFacesSheet(html)` (exported): it writes both marks again from the rules the sheet holds, leaves the sheet's text and its other attributes as they are, and returns a page without a faces sheet unchanged. `facesSheetMarks(rules)` returns `{ version, files }` for a sheet's text or its rules (`null` without a `@font-face`).
211
211
 
212
+ **The faces a page draws.** `drawnFaces(html, { readSheet })` (exported, also as `@symbo.ls/brender/drawnFaces`) reads which faces of the sheet the page's elements draw, as the browser picks them: each element's family, weight and style from the cascade (`!important`, specificity, order, inheritance, `var()`, the `font` shorthand, the browser's own rules for headings, emphasis and form controls), each character of its text against the faces' `unicode-range`. Rules under `@media` / `@supports` / `@container` / `@layer` and for states (`:hover`, …) count as possibilities. It returns a `Map` from a `@font-face` rule's index in the sheet to the characters drawn in it, or `null` when a stylesheet of the page cannot be read: `readSheet(href, from)` returns the text of a `<link rel="stylesheet">` file or an `@import` (`from`: the address of the sheet that imports it), or `null`. A `<link data-smbls-font>` (a font service's `@font-face` sheet) is not read. `smbls build` uses it for `brender.faces.inline: true` and `window.__smblsFacesReady` (CLI README, "Which faces a page draws").
213
+
212
214
  The runtime also reads what pages from brender up to 3.14.664 carry — the inline `window.__BRENDER__` flag, `window.__BR_REGISTRY__`, `window.__BRENDER_HYDRATE__` — and, when a policy blocked that flag, takes a `data-br` element directly in the mount node for the mark. An smbls runtime older than this format does not know the `<meta>`: keep `smbls` and `@symbo.ls/brender` at the same release.
213
215
 
214
216
  **The data is the build's.** A pre-rendered page shows the answers its render got until it is rendered again — a `smbls build` page until the next build. A page flagged stale (`<html data-smbls-stale>` from the host, or the project's `fetch.seed.isStale(stamp)` / `fetch.seed.maxAge`) shows them at once and then, after the adoption, asks again and swaps in what changed; `fetch.seed: false` keeps the answers out of the page. See `@symbo.ls/fetch`, "A pre-built page shows the data of its build".
@@ -0,0 +1,3 @@
1
+ import{parseHTML as ue}from"linkedom";import{stylesheetRules as he}from"./pageWeight.js";const G=/<style\b[^>]*\bdata-smbls-faces\b[^>]*>([\s\S]*?)<\/style\s*>/i,me=/@font-face\s*\{([^}]*)\}/gi,de=/<(script|template|noscript)\b([^>]*)>[\s\S]*?<\/\1\s*>/gi,H=8,J=new Set(["serif","sans-serif","monospace","cursive","fantasy","system-ui","ui-serif","ui-sans-serif","ui-monospace","ui-rounded","math","emoji","fangsong","-apple-system","blinkmacsystemfont"]),pe=new Set(["hover","focus","focus-visible","focus-within","active","visited","link","any-link","target","target-within","checked","indeterminate","default","disabled","enabled","read-only","read-write","required","optional","valid","invalid","user-valid","user-invalid","in-range","out-of-range","placeholder-shown","autofill","-webkit-autofill","open","closed","popover-open","modal","fullscreen","picture-in-picture","playing","paused","current","past","future","defined","lang","dir","state","blank","local-link","scope","host","host-context"]),V=new Set(["before","after","first-line","first-letter"]),B=new Set(["is","where","matches","-webkit-any","-moz-any","has"]),ge=`h1,h2,h3,h4,h5,h6,th{font-weight:bold}b,strong{font-weight:bolder}
2
+ em,i,cite,var,dfn,address{font-style:italic}code,kbd,samp,pre,tt,listing,xmp,plaintext{font-family:monospace}
3
+ input,textarea,select,button{font-family:system-ui;font-weight:400;font-style:normal}`,P=new Set(["font","font-family","font-weight","font-style"]),F=/^\s*(?:only\s+)?(?:all|screen)\s*$/i,K=/var\(\s*(--[\w-]+)/g,q=(t,s)=>{let n=0,o=null;for(let r=s;r<t.length;r++){const e=t[r];if(o){e==="\\"?r++:e===o&&(o=null);continue}if(e==='"'||e==="'")o=e;else if(e==="(")n++;else if(e===")"&&!--n)return r}return-1},L=(t,s)=>{const n=[];let o=0,r=null,e=0;for(let i=0;i<t.length;i++){const l=t[i];if(r){l==="\\"?i++:l===r&&(r=null);continue}l==='"'||l==="'"?r=l:l==="("||l==="["||l==="{"?o++:l===")"||l==="]"||l==="}"?o=Math.max(0,o-1):l===s&&!o&&(n.push(t.slice(e,i)),e=i+1)}return n.push(t.slice(e)),n},X=/[\w\- -￿]/,C=(t,s)=>{let n=s;for(;n<t.length;)if(t[n]==="\\")n+=2;else if(X.test(t[n]))n++;else break;return n},Q=t=>{let s=0,n=0,o=0;const r=e=>{s+=Math.floor(e/1e6),n+=Math.floor(e/1e3)%1e3,o+=e%1e3};for(let e=0;e<t.length;){const i=t[e];if(i==='"'||i==="'"){const l=t.indexOf(i,e+1);e=l===-1?t.length:l+1}else if(i==="#")s++,e=C(t,e+1);else if(i===".")n++,e=C(t,e+1);else if(i==="["){n++;const l=t.indexOf("]",e);e=l===-1?t.length:l+1}else if(i===":"){const l=t[e+1]===":",u=e+(l?2:1),f=C(t,u),h=t.slice(u,f).toLowerCase();let g=null,a=f;if(t[f]==="("){const m=q(t,f);g=t.slice(f+1,m===-1?t.length:m),a=m===-1?t.length:m+1}l||V.has(h)?o++:h==="where"||(B.has(h)||h==="not"?g&&r(Math.max(0,...L(g,",").map(m=>Q(m.trim())))):n++),e=a}else i==="*"||i===">"||i==="+"||i==="~"||i==="|"||/\s/.test(i)?e++:X.test(i)||i==="\\"?(o++,e=C(t,e)):e++}return s*1e6+n*1e3+o},Z=t=>{let s=!1,n="element";return{text:(e=>{let i="";for(let l=0;l<e.length;){const u=e[l];if(u==='"'||u==="'"){const f=e.indexOf(u,l+1),h=f===-1?e.length:f+1;i+=e.slice(l,h),l=h}else if(u==="["){const f=e.indexOf("]",l),h=f===-1?e.length:f+1;i+=e.slice(l,h),l=h}else if(u===":"){const f=e[l+1]===":",h=l+(f?2:1),g=C(e,h),a=e.slice(h,g).toLowerCase();let m=null,O=g;if(e[g]==="("){const d=q(e,g);m=e.slice(g+1,d===-1?e.length:d),O=d===-1?e.length:d+1}if(f||V.has(a))a==="placeholder"||a==="-webkit-input-placeholder"||a==="-moz-placeholder"?n="placeholder":a==="before"||a==="after"?n=a:a==="first-line"||a==="first-letter"?s=!0:n=null,i+=":is(*)";else if(a==="not"&&m!==null){const d=ee(m);d.conditional?(s=!0,i+=":is(*)"):i+=`:not(${d.text})`}else if(B.has(a)&&m!==null){const d=ee(m);d.conditional&&(s=!0),i+=`:${a==="matches"||a.endsWith("-any")?"is":a}(${d.text||"*"})`}else pe.has(a)||a.startsWith("-webkit-")||a.startsWith("-moz-")?(s=!0,i+=":is(*)"):i+=e.slice(l,O);l=O}else i+=u,l++}return i})(t),conditional:s,target:n}},ye=t=>{let s="";for(let n=0;n<t.length;){const o=t[n];if(o==='"'||o==="'"||o==="["){const r=t.indexOf(o==="["?"]":o,n+1),e=r===-1?t.length:r+1;s+=t.slice(n,e),n=e}else if(o===":"){let r=C(t,n+(t[n+1]===":"?2:1));if(t[r]==="("){const e=q(t,r);r=e===-1?t.length:e+1}s+=":is(*)",n=r}else s+=o,n++}return s},ee=t=>{let s=!1;return{text:L(t,",").map(o=>{const r=Z(o.trim());return r.conditional&&(s=!0),r.text||"*"}).join(", "),conditional:s}},te=t=>{const s=[];for(const n of L(t.replace(/\/\*[\s\S]*?\*\//g,""),";")){if(n.includes("{"))continue;const o=n.indexOf(":");if(o===-1)continue;const r=n.slice(0,o).trim(),e=r.startsWith("--")?r:r.toLowerCase();if(!P.has(e)&&e!=="content"&&!e.startsWith("--"))continue;let i=n.slice(o+1).trim();const l=/!\s*important\s*$/i.test(i);l&&(i=i.replace(/!\s*important\s*$/i,"").trim()),s.push({prop:e,value:i,important:l})}return s},we=/^@import\s+(?:url\(\s*)?(['"]?)([^'")\s]+)\1\s*\)?\s*([^;]*)/i,T=(t,s,n,o,r=null,e=[])=>{const i=he(t);if(!i)return n.unknown=!0,e;for(const l of i){const u=l.indexOf("{");if(u===-1){const a=we.exec(l);if(!a)continue;const m=n.readSheet?n.readSheet(a[2],o):null;typeof m!="string"?n.unknown=!0:T(m,s||!!a[3].trim()&&!F.test(a[3]),n,a[2],r,e);continue}if(!l.endsWith("}"))continue;const f=l.slice(0,u).trim(),h=l.slice(u+1,-1);if(f[0]==="@"){const a=(/^@([\w-]+)/.exec(f)||[])[1]?.toLowerCase();a==="media"?T(h,s||!F.test(f.slice(6)),n,o,r,e):(a==="supports"||a==="container"||a==="document"||a==="scope"||a==="starting-style"||a==="layer")&&T(h,!0,n,o,r,e);continue}const g=r?L(f,",").map(a=>a.includes("&")?a.replace(/&/g,`:is(${r})`):`:is(${r}) ${a.trim()}`).join(", "):f;if(e.push({selector:g,block:h,conditional:s}),h.includes("{")){const a=h.slice(h.search(/[^;{}]*\{/));T(a,s,n,o,g,e)}}return e},N=(t,s,n=0)=>{const o=t.indexOf("var(");if(o===-1)return[t];if(n>8)return[];const r=q(t,o+3);if(r===-1)return[];const e=t.slice(o+4,r),i=L(e,","),l=i[0].trim(),u=i.length>1?i.slice(1).join(",").trim():null,f=s.get(l),h=f&&f.length?f:u!==null?N(u,s,n+1):[],g=[];for(const a of h)for(const m of N(t.slice(0,o)+a+t.slice(r+1),s,n+1))if(g.includes(m)||g.push(m),g.length>=H)return g;return g},ne=t=>t.trim().replace(/^(['"])([\s\S]*)\1$/,"$2").replace(/\\(.)/g,"$1").trim().toLowerCase(),be=t=>L(t,",").map(ne).filter(Boolean),se={normal:400,bold:700},oe=(t,s)=>{const n=String(t).trim().toLowerCase();if(n in se)return se[n];if(n==="bolder")return s<350?400:s<550?700:900;if(n==="lighter")return s<550?100:s<750?400:700;const o=Number(n);return Number.isFinite(o)&&o>=1&&o<=1e3?o:null},ie=t=>{const s=String(t).trim().toLowerCase().split(/\s+/)[0];return s==="italic"||s==="oblique"||s==="normal"?s:null},Se=/\\([0-9a-f]{1,6})\s?|\\([\s\S])/gi,ke=(t,s)=>{let n="";for(const o of t.matchAll(/(["'])((?:\\[\s\S]|(?!\1)[^\\])*)\1|attr\(\s*([\w-]+)[^)]*\)/gi))o[3]?n+=s.getAttribute(o[3])||"":n+=o[2].replace(Se,(r,e,i)=>{if(!e)return i;const l=parseInt(e,16);return l>0&&l<=1114111?String.fromCodePoint(l):"\uFFFD"});return n},ve=new Set(["caption","icon","menu","message-box","small-caption","status-bar"]),Oe=t=>{const s=t.trim();if(ve.has(s.toLowerCase()))return{family:"system-ui",weight:"normal",style:"normal"};const n=[];let o=s;for(;;){const u=/^\s*([^\s,'"]+)/.exec(o);if(!u)break;const f=u[1];if(/^[\d.]/.test(f)&&!/^\d{1,4}$/.test(f)||/^\d{1,4}$/.test(f)&&Number(f)>1e3)break;const h=f.toLowerCase();if(/^(xx-small|x-small|small|medium|large|x-large|xx-large|xxx-large|larger|smaller|calc\(|clamp\(|min\(|max\()/.test(h)||(n.push(h),o=o.slice(u[0].length),n.length>4))break}const r=/^\s*(?:[^\s/]+|(?:calc|clamp|min|max)\([^)]*\))(?:\s*\/\s*[^\s]+)?\s+/.exec(o);if(!r)return null;const e=o.slice(r[0].length).trim();if(!e)return null;let i="normal",l="normal";for(const u of n)u==="italic"||u==="oblique"?l=u:(u==="bold"||u==="bolder"||u==="lighter"||/^\d{1,4}$/.test(u))&&(i=u);return{family:e,weight:i,style:l}},_=(t,s)=>{const n=new RegExp(`(?:^|;|\\s)${s}\\s*:\\s*([^;]+)`,"i").exec(t);return n?n[1].trim():null},Ae=t=>{if(!t)return null;const s=[];for(const n of String(t).split(",")){const o=n.trim().replace(/^u\+/i,"");if(!o)continue;if(o.includes("?")){s.push([parseInt(o.replace(/\?/g,"0"),16),parseInt(o.replace(/\?/g,"f"),16)]);continue}const[r,e]=o.split("-"),i=parseInt(r,16);s.push([i,e?parseInt(e,16):i])}return s.length?s:null},Ee=t=>{const s=[];let n=0;for(const o of t.matchAll(me)){const r=o[1],e=ne(_(r,"font-family")||""),i=String(_(r,"font-weight")||"400").trim().split(/\s+/).map(l=>oe(l,400)||400);s.push({index:n++,family:e,min:Math.min(...i),max:Math.max(...i),style:ie(_(r,"font-style")||"normal")||"normal",ranges:Ae(_(r,"unicode-range"))})}return s},le={italic:["italic","oblique","normal"],oblique:["oblique","italic","normal"],normal:["normal","oblique","italic"]},xe=(t,s)=>{for(const n of le[s]||le.normal){const o=t.filter(r=>r.style===n);if(o.length)return o}return t},Me=(t,s)=>{const n=t.filter(i=>i.min<=s&&s<=i.max);if(n.length)return n;const o=t.filter(i=>i.max<s).sort((i,l)=>l.max-i.max),r=t.filter(i=>i.min>s).sort((i,l)=>i.min-l.min);let e;return s>=400&&s<=500?e=r.find(i=>i.min<=500)||o[0]||r[0]:s<400?e=o[0]||r[0]:e=r[0]||o[0],e?t.filter(i=>i.min===e.min&&i.max===e.max):[]},Le=(t,s)=>!t.ranges||t.ranges.some(([n,o])=>s>=n&&s<=o),re=t=>t.ua?0:t.important?2:1,ce=(t,s)=>{const n=re(t),o=re(s);return n!==o?n>o:t.rank!==s.rank?t.rank>s.rank:t.order>s.order},D=t=>{let s=null;for(const o of t)!o.conditional&&(!s||ce(o,s))&&(s=o);const n=s?[s]:[];for(const o of t)o.conditional&&(!s||ce(o,s))&&n.push(o);return{list:n,inherit:!s}},U=t=>{const s=[],n=new Set;for(const o of t){const r=JSON.stringify(o);if(!n.has(r)&&(n.add(r),s.push(o),s.length>=H))break}return s},ae=(t,s)=>{K.lastIndex=0;let n;for(;n=K.exec(t);)s.add(n[1]);return s},fe={vars:new Map,family:[["serif"]],weight:[400],style:["normal"]},Ce=new Set(["script","style","template","noscript","head","title"]),Y=(t,s)=>{const n=new Map(s.vars),o=[...new Set(t.filter(e=>e.prop.startsWith("--")).map(e=>e.prop))];for(const e of o){const{list:i,inherit:l}=D(t.filter(f=>f.prop===e)),u=l?[...s.vars.get(e)||[]]:[];for(const f of i){const h=f.value.trim();/^(inherit|unset)$/i.test(h)?u.push(...s.vars.get(e)||[]):/^(initial|revert|revert-layer)$/i.test(h)||u.push(h)}n.set(e,U(u))}for(const e of o)n.set(e,U(n.get(e).flatMap(i=>N(i,n))));const r={vars:n};for(const e of["family","weight","style"]){const i="font-"+e,{list:l,inherit:u}=D(t.filter(h=>h.prop===i||h.prop==="font")),f=u?[...s[e]]:[];for(const h of l){const g=f.length;for(const a of N(h.value,n)){const m=a.trim().toLowerCase();if(m==="inherit"||m==="unset"){f.push(...s[e]);continue}if(m==="initial"||m==="revert"||m==="revert-layer"){f.push(...fe[e]);continue}let O=a;if(h.prop==="font"){const d=Oe(a);if(!d)continue;O=d[e]}if(e==="family")f.push(be(O));else if(e==="style"){const d=ie(O);d&&f.push(d)}else for(const d of s.weight){const $=oe(O,d);$&&f.push($)}}f.length===g&&f.push(...s[e])}r[e]=U(f)}return r},Ne=(t,s={})=>{const n=new Map,o=G.exec(t);if(!o)return n;const r=Ee(o[1]);if(!r.length)return n;const e=new Map;for(const c of r)e.has(c.family)||e.set(c.family,[]),e.get(c.family).push(c);const i=t.replace(G,"<style data-smbls-faces></style>").replace(de,"<$1$2></$1>"),{document:l}=ue(i),u={readSheet:typeof s.readSheet=="function"?s.readSheet:null,unknown:!1},f=[],h=(c,y,w,b)=>{for(const S of T(c,y,u,b)){const p=te(S.block);p.length&&f.push({selector:S.selector,decls:p,conditional:S.conditional,ua:w})}};h(ge,!1,!0);for(const c of l.querySelectorAll("style, link")){const y=c.getAttribute("media"),w=!!y&&!F.test(y);if(c.localName==="style"){c.hasAttribute("data-smbls-faces")||h(c.textContent||"",w,!1);continue}const b=(c.getAttribute("rel")||"").toLowerCase().split(/\s+/);if(!b.includes("stylesheet")||c.hasAttribute("data-smbls-font")||c.hasAttribute("disabled"))continue;const S=c.getAttribute("href")||"",p=u.readSheet&&S?u.readSheet(S):null;if(typeof p!="string")return null;h(p,w||b.includes("alternate"),!1,S)}if(u.unknown)return null;const g=[];for(const c of l.querySelectorAll("[style]")){const y=te(c.getAttribute("style")||"");y.length&&g.push({el:c,decls:y})}const a=new Set,m=[...f.flatMap(c=>c.decls),...g.flatMap(c=>c.decls)];for(const c of m)(P.has(c.prop)||c.prop==="content")&&ae(c.value,a);for(let c=-1;c!==a.size;){c=a.size;for(const y of m)a.has(y.prop)&&ae(y.value,a)}const O=c=>P.has(c.prop)||c.prop==="content"||a.has(c.prop),d={element:new Map,placeholder:new Map,before:new Map,after:new Map};let $=0;for(const c of f){const y=c.decls.filter(O);if(y.length)for(const w of L(c.selector,",")){const b=w.trim();if(!b)continue;let{text:S,conditional:p,target:E}=Z(b);if(!E)continue;let k;try{k=l.querySelectorAll(S)}catch{try{k=l.querySelectorAll(ye(S))}catch{continue}p=!0}const A=c.ua?0:Q(b),x=y.map(v=>({...v,rank:A,order:++$,conditional:p||c.conditional,ua:c.ua})),M=d[E];for(const v of k)M.has(v)||M.set(v,[]),M.get(v).push(...x)}}for(const{el:c,decls:y}of g){const w=y.filter(O);w.length&&(d.element.has(c)||d.element.set(c,[]),d.element.get(c).push(...w.map(b=>({...b,rank:1e9,order:++$,conditional:!1,ua:!1}))))}const I=new Map,W=(c,y)=>{if(y.trim())for(const w of c.family)for(const b of c.weight)for(const S of c.style){const p=JSON.stringify([w,b,S]);I.set(p,(I.get(p)||"")+y)}},j=(c,y)=>{const w=Y(d.element.get(c)||[],y);let b="";for(const p of c.childNodes)p.nodeType===3?b+=p.textContent:p.nodeType===1&&!Ce.has(p.localName)&&j(p,w);const S=c.localName;if(S==="input"||S==="textarea"){b+=" "+(c.getAttribute("value")||"");const p=c.getAttribute("placeholder");p&&W(d.placeholder.has(c)?Y(d.placeholder.get(c),w):w,p)}W(w,b);for(const p of["before","after"]){const E=d[p].get(c);if(!E)continue;const k=Y(E,w);for(const A of D(E.filter(x=>x.prop==="content")).list)for(const x of N(A.value,k.vars))W(k,ke(x,c))}};l.documentElement&&j(l.documentElement,fe);for(const[c,y]of I){const[w,b,S]=JSON.parse(c),p=new Map;for(const k of w){if(J.has(k))break;const A=e.get(k);A&&!p.has(k)&&p.set(k,Me(xe(A,S),b))}if(!p.size)continue;const E=new Set;for(const k of y){if(E.has(k))continue;E.add(k);const A=k.codePointAt(0);if(!(A<=32||A===160))for(const x of w){if(J.has(x))break;const M=p.get(x)||[];let v=null;for(let R=M.length-1;R>=0&&!v;R--)Le(M[R],A)&&(v=M[R]);if(!v)continue;const z=n.get(v.index)||"";z.includes(k)||n.set(v.index,z+k);break}}}return n};export{Ne as drawnFaces};
package/dist/esm/index.js CHANGED
@@ -1 +1 @@
1
- import{createEnv as e}from"./env.js";import{resetKeys as r,assignKeys as t,mapKeysToElements as o}from"./keys.js";import{loadProject as a,loadAndRenderAll as m}from"./load.js";import{render as s,renderElement as p,renderRoute as i,renderPage as l,resetGlobalCSSCache as d,getAccumulatedEmotionCSS as n,replaceEmotionCSS as c,adoptionScripts as f,prerenderParts as S,buildStamp as g}from"./render.js";import{extractMetadata as h,generateHeadHtml as u}from"./metadata.js";import{collectBrNodes as P,hydrate as y}from"./hydrate.js";import{generateSitemap as C}from"./sitemap.js";import{prefetchPageData as E,injectPrefetchedState as R}from"./prefetch.js";import{composeIntoShell as b}from"./shell.js";import{trimSpriteToPage as k,classRulesForPage as x,markupClasses as A,trimVariablesToPage as K,stylesheetRules as T}from"./pageWeight.js";import{markFacesSheet as j,facesSheetMarks as F}from"./faces.js";var z={createEnv:e,resetKeys:r,assignKeys:t,mapKeysToElements:o,loadProject:a,loadAndRenderAll:m,render:s,renderElement:p,renderRoute:i,renderPage:l,resetGlobalCSSCache:d,getAccumulatedEmotionCSS:n,replaceEmotionCSS:c,extractMetadata:h,generateHeadHtml:u,collectBrNodes:P,hydrate:y,generateSitemap:C,prefetchPageData:E,injectPrefetchedState:R,composeIntoShell:b,adoptionScripts:f,prerenderParts:S,buildStamp:g,trimSpriteToPage:k,classRulesForPage:x,markupClasses:A,trimVariablesToPage:K,stylesheetRules:T,markFacesSheet:j,facesSheetMarks:F};export{f as adoptionScripts,t as assignKeys,g as buildStamp,x as classRulesForPage,P as collectBrNodes,b as composeIntoShell,e as createEnv,z as default,h as extractMetadata,F as facesSheetMarks,u as generateHeadHtml,C as generateSitemap,n as getAccumulatedEmotionCSS,y as hydrate,R as injectPrefetchedState,m as loadAndRenderAll,a as loadProject,o as mapKeysToElements,j as markFacesSheet,A as markupClasses,E as prefetchPageData,S as prerenderParts,s as render,p as renderElement,l as renderPage,i as renderRoute,c as replaceEmotionCSS,d as resetGlobalCSSCache,r as resetKeys,T as stylesheetRules,k as trimSpriteToPage,K as trimVariablesToPage};
1
+ import{createEnv as e}from"./env.js";import{resetKeys as r,assignKeys as t,mapKeysToElements as o}from"./keys.js";import{loadProject as a,loadAndRenderAll as m}from"./load.js";import{render as s,renderElement as p,renderRoute as i,renderPage as l,resetGlobalCSSCache as d,getAccumulatedEmotionCSS as n,replaceEmotionCSS as c,adoptionScripts as f,prerenderParts as S,buildStamp as g}from"./render.js";import{extractMetadata as h,generateHeadHtml as u}from"./metadata.js";import{collectBrNodes as P,hydrate as y}from"./hydrate.js";import{generateSitemap as C}from"./sitemap.js";import{prefetchPageData as E,injectPrefetchedState as R}from"./prefetch.js";import{composeIntoShell as b}from"./shell.js";import{trimSpriteToPage as k,classRulesForPage as x,markupClasses as A,trimVariablesToPage as F,stylesheetRules as K}from"./pageWeight.js";import{markFacesSheet as T,facesSheetMarks as j}from"./faces.js";import{drawnFaces as H}from"./drawnFaces.js";var L={createEnv:e,resetKeys:r,assignKeys:t,mapKeysToElements:o,loadProject:a,loadAndRenderAll:m,render:s,renderElement:p,renderRoute:i,renderPage:l,resetGlobalCSSCache:d,getAccumulatedEmotionCSS:n,replaceEmotionCSS:c,extractMetadata:h,generateHeadHtml:u,collectBrNodes:P,hydrate:y,generateSitemap:C,prefetchPageData:E,injectPrefetchedState:R,composeIntoShell:b,adoptionScripts:f,prerenderParts:S,buildStamp:g,trimSpriteToPage:k,classRulesForPage:x,markupClasses:A,trimVariablesToPage:F,stylesheetRules:K,markFacesSheet:T,facesSheetMarks:j,drawnFaces:H};export{f as adoptionScripts,t as assignKeys,g as buildStamp,x as classRulesForPage,P as collectBrNodes,b as composeIntoShell,e as createEnv,L as default,H as drawnFaces,h as extractMetadata,j as facesSheetMarks,u as generateHeadHtml,C as generateSitemap,n as getAccumulatedEmotionCSS,y as hydrate,R as injectPrefetchedState,m as loadAndRenderAll,a as loadProject,o as mapKeysToElements,T as markFacesSheet,A as markupClasses,E as prefetchPageData,S as prerenderParts,s as render,p as renderElement,l as renderPage,i as renderRoute,c as replaceEmotionCSS,d as resetGlobalCSSCache,r as resetKeys,K as stylesheetRules,k as trimSpriteToPage,F as trimVariablesToPage};
package/drawnFaces.js ADDED
@@ -0,0 +1,783 @@
1
+ // ── The faces a built page's elements draw ──────────────────────────────────
2
+ // XMA r12 results (mirc #503, item 2): window.__smblsFacesReady and
3
+ // `brender.faces.inline: true` took every face of a family the page's styles
4
+ // name, when any character of the whole page was in its range — a page that
5
+ // draws Playfair Display only in bold headings still asked for its regular
6
+ // face, ~60 KB a page never needs.
7
+ //
8
+ // This reads the page as the browser would draw it: each element's
9
+ // font-family, font-weight and font-style from the rules that match it (the
10
+ // cascade: origin, !important, specificity, order; inheritance; var(); the
11
+ // `font` shorthand; the browser's own bold headings, italic emphasis and
12
+ // system-font form controls), then the characters of the element's own text
13
+ // against the faces sheet (`<style data-smbls-faces>`) by the browser's face
14
+ // matching (style, then weight; then unicode-range per character, falling to
15
+ // the next family for a character no face of a family covers).
16
+ //
17
+ // What can change after the first view counts too, so no face that may be
18
+ // drawn is left out: a rule under @media / @supports / @container / @layer,
19
+ // and a rule whose selector names a state (:hover, :focus, :checked, …) or
20
+ // :lang(), adds its value as a possibility wherever it would win. A rule for
21
+ // `::placeholder` reaches the field's placeholder, one for `::before` /
22
+ // `::after` the strings and attr() of its `content`; other generated content
23
+ // (`::marker`, counters' own text) is not read. A name in a family list that
24
+ // the sheet does not declare (a system font) is passed over, so the next
25
+ // family counts for every character; a generic family (`sans-serif`,
26
+ // `system-ui`, …) ends the list.
27
+ //
28
+ // The page's stylesheets are its `<style>` elements, its
29
+ // `<link rel="stylesheet">` files and their @import files, read by
30
+ // `options.readSheet(href, from)` (`from`: the address of the sheet that
31
+ // imports it, none for the page) — a `<link data-smbls-font>` (a font
32
+ // service's sheet of @font-face rules, smbls fontFaces.js) is not read.
33
+ //
34
+ // Returns Map<index, chars>: `index` the n-th `@font-face` rule of the faces
35
+ // sheet (in order, as /@font-face\s*\{([^}]*)\}/g finds them), `chars` the
36
+ // characters the page draws in it (unique, in order of first use) — or null
37
+ // when a stylesheet of the page cannot be read (no readSheet, another origin,
38
+ // a sheet that does not parse): any face may be drawn then.
39
+ import { parseHTML } from 'linkedom'
40
+ import { stylesheetRules } from './pageWeight.js'
41
+
42
+ const FACES_STYLE = /<style\b[^>]*\bdata-smbls-faces\b[^>]*>([\s\S]*?)<\/style\s*>/i
43
+ const FACE_RULE = /@font-face\s*\{([^}]*)\}/gi
44
+ const RAW = /<(script|template|noscript)\b([^>]*)>[\s\S]*?<\/\1\s*>/gi
45
+
46
+ // At most this many values per property and element: a page whose
47
+ // variables branch more is read with the first ones.
48
+ const MAX_VALUES = 8
49
+
50
+ const GENERIC = new Set([
51
+ 'serif', 'sans-serif', 'monospace', 'cursive', 'fantasy', 'system-ui', 'ui-serif', 'ui-sans-serif',
52
+ 'ui-monospace', 'ui-rounded', 'math', 'emoji', 'fangsong', '-apple-system', 'blinkmacsystemfont'
53
+ ])
54
+
55
+ // Selector states: matched as if they held (the value is a possibility).
56
+ const STATES = new Set([
57
+ 'hover', 'focus', 'focus-visible', 'focus-within', 'active', 'visited', 'link', 'any-link', 'target',
58
+ 'target-within', 'checked', 'indeterminate', 'default', 'disabled', 'enabled', 'read-only', 'read-write',
59
+ 'required', 'optional', 'valid', 'invalid', 'user-valid', 'user-invalid', 'in-range', 'out-of-range',
60
+ 'placeholder-shown', 'autofill', '-webkit-autofill', 'open', 'closed', 'popover-open', 'modal',
61
+ 'fullscreen', 'picture-in-picture', 'playing', 'paused', 'current', 'past', 'future', 'defined',
62
+ 'lang', 'dir', 'state', 'blank', 'local-link', 'scope', 'host', 'host-context'
63
+ ])
64
+ const LEGACY_PSEUDO_ELEMENTS = new Set(['before', 'after', 'first-line', 'first-letter'])
65
+ const SELECTOR_LISTS = new Set(['is', 'where', 'matches', '-webkit-any', '-moz-any', 'has'])
66
+
67
+ // The browser's own rules (lowest in the cascade): bold headings, italic
68
+ // emphasis, a system font in form controls (`font: inherit` undoes it).
69
+ const UA_RULES = `h1,h2,h3,h4,h5,h6,th{font-weight:bold}b,strong{font-weight:bolder}
70
+ em,i,cite,var,dfn,address{font-style:italic}code,kbd,samp,pre,tt,listing,xmp,plaintext{font-family:monospace}
71
+ input,textarea,select,button{font-family:system-ui;font-weight:400;font-style:normal}`
72
+
73
+ const FONT_PROPS = new Set(['font', 'font-family', 'font-weight', 'font-style'])
74
+ const ALWAYS_MEDIA = /^\s*(?:only\s+)?(?:all|screen)\s*$/i
75
+ const VAR_NAME = /var\(\s*(--[\w-]+)/g
76
+
77
+ // ── Text helpers ────────────────────────────────────────────────────────────
78
+ // The end of a balanced (…) that opens at `at` (quotes skipped); -1 if none.
79
+ const closeOf = (text, at) => {
80
+ let depth = 0
81
+ let quote = null
82
+ for (let i = at; i < text.length; i++) {
83
+ const ch = text[i]
84
+ if (quote) {
85
+ if (ch === '\\') i++
86
+ else if (ch === quote) quote = null
87
+ continue
88
+ }
89
+ if (ch === '"' || ch === "'") quote = ch
90
+ else if (ch === '(') depth++
91
+ else if (ch === ')' && !--depth) return i
92
+ }
93
+ return -1
94
+ }
95
+
96
+ // Split at top-level `sep` (outside quotes, (), [], {}).
97
+ const splitTop = (text, sep) => {
98
+ const out = []
99
+ let depth = 0
100
+ let quote = null
101
+ let start = 0
102
+ for (let i = 0; i < text.length; i++) {
103
+ const ch = text[i]
104
+ if (quote) {
105
+ if (ch === '\\') i++
106
+ else if (ch === quote) quote = null
107
+ continue
108
+ }
109
+ if (ch === '"' || ch === "'") quote = ch
110
+ else if (ch === '(' || ch === '[' || ch === '{') depth++
111
+ else if (ch === ')' || ch === ']' || ch === '}') depth = Math.max(0, depth - 1)
112
+ else if (ch === sep && !depth) {
113
+ out.push(text.slice(start, i))
114
+ start = i + 1
115
+ }
116
+ }
117
+ out.push(text.slice(start))
118
+ return out
119
+ }
120
+
121
+ const IDENT_CHAR = /[\w\- -￿]/
122
+ const identEnd = (text, at) => {
123
+ let i = at
124
+ while (i < text.length) {
125
+ if (text[i] === '\\') i += 2
126
+ else if (IDENT_CHAR.test(text[i])) i++
127
+ else break
128
+ }
129
+ return i
130
+ }
131
+
132
+ // ── Selectors ───────────────────────────────────────────────────────────────
133
+ // Specificity of one complex selector, as one number (a, b, c → a·1e6 + b·1e3 + c).
134
+ const specificityOf = (sel) => {
135
+ let a = 0
136
+ let b = 0
137
+ let c = 0
138
+ const add = (n) => {
139
+ a += Math.floor(n / 1e6)
140
+ b += Math.floor(n / 1e3) % 1e3
141
+ c += n % 1e3
142
+ }
143
+ for (let i = 0; i < sel.length;) {
144
+ const ch = sel[i]
145
+ if (ch === '"' || ch === "'") {
146
+ const end = sel.indexOf(ch, i + 1)
147
+ i = end === -1 ? sel.length : end + 1
148
+ } else if (ch === '#') {
149
+ a++
150
+ i = identEnd(sel, i + 1)
151
+ } else if (ch === '.') {
152
+ b++
153
+ i = identEnd(sel, i + 1)
154
+ } else if (ch === '[') {
155
+ b++
156
+ const end = sel.indexOf(']', i)
157
+ i = end === -1 ? sel.length : end + 1
158
+ } else if (ch === ':') {
159
+ const element = sel[i + 1] === ':'
160
+ const start = i + (element ? 2 : 1)
161
+ const end = identEnd(sel, start)
162
+ const name = sel.slice(start, end).toLowerCase()
163
+ let args = null
164
+ let next = end
165
+ if (sel[end] === '(') {
166
+ const close = closeOf(sel, end)
167
+ args = sel.slice(end + 1, close === -1 ? sel.length : close)
168
+ next = close === -1 ? sel.length : close + 1
169
+ }
170
+ if (element || LEGACY_PSEUDO_ELEMENTS.has(name)) c++
171
+ else if (name === 'where') { /* 0 */ } else if (SELECTOR_LISTS.has(name) || name === 'not') {
172
+ if (args) add(Math.max(0, ...splitTop(args, ',').map((s) => specificityOf(s.trim()))))
173
+ } else b++
174
+ i = next
175
+ } else if (ch === '*' || ch === '>' || ch === '+' || ch === '~' || ch === '|' || /\s/.test(ch)) i++
176
+ else if (IDENT_CHAR.test(ch) || ch === '\\') {
177
+ c++
178
+ i = identEnd(sel, i)
179
+ } else i++
180
+ }
181
+ return a * 1e6 + b * 1e3 + c
182
+ }
183
+
184
+ // The selector css-select can match: states and pseudo-elements become
185
+ // `:is(*)` (a no-op). `conditional` when a state, :lang() or a ::first-*
186
+ // was taken out; `target` 'element', 'placeholder' (::placeholder),
187
+ // 'before' / 'after', or null for what draws no text of the page's
188
+ // (::marker, ::selection, …).
189
+ const staticSelector = (sel) => {
190
+ let conditional = false
191
+ let target = 'element'
192
+ const walk = (text) => {
193
+ let out = ''
194
+ for (let i = 0; i < text.length;) {
195
+ const ch = text[i]
196
+ if (ch === '"' || ch === "'") {
197
+ const end = text.indexOf(ch, i + 1)
198
+ const stop = end === -1 ? text.length : end + 1
199
+ out += text.slice(i, stop)
200
+ i = stop
201
+ } else if (ch === '[') {
202
+ const end = text.indexOf(']', i)
203
+ const stop = end === -1 ? text.length : end + 1
204
+ out += text.slice(i, stop)
205
+ i = stop
206
+ } else if (ch === ':') {
207
+ const element = text[i + 1] === ':'
208
+ const start = i + (element ? 2 : 1)
209
+ const end = identEnd(text, start)
210
+ const name = text.slice(start, end).toLowerCase()
211
+ let args = null
212
+ let next = end
213
+ if (text[end] === '(') {
214
+ const close = closeOf(text, end)
215
+ args = text.slice(end + 1, close === -1 ? text.length : close)
216
+ next = close === -1 ? text.length : close + 1
217
+ }
218
+ if (element || LEGACY_PSEUDO_ELEMENTS.has(name)) {
219
+ if (name === 'placeholder' || name === '-webkit-input-placeholder' || name === '-moz-placeholder') target = 'placeholder'
220
+ else if (name === 'before' || name === 'after') target = name
221
+ else if (name === 'first-line' || name === 'first-letter') conditional = true
222
+ else target = null
223
+ out += ':is(*)'
224
+ } else if (name === 'not' && args !== null) {
225
+ const inner = staticSelectorList(args)
226
+ // a state inside :not() — drop the :not() (matches more, never less)
227
+ if (inner.conditional) {
228
+ conditional = true
229
+ out += ':is(*)'
230
+ } else out += `:not(${inner.text})`
231
+ } else if (SELECTOR_LISTS.has(name) && args !== null) {
232
+ const inner = staticSelectorList(args)
233
+ if (inner.conditional) conditional = true
234
+ out += `:${name === 'matches' || name.endsWith('-any') ? 'is' : name}(${inner.text || '*'})`
235
+ } else if (STATES.has(name) || name.startsWith('-webkit-') || name.startsWith('-moz-')) {
236
+ conditional = true
237
+ out += ':is(*)'
238
+ } else out += text.slice(i, next)
239
+ i = next
240
+ } else {
241
+ out += ch
242
+ i++
243
+ }
244
+ }
245
+ return out
246
+ }
247
+ const text = walk(sel)
248
+ return { text, conditional, target }
249
+ }
250
+ // What is left when css-select still cannot match a selector: every
251
+ // pseudo-class out (it matches more, never less).
252
+ const looseSelector = (text) => {
253
+ let out = ''
254
+ for (let i = 0; i < text.length;) {
255
+ const ch = text[i]
256
+ if (ch === '"' || ch === "'" || ch === '[') {
257
+ const end = text.indexOf(ch === '[' ? ']' : ch, i + 1)
258
+ const stop = end === -1 ? text.length : end + 1
259
+ out += text.slice(i, stop)
260
+ i = stop
261
+ } else if (ch === ':') {
262
+ let end = identEnd(text, i + (text[i + 1] === ':' ? 2 : 1))
263
+ if (text[end] === '(') {
264
+ const close = closeOf(text, end)
265
+ end = close === -1 ? text.length : close + 1
266
+ }
267
+ out += ':is(*)'
268
+ i = end
269
+ } else {
270
+ out += ch
271
+ i++
272
+ }
273
+ }
274
+ return out
275
+ }
276
+ const staticSelectorList = (list) => {
277
+ let conditional = false
278
+ const parts = splitTop(list, ',').map((s) => {
279
+ const r = staticSelector(s.trim())
280
+ if (r.conditional) conditional = true
281
+ return r.text || '*'
282
+ })
283
+ return { text: parts.join(', '), conditional }
284
+ }
285
+
286
+ // ── Declarations ────────────────────────────────────────────────────────────
287
+ // The declarations of a block (its nested rules aside) that name a font
288
+ // property, `content` or a custom property.
289
+ const declarationsOf = (block) => {
290
+ const out = []
291
+ for (const part of splitTop(block.replace(/\/\*[\s\S]*?\*\//g, ''), ';')) {
292
+ if (part.includes('{')) continue
293
+ const colon = part.indexOf(':')
294
+ if (colon === -1) continue
295
+ const prop = part.slice(0, colon).trim()
296
+ const key = prop.startsWith('--') ? prop : prop.toLowerCase()
297
+ if (!FONT_PROPS.has(key) && key !== 'content' && !key.startsWith('--')) continue
298
+ let value = part.slice(colon + 1).trim()
299
+ const important = /!\s*important\s*$/i.test(value)
300
+ if (important) value = value.replace(/!\s*important\s*$/i, '').trim()
301
+ out.push({ prop: key, value, important })
302
+ }
303
+ return out
304
+ }
305
+
306
+ // Every style rule of a stylesheet text, at any depth of @media /
307
+ // @supports / @container / @layer (and CSS nesting), and of the files its
308
+ // @import rules name (`ctx.readSheet`): { selector, block, conditional }.
309
+ // `ctx.unknown` turns true for a sheet that cannot be read.
310
+ const IMPORT = /^@import\s+(?:url\(\s*)?(['"]?)([^'")\s]+)\1\s*\)?\s*([^;]*)/i
311
+ const styleRulesOf = (css, conditional, ctx, from, parent = null, into = []) => {
312
+ const rules = stylesheetRules(css)
313
+ if (!rules) {
314
+ ctx.unknown = true
315
+ return into
316
+ }
317
+ for (const rule of rules) {
318
+ const open = rule.indexOf('{')
319
+ if (open === -1) {
320
+ const m = IMPORT.exec(rule)
321
+ if (!m) continue
322
+ const sheet = ctx.readSheet ? ctx.readSheet(m[2], from) : null
323
+ if (typeof sheet !== 'string') ctx.unknown = true
324
+ else styleRulesOf(sheet, conditional || (!!m[3].trim() && !ALWAYS_MEDIA.test(m[3])), ctx, m[2], parent, into)
325
+ continue
326
+ }
327
+ if (!rule.endsWith('}')) continue
328
+ const prelude = rule.slice(0, open).trim()
329
+ const body = rule.slice(open + 1, -1)
330
+ if (prelude[0] === '@') {
331
+ const name = (/^@([\w-]+)/.exec(prelude) || [])[1]?.toLowerCase()
332
+ if (name === 'media') styleRulesOf(body, conditional || !ALWAYS_MEDIA.test(prelude.slice(6)), ctx, from, parent, into)
333
+ // a layer reorders the cascade: its rules count as possibilities
334
+ else if (name === 'supports' || name === 'container' || name === 'document' || name === 'scope' || name === 'starting-style' || name === 'layer') {
335
+ styleRulesOf(body, true, ctx, from, parent, into)
336
+ }
337
+ continue
338
+ }
339
+ const selector = parent
340
+ ? splitTop(prelude, ',').map((s) => s.includes('&') ? s.replace(/&/g, `:is(${parent})`) : `:is(${parent}) ${s.trim()}`).join(', ')
341
+ : prelude
342
+ into.push({ selector, block: body, conditional })
343
+ // nested rules (CSS nesting)
344
+ if (body.includes('{')) {
345
+ const nested = body.slice(body.search(/[^;{}]*\{/))
346
+ styleRulesOf(nested, conditional, ctx, from, selector, into)
347
+ }
348
+ }
349
+ return into
350
+ }
351
+
352
+ // ── Values ──────────────────────────────────────────────────────────────────
353
+ // var() in `value`, from `vars` (name → values): every resulting text (at
354
+ // most MAX_VALUES); null when a var() has no value and no fallback.
355
+ const substitute = (value, vars, depth = 0) => {
356
+ const at = value.indexOf('var(')
357
+ if (at === -1) return [value]
358
+ if (depth > 8) return []
359
+ const close = closeOf(value, at + 3)
360
+ if (close === -1) return []
361
+ const inner = value.slice(at + 4, close)
362
+ const comma = splitTop(inner, ',')
363
+ const name = comma[0].trim()
364
+ const fallback = comma.length > 1 ? comma.slice(1).join(',').trim() : null
365
+ const known = vars.get(name)
366
+ const options = known && known.length ? known : fallback !== null ? substitute(fallback, vars, depth + 1) : []
367
+ const out = []
368
+ for (const option of options) {
369
+ for (const rest of substitute(value.slice(0, at) + option + value.slice(close + 1), vars, depth + 1)) {
370
+ if (!out.includes(rest)) out.push(rest)
371
+ if (out.length >= MAX_VALUES) return out
372
+ }
373
+ }
374
+ return out
375
+ }
376
+
377
+ const unquote = (name) => name.trim().replace(/^(['"])([\s\S]*)\1$/, '$2').replace(/\\(.)/g, '$1').trim().toLowerCase()
378
+ const familiesOf = (value) => splitTop(value, ',').map(unquote).filter(Boolean)
379
+
380
+ const WEIGHT_WORDS = { normal: 400, bold: 700 }
381
+ const weightOf = (value, parent) => {
382
+ const v = String(value).trim().toLowerCase()
383
+ if (v in WEIGHT_WORDS) return WEIGHT_WORDS[v]
384
+ if (v === 'bolder') return parent < 350 ? 400 : parent < 550 ? 700 : 900
385
+ if (v === 'lighter') return parent < 550 ? 100 : parent < 750 ? 400 : 700
386
+ const n = Number(v)
387
+ return Number.isFinite(n) && n >= 1 && n <= 1000 ? n : null
388
+ }
389
+ const styleOf = (value) => {
390
+ const v = String(value).trim().toLowerCase().split(/\s+/)[0]
391
+ return v === 'italic' || v === 'oblique' || v === 'normal' ? v : null
392
+ }
393
+
394
+ // The text a `content` value draws: its strings (CSS escapes read) and
395
+ // attr() values; '' for none, normal, url(), counters and quotes.
396
+ const CSS_ESCAPE = /\\([0-9a-f]{1,6})\s?|\\([\s\S])/gi
397
+ const contentText = (value, el) => {
398
+ let out = ''
399
+ for (const m of value.matchAll(/(["'])((?:\\[\s\S]|(?!\1)[^\\])*)\1|attr\(\s*([\w-]+)[^)]*\)/gi)) {
400
+ if (m[3]) out += el.getAttribute(m[3]) || ''
401
+ else {
402
+ out += m[2].replace(CSS_ESCAPE, (all, hex, ch) => {
403
+ if (!hex) return ch
404
+ const code = parseInt(hex, 16)
405
+ return code > 0 && code <= 0x10ffff ? String.fromCodePoint(code) : '\ufffd'
406
+ })
407
+ }
408
+ }
409
+ return out
410
+ }
411
+
412
+ const SYSTEM_FONTS = new Set(['caption', 'icon', 'menu', 'message-box', 'small-caption', 'status-bar'])
413
+ // The `font` shorthand → { family, weight, style } (null: not a font).
414
+ const fontShorthand = (value) => {
415
+ const v = value.trim()
416
+ if (SYSTEM_FONTS.has(v.toLowerCase())) return { family: 'system-ui', weight: 'normal', style: 'normal' }
417
+ const tokens = []
418
+ let rest = v
419
+ // the tokens before the size: style, variant, weight, stretch
420
+ for (;;) {
421
+ const m = /^\s*([^\s,'"]+)/.exec(rest)
422
+ if (!m) break
423
+ const word = m[1]
424
+ if (/^[\d.]/.test(word) && !/^\d{1,4}$/.test(word)) break // a size (12px, 1.2em, 100%)
425
+ if (/^\d{1,4}$/.test(word) && Number(word) > 1000) break
426
+ const low = word.toLowerCase()
427
+ if (/^(xx-small|x-small|small|medium|large|x-large|xx-large|xxx-large|larger|smaller|calc\(|clamp\(|min\(|max\()/.test(low)) break
428
+ tokens.push(low)
429
+ rest = rest.slice(m[0].length)
430
+ if (tokens.length > 4) break
431
+ }
432
+ // the size [/ line-height], then the families
433
+ const size = /^\s*(?:[^\s/]+|(?:calc|clamp|min|max)\([^)]*\))(?:\s*\/\s*[^\s]+)?\s+/.exec(rest)
434
+ if (!size) return null
435
+ const family = rest.slice(size[0].length).trim()
436
+ if (!family) return null
437
+ let weight = 'normal'
438
+ let style = 'normal'
439
+ for (const t of tokens) {
440
+ if (t === 'italic' || t === 'oblique') style = t
441
+ else if (t === 'bold' || t === 'bolder' || t === 'lighter' || /^\d{1,4}$/.test(t)) weight = t
442
+ }
443
+ return { family, weight, style }
444
+ }
445
+
446
+ // ── The faces sheet ─────────────────────────────────────────────────────────
447
+ const descriptor = (body, name) => {
448
+ const m = new RegExp(`(?:^|;|\\s)${name}\\s*:\\s*([^;]+)`, 'i').exec(body)
449
+ return m ? m[1].trim() : null
450
+ }
451
+ const rangesOf = (text) => {
452
+ if (!text) return null
453
+ const out = []
454
+ for (const part of String(text).split(',')) {
455
+ const p = part.trim().replace(/^u\+/i, '')
456
+ if (!p) continue
457
+ if (p.includes('?')) {
458
+ out.push([parseInt(p.replace(/\?/g, '0'), 16), parseInt(p.replace(/\?/g, 'f'), 16)])
459
+ continue
460
+ }
461
+ const [x, y] = p.split('-')
462
+ const from = parseInt(x, 16)
463
+ out.push([from, y ? parseInt(y, 16) : from])
464
+ }
465
+ return out.length ? out : null
466
+ }
467
+ const facesOf = (sheet) => {
468
+ const faces = []
469
+ let index = 0
470
+ for (const m of sheet.matchAll(FACE_RULE)) {
471
+ const body = m[1]
472
+ const family = unquote(descriptor(body, 'font-family') || '')
473
+ const weights = String(descriptor(body, 'font-weight') || '400').trim().split(/\s+/).map((w) => weightOf(w, 400) || 400)
474
+ faces.push({
475
+ index: index++,
476
+ family,
477
+ min: Math.min(...weights),
478
+ max: Math.max(...weights),
479
+ style: styleOf(descriptor(body, 'font-style') || 'normal') || 'normal',
480
+ ranges: rangesOf(descriptor(body, 'unicode-range'))
481
+ })
482
+ }
483
+ return faces
484
+ }
485
+
486
+ // The browser's face matching (CSS Fonts 4, §5.2): style first, then weight.
487
+ const STYLE_ORDER = { italic: ['italic', 'oblique', 'normal'], oblique: ['oblique', 'italic', 'normal'], normal: ['normal', 'oblique', 'italic'] }
488
+ const byStyle = (faces, style) => {
489
+ for (const s of STYLE_ORDER[style] || STYLE_ORDER.normal) {
490
+ const got = faces.filter((f) => f.style === s)
491
+ if (got.length) return got
492
+ }
493
+ return faces
494
+ }
495
+ const byWeight = (faces, w) => {
496
+ const within = faces.filter((f) => f.min <= w && w <= f.max)
497
+ if (within.length) return within
498
+ const below = faces.filter((f) => f.max < w).sort((x, y) => y.max - x.max)
499
+ const above = faces.filter((f) => f.min > w).sort((x, y) => x.min - y.min)
500
+ let pick
501
+ if (w >= 400 && w <= 500) pick = above.find((f) => f.min <= 500) || below[0] || above[0]
502
+ else if (w < 400) pick = below[0] || above[0]
503
+ else pick = above[0] || below[0]
504
+ return pick ? faces.filter((f) => f.min === pick.min && f.max === pick.max) : []
505
+ }
506
+ const covers = (face, code) => !face.ranges || face.ranges.some(([from, to]) => code >= from && code <= to)
507
+
508
+ // ── The page ────────────────────────────────────────────────────────────────
509
+ // The cascade's order of origins: the browser's rules, the page's, the
510
+ // page's !important ones (the browser's !important ones are not read).
511
+ const levelOf = (d) => (d.ua ? 0 : d.important ? 2 : 1)
512
+ const beats = (x, y) => {
513
+ const lx = levelOf(x)
514
+ const ly = levelOf(y)
515
+ if (lx !== ly) return lx > ly
516
+ if (x.rank !== y.rank) return x.rank > y.rank
517
+ return x.order > y.order
518
+ }
519
+ // The values that may win: the winner of the rules that always hold, and
520
+ // every conditional one that would beat it; `inherit` when no rule always
521
+ // holds (the inherited value stays possible).
522
+ const winnersOf = (list) => {
523
+ let base = null
524
+ for (const d of list) if (!d.conditional && (!base || beats(d, base))) base = d
525
+ const out = base ? [base] : []
526
+ for (const d of list) if (d.conditional && (!base || beats(d, base))) out.push(d)
527
+ return { list: out, inherit: !base }
528
+ }
529
+ const uniq = (values) => {
530
+ const out = []
531
+ const keys = new Set()
532
+ for (const v of values) {
533
+ const key = JSON.stringify(v)
534
+ if (keys.has(key)) continue
535
+ keys.add(key)
536
+ out.push(v)
537
+ if (out.length >= MAX_VALUES) break
538
+ }
539
+ return out
540
+ }
541
+ const varsIn = (text, into) => {
542
+ VAR_NAME.lastIndex = 0
543
+ let m
544
+ while ((m = VAR_NAME.exec(text))) into.add(m[1])
545
+ return into
546
+ }
547
+
548
+ const ROOT = { vars: new Map(), family: [['serif']], weight: [400], style: ['normal'] }
549
+ const SKIP = new Set(['script', 'style', 'template', 'noscript', 'head', 'title'])
550
+
551
+ // One element's possible fonts, from its declarations and its parent's.
552
+ const fontOf = (decls, parent) => {
553
+ // custom properties (inherited): the raw values first, then var() in them
554
+ const vars = new Map(parent.vars)
555
+ const names = [...new Set(decls.filter((d) => d.prop.startsWith('--')).map((d) => d.prop))]
556
+ for (const name of names) {
557
+ const { list, inherit } = winnersOf(decls.filter((d) => d.prop === name))
558
+ const values = inherit ? [...(parent.vars.get(name) || [])] : []
559
+ for (const d of list) {
560
+ const v = d.value.trim()
561
+ if (/^(inherit|unset)$/i.test(v)) values.push(...(parent.vars.get(name) || []))
562
+ else if (!/^(initial|revert|revert-layer)$/i.test(v)) values.push(v)
563
+ }
564
+ vars.set(name, uniq(values))
565
+ }
566
+ for (const name of names) vars.set(name, uniq(vars.get(name).flatMap((v) => substitute(v, vars))))
567
+ const font = { vars }
568
+ // font-family, font-weight, font-style (the `font` shorthand sets all three)
569
+ for (const prop of ['family', 'weight', 'style']) {
570
+ const longhand = 'font-' + prop
571
+ const { list, inherit } = winnersOf(decls.filter((d) => d.prop === longhand || d.prop === 'font'))
572
+ const values = inherit ? [...parent[prop]] : []
573
+ for (const d of list) {
574
+ const before = values.length
575
+ for (const text of substitute(d.value, vars)) {
576
+ const keyword = text.trim().toLowerCase()
577
+ if (keyword === 'inherit' || keyword === 'unset') {
578
+ values.push(...parent[prop])
579
+ continue
580
+ }
581
+ if (keyword === 'initial' || keyword === 'revert' || keyword === 'revert-layer') {
582
+ values.push(...ROOT[prop])
583
+ continue
584
+ }
585
+ let raw = text
586
+ if (d.prop === 'font') {
587
+ const f = fontShorthand(text)
588
+ if (!f) continue
589
+ raw = f[prop]
590
+ }
591
+ if (prop === 'family') values.push(familiesOf(raw))
592
+ else if (prop === 'style') {
593
+ const st = styleOf(raw)
594
+ if (st) values.push(st)
595
+ } else {
596
+ for (const p of parent.weight) {
597
+ const w = weightOf(raw, p)
598
+ if (w) values.push(w)
599
+ }
600
+ }
601
+ }
602
+ // A value that is no value (a var() with none, a word that is not a
603
+ // font) leaves the property inherited.
604
+ if (values.length === before) values.push(...parent[prop])
605
+ }
606
+ font[prop] = uniq(values)
607
+ }
608
+ return font
609
+ }
610
+
611
+ /**
612
+ * The faces of the page's faces sheet that its elements draw.
613
+ * @param {string} html - a page built ahead (its faces sheet in place)
614
+ * @param {{ readSheet?: (href: string, from?: string) => string | null }} [options]
615
+ * @returns {Map<number, string> | null} faces sheet rule index → the characters
616
+ * drawn in it; null when a stylesheet of the page cannot be read
617
+ */
618
+ export const drawnFaces = (html, options = {}) => {
619
+ const drawn = new Map()
620
+ const sheet = FACES_STYLE.exec(html)
621
+ if (!sheet) return drawn
622
+ const faces = facesOf(sheet[1])
623
+ if (!faces.length) return drawn
624
+ const families = new Map()
625
+ for (const f of faces) {
626
+ if (!families.has(f.family)) families.set(f.family, [])
627
+ families.get(f.family).push(f)
628
+ }
629
+ // Scripts, templates and the faces sheet hold no text the page draws.
630
+ const lean = html.replace(FACES_STYLE, '<style data-smbls-faces></style>').replace(RAW, '<$1$2></$1>')
631
+ const { document } = parseHTML(lean)
632
+
633
+ // The rules: the browser's, then the page's sheets in document order,
634
+ // then the style attributes (above every rule).
635
+ const ctx = { readSheet: typeof options.readSheet === 'function' ? options.readSheet : null, unknown: false }
636
+ const rules = []
637
+ const add = (css, conditional, ua, from) => {
638
+ for (const rule of styleRulesOf(css, conditional, ctx, from)) {
639
+ const decls = declarationsOf(rule.block)
640
+ if (decls.length) rules.push({ selector: rule.selector, decls, conditional: rule.conditional, ua })
641
+ }
642
+ }
643
+ add(UA_RULES, false, true)
644
+ for (const el of document.querySelectorAll('style, link')) {
645
+ const media = el.getAttribute('media')
646
+ const conditional = !!media && !ALWAYS_MEDIA.test(media)
647
+ if (el.localName === 'style') {
648
+ if (!el.hasAttribute('data-smbls-faces')) add(el.textContent || '', conditional, false)
649
+ continue
650
+ }
651
+ const rel = (el.getAttribute('rel') || '').toLowerCase().split(/\s+/)
652
+ if (!rel.includes('stylesheet') || el.hasAttribute('data-smbls-font') || el.hasAttribute('disabled')) continue
653
+ const href = el.getAttribute('href') || ''
654
+ const css = ctx.readSheet && href ? ctx.readSheet(href) : null
655
+ if (typeof css !== 'string') return null
656
+ add(css, conditional || rel.includes('alternate'), false, href)
657
+ }
658
+ if (ctx.unknown) return null
659
+ const attributed = []
660
+ for (const el of document.querySelectorAll('[style]')) {
661
+ const decls = declarationsOf(el.getAttribute('style') || '')
662
+ if (decls.length) attributed.push({ el, decls })
663
+ }
664
+ // Only the custom properties a font or `content` declaration reads (at
665
+ // any depth).
666
+ const wanted = new Set()
667
+ const every = [...rules.flatMap((r) => r.decls), ...attributed.flatMap((a) => a.decls)]
668
+ for (const d of every) if (FONT_PROPS.has(d.prop) || d.prop === 'content') varsIn(d.value, wanted)
669
+ for (let size = -1; size !== wanted.size;) {
670
+ size = wanted.size
671
+ for (const d of every) if (wanted.has(d.prop)) varsIn(d.value, wanted)
672
+ }
673
+ const keep = (d) => FONT_PROPS.has(d.prop) || d.prop === 'content' || wanted.has(d.prop)
674
+
675
+ // Per element (and per ::placeholder, ::before, ::after of it): its
676
+ // declarations of those properties, each with its place in the cascade.
677
+ const declared = { element: new Map(), placeholder: new Map(), before: new Map(), after: new Map() }
678
+ let order = 0
679
+ for (const rule of rules) {
680
+ const decls = rule.decls.filter(keep)
681
+ if (!decls.length) continue
682
+ for (const part of splitTop(rule.selector, ',')) {
683
+ const selector = part.trim()
684
+ if (!selector) continue
685
+ let { text, conditional, target } = staticSelector(selector)
686
+ if (!target) continue
687
+ let matched
688
+ try {
689
+ matched = document.querySelectorAll(text)
690
+ } catch (err) {
691
+ // matched with its pseudo-classes out: a possibility, never certain
692
+ try { matched = document.querySelectorAll(looseSelector(text)) } catch (err2) { continue }
693
+ conditional = true
694
+ }
695
+ const rank = rule.ua ? 0 : specificityOf(selector)
696
+ const entries = decls.map((d) => ({ ...d, rank, order: ++order, conditional: conditional || rule.conditional, ua: rule.ua }))
697
+ const into = declared[target]
698
+ for (const el of matched) {
699
+ if (!into.has(el)) into.set(el, [])
700
+ into.get(el).push(...entries)
701
+ }
702
+ }
703
+ }
704
+ for (const { el, decls } of attributed) {
705
+ const own = decls.filter(keep)
706
+ if (!own.length) continue
707
+ if (!declared.element.has(el)) declared.element.set(el, [])
708
+ declared.element.get(el).push(...own.map((d) => ({ ...d, rank: 1e9, order: ++order, conditional: false, ua: false })))
709
+ }
710
+
711
+ // The characters of each element's own text, per font it may be drawn in.
712
+ const textOf = new Map()
713
+ const addText = (font, text) => {
714
+ if (!text.trim()) return
715
+ for (const family of font.family) {
716
+ for (const weight of font.weight) {
717
+ for (const style of font.style) {
718
+ const key = JSON.stringify([family, weight, style])
719
+ textOf.set(key, (textOf.get(key) || '') + text)
720
+ }
721
+ }
722
+ }
723
+ }
724
+ const walk = (el, parent) => {
725
+ const font = fontOf(declared.element.get(el) || [], parent)
726
+ let own = ''
727
+ for (const node of el.childNodes) {
728
+ if (node.nodeType === 3) own += node.textContent
729
+ else if (node.nodeType === 1 && !SKIP.has(node.localName)) walk(node, font)
730
+ }
731
+ const tag = el.localName
732
+ if (tag === 'input' || tag === 'textarea') {
733
+ own += ' ' + (el.getAttribute('value') || '')
734
+ // ::placeholder inherits from its field
735
+ const placeholder = el.getAttribute('placeholder')
736
+ if (placeholder) addText(declared.placeholder.has(el) ? fontOf(declared.placeholder.get(el), font) : font, placeholder)
737
+ }
738
+ addText(font, own)
739
+ // ::before / ::after inherit from the element; their text is `content`
740
+ for (const kind of ['before', 'after']) {
741
+ const decls = declared[kind].get(el)
742
+ if (!decls) continue
743
+ const box = fontOf(decls, font)
744
+ for (const d of winnersOf(decls.filter((x) => x.prop === 'content')).list) {
745
+ for (const value of substitute(d.value, box.vars)) addText(box, contentText(value, el))
746
+ }
747
+ }
748
+ }
749
+ if (document.documentElement) walk(document.documentElement, ROOT)
750
+
751
+ // The faces those characters are drawn in: per family, the faces of the
752
+ // style and weight the browser picks; per character, the last of them
753
+ // whose unicode-range holds it (CSS Fonts 4: overlapping ranges, the last
754
+ // rule first), else the next family.
755
+ for (const [key, text] of textOf) {
756
+ const [list, weight, style] = JSON.parse(key)
757
+ const chosen = new Map()
758
+ for (const family of list) {
759
+ if (GENERIC.has(family)) break
760
+ const all = families.get(family)
761
+ if (all && !chosen.has(family)) chosen.set(family, byWeight(byStyle(all, style), weight))
762
+ }
763
+ if (!chosen.size) continue
764
+ const seen = new Set()
765
+ for (const ch of text) {
766
+ if (seen.has(ch)) continue
767
+ seen.add(ch)
768
+ const code = ch.codePointAt(0)
769
+ if (code <= 32 || code === 0xa0) continue
770
+ for (const family of list) {
771
+ if (GENERIC.has(family)) break
772
+ const set = chosen.get(family) || []
773
+ let face = null
774
+ for (let i = set.length - 1; i >= 0 && !face; i--) if (covers(set[i], code)) face = set[i]
775
+ if (!face) continue
776
+ const chars = drawn.get(face.index) || ''
777
+ if (!chars.includes(ch)) drawn.set(face.index, chars + ch)
778
+ break
779
+ }
780
+ }
781
+ }
782
+ return drawn
783
+ }
package/index.js CHANGED
@@ -16,6 +16,9 @@ import { trimSpriteToPage, classRulesForPage, markupClasses, trimVariablesToPage
16
16
  // page's faces name (`smbls build` maps file: URLs into its output) marks the
17
17
  // sheet again.
18
18
  import { markFacesSheet, facesSheetMarks } from './faces.js'
19
+ // The faces a page's elements draw (XMA r12 results, item 2): `smbls build`
20
+ // inlines and awaits only those.
21
+ import { drawnFaces } from './drawnFaces.js'
19
22
 
20
23
  export {
21
24
  createEnv,
@@ -48,7 +51,8 @@ export {
48
51
  trimVariablesToPage,
49
52
  stylesheetRules,
50
53
  markFacesSheet,
51
- facesSheetMarks
54
+ facesSheetMarks,
55
+ drawnFaces
52
56
  }
53
57
 
54
58
  export default {
@@ -82,5 +86,6 @@ export default {
82
86
  trimVariablesToPage,
83
87
  stylesheetRules,
84
88
  markFacesSheet,
85
- facesSheetMarks
89
+ facesSheetMarks,
90
+ drawnFaces
86
91
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@symbo.ls/brender",
3
- "version": "3.14.678",
3
+ "version": "3.14.679",
4
4
  "license": "CC-BY-NC-4.0",
5
5
  "type": "module",
6
6
  "module": "./dist/esm/index.js",
@@ -30,6 +30,11 @@
30
30
  "development": "./faces.js",
31
31
  "import": "./dist/esm/faces.js",
32
32
  "default": "./dist/esm/faces.js"
33
+ },
34
+ "./drawnFaces": {
35
+ "development": "./drawnFaces.js",
36
+ "import": "./dist/esm/drawnFaces.js",
37
+ "default": "./dist/esm/drawnFaces.js"
33
38
  }
34
39
  },
35
40
  "source": "index.js",
@@ -53,16 +58,16 @@
53
58
  "dependencies": {
54
59
  "@symbo.ls/css": "^3.14.782",
55
60
  "@symbo.ls/default-config": "^3.14.789",
56
- "@symbo.ls/element": "^3.14.818",
57
- "@symbo.ls/fetch": "^3.14.674",
58
- "@symbo.ls/helmet": "^3.14.673",
61
+ "@symbo.ls/element": "^3.14.819",
62
+ "@symbo.ls/fetch": "^3.14.675",
63
+ "@symbo.ls/helmet": "^3.14.674",
59
64
  "@symbo.ls/scratch": "^3.14.796",
60
- "@symbo.ls/utils": "^3.14.854",
65
+ "@symbo.ls/utils": "^3.14.855",
61
66
  "esbuild": "^0.28.1",
62
67
  "linkedom": "^0.16.8"
63
68
  },
64
69
  "peerDependencies": {
65
- "smbls": "^3.14.860"
70
+ "smbls": "^3.14.862"
66
71
  },
67
72
  "optionalDependencies": {
68
73
  "@symbo.ls/funcql": "^3.14.0"