@jxsuite/compiler 5.0.0 → 5.1.1

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/dist/compiler.js CHANGED
@@ -284,7 +284,7 @@ app.post('${t}/${e}', async (c) => {
284
284
  }
285
285
  })`}var UE=P(()=>{Jl();qb();_n()});function zb(e){let t=[],r=[],n=null,s=0,A=()=>r.at(-1)?.path??"",o=()=>n===null?A():gZ(A(),n),i=()=>{s+=1;let a="";while(s<e.length){let c=e[s];if(c==="\\"){let l=e[s+1]??"";if(l==="u")a+=String.fromCodePoint(Number.parseInt(e.slice(s+2,s+6),16)),s+=6;else a+=lZ[l]??l,s+=2;continue}if(c==='"')return s+=1,a;a+=c,s+=1}return a};while(s<e.length){let a=e[s];if(Kb.has(a)||a===","||a===":"){s+=1;continue}if(a==="{"||a==="["){r.push(a==="{"?{keys:new Set,kind:"object",path:o()}:{kind:"array",path:o()}),n=null,s+=1;continue}if(a==="}"||a==="]"){r.pop(),n=null,s+=1;continue}if(a==='"'){let l=i(),u=r.at(-1);if(u?.kind==="object"&&uZ(e,s)===":"){if(u.keys.has(l))t.push({detail:l,kind:"duplicate-key",path:u.path});u.keys.add(l),n=l}else n=null;continue}if(a==="-"||a>="0"&&a<="9"){let l=s;while(s<e.length&&/[\d+.eE-]/.test(e[s]))s+=1;let u=e.slice(l,s);if(!fZ(u))t.push({detail:u,kind:"unsafe-number",path:o()});n=null;continue}let c=s;while(s<e.length&&/[A-Za-z]/.test(e[s]))s+=1;if(s===c)s+=1;n=null}return t}function uZ(e,t){let r=t;while(r<e.length&&Kb.has(e[r]))r+=1;return e[r]??""}function gZ(e,t){return e===""?t:`${e}.${t}`}function fZ(e){if(/[.eE]/.test(e))return!0;let t=Number(e);if(!Number.isFinite(t))return!1;return String(t)===e.replace(/^\+/,"")}function Xb(e){let t=e.path===""?"the document root":`"${e.path}"`;return e.kind==="duplicate-key"?`duplicate key "${e.detail}" in ${t} — JSON.parse keeps the last one and `+"silently discards the first (RFC 7493 §2.3)":`number ${e.detail} in ${t} cannot be represented exactly and will change when `+"the document is written back (RFC 7493 §2.2)"}var Kb,lZ;var ex=P(()=>{Kb=new Set([" ","\t",`
286
286
  `,"\r"]);lZ={b:"\b",f:"\f",n:`
287
- `,r:"\r",t:"\t"}});function hZ(e){return e instanceof Error?e.message:String(e)}function LE(e){if(typeof e==="string")return tx.test(e)?e.normalize("NFC"):e;if(Array.isArray(e))return e.map((t)=>LE(t));if(Ye(e)){let t={};for(let[r,n]of Object.entries(e))t[tx.test(r)?r.normalize("NFC"):r]=LE(n);return t}return e}function OE(e,t,r){let n;try{n=JSON.parse(e)}catch(A){throw Error(`Failed to parse ${r} at ${t}: ${hZ(A)}`,{cause:A})}if(!Ye(n))throw Error(`Invalid ${r} at ${t}: expected a JSON object`);let s=zb(e);if(s.length>0){let A=s.map((o)=>Xb(o)).join("; ");throw Error(`Invalid ${r} at ${t}: ${A}`)}return LE(n)}function Ql(e,t){return OE(e,t,"Jx document")}function n3(e,t){return OE(e,t,"project config")}function s3(e,t){return OE(e,t,"class definition")}var tx;var vE=P(()=>{xr();ex();tx=/[^\p{ASCII}]/u});function Ax(e){if(typeof e!=="string")return null;let[t,...r]=e.split(";"),n=(t??"").indexOf("/");if(n===-1)return null;let s=(t??"").slice(0,n).trim().toLowerCase(),A=(t??"").slice(n+1).trim().toLowerCase();if(!rx.test(s)||!rx.test(A))return null;let o=A.lastIndexOf("+"),i=o===-1?null:A.slice(o+1),a=o===-1?A:A.slice(0,o);if(i==="")return null;let c=a.indexOf("."),l=c===-1?null:a.slice(0,c),u=l!==null&&EZ.has(l)?l:null,g=u===null?a:a.slice(u.length+1);if(g==="")return null;let h={};for(let f of r){let d=f.indexOf("=");if(d===-1)return null;let E=f.slice(0,d).trim().toLowerCase(),I=f.slice(d+1).trim();if(E==="")return null;if(I.startsWith('"')&&I.endsWith('"')&&I.length>=2)I=I.slice(1,-1);h[E]=I}return{parameters:h,subtype:g,suffix:i,tree:u,type:s}}function ox(e){if(typeof e!=="string"||e.trim()==="")return"expected a media type string";if(!e.includes("/"))return`"${e}" has no "/" — a media type is type/subtype (RFC 6838 §4.2)`;if(Ax(e)===null)return`"${e}" is not a well-formed media type (RFC 6838 §4.2)`;return null}function ix(e){let t=Ax(e);if(t===null)return null;let r=t.tree===null?"":`${t.tree}.`,n=t.suffix===null?"":`+${t.suffix}`;return`${t.type}/${r}${t.subtype}${n}`}var rx,EZ,nx="text/markdown; variant=GFM",sx="application/yaml",o3;var ax=P(()=>{rx=/^[A-Za-z0-9][A-Za-z0-9!#$&^_.+-]{0,126}$/,EZ=new Set(["vnd","prs","x"]);o3={".markdown":`${nx}; charset=utf-8`,".md":`${nx}; charset=utf-8`,".yaml":`${sx}; charset=utf-8`,".yml":`${sx}; charset=utf-8`}});class Bl{name;classPath;classDef;extensions;mediaType;mediaTypeEssence;documentKinds;exportTarget;remote;studio;project;server;connector;capabilities;#e;#t=null;constructor(e,t,r,n){this.name=e,this.classPath=t,this.classDef=r,this.#e=n;let{format:s}=r;this.extensions=(s?.extensions??[]).map((A)=>A.toLowerCase()),this.mediaType=s?.mediaType??null,this.mediaTypeEssence=ix(this.mediaType),this.documentKinds=s?.documentKinds??[],this.exportTarget=s?.exportTarget===!0,this.remote=s?.remote===!0,this.studio=r.$studio??null,this.project=r.project??null,this.server=r.server??null,this.connector=r.connector??null,this.capabilities=IZ(r)}implementation(){if(!this.#t){let e=this.classDef.$implementation;if(!e)return Promise.reject(Error(`Format class "${this.name}" has no $implementation to import`));this.#t=this.#e.importModule(this.#e.resolvePath(this.classPath,e))}return this.#t}async call(e,...t){let r=this.capabilities[e];if(!r)throw Error(`Format class "${this.name}" does not declare a "${e}" capability`);let n=await this.implementation(),s=this.classDef.title??this.name,A=n[s]??n.default?.[s]??n.default,o=A?.[r.identifier];if(typeof o!=="function")throw TypeError(`Format class "${this.name}": implementation export "${s}" has no static "${r.identifier}" method`);return await o.call(A,...t)}}class pl{#e;constructor(e){this.#e=e;for(let t of e){if(t.mediaType===null)continue;let r=ox(t.mediaType);if(r!==null)throw Error(`Format "${t.name}" declares an invalid mediaType: ${r}`)}for(let t of lx){let r=new Map;for(let n of e){if(!n.capabilities[t])continue;for(let s of n.extensions){let A=r.get(s);if(A)throw Error(`Format conflict: "${A.name}" and "${n.name}" both claim "${s}" with the "${t}" capability`);r.set(s,n)}}}}get entries(){return this.#e}byExtension(e,t){let r=cx(e);return this.#e.find((n)=>n.extensions.includes(r)&&(!t||n.capabilities[t]))}byName(e){return this.#e.find((t)=>t.name===e)}withCapability(e){return this.#e.filter((t)=>t.capabilities[e])}documentExtensions(e){let t=new Set;for(let r of this.#e){if(e&&!r.documentKinds.includes(e))continue;for(let n of r.extensions)t.add(n)}return t.delete(".json"),[...t]}has(e){let t=cx(e);return this.#e.some((r)=>r.extensions.includes(t))}}function IZ(e){let t={},r=e.$defs?.methods??{};for(let[n,s]of Object.entries(r)){let A=s.role;if(!A||!ux.includes(A))continue;let o={identifier:s.identifier??n,timing:s.timing??dZ};if(typeof s.discriminator==="string")o.discriminator=s.discriminator;t[A]=o}return t}function cx(e){let t=e.toLowerCase();return t.startsWith(".")?t:`.${t}`}var lx,ux,dZ;var HE=P(()=>{ax();lx=["parse","serialize","rewrite","discover","load"],ux=[...lx,"projectData","resolvePaths","lower","emit","head","assets","mount","dialect","deploySchema","bindings","testConnection"],dZ=["compiler","server"]});class gx{#e;#t;#r;constructor(e){this.#e=e,this.#t=e.flatMap((s)=>s.classes);let t=new Map;for(let s of e)for(let A of s.classes){let o=t.get(A.name);if(o&&o!==s.specifier)throw Error(`Extension conflict: "${o}" and "${s.specifier}" both provide a class named "${A.name}"`);t.set(A.name,s.specifier)}let r=new Map;for(let s of this.#t){if(!s.project)continue;let A=r.get(s.project.key);if(A)throw Error(`Extension conflict: "${A.name}" and "${s.name}" both claim the project section "${s.project.key}"`);r.set(s.project.key,s)}let n=new Map;for(let s of this.#t){if(!s.server)continue;let{basePath:A}=s.server;if(!A.startsWith("/_jx/"))throw Error(`Extension "${s.name}": server basePath "${A}" must be under /_jx/`);let o=n.get(A);if(o)throw Error(`Extension conflict: "${o.name}" and "${s.name}" both mount "${A}"`);n.set(A,s)}this.#r=new pl(this.#t.filter((s)=>s.extensions.length>0))}get extensions(){return this.#e}get classes(){return this.#t}get formats(){return this.#r}byName(e){return this.#t.find((t)=>t.name===e)}projectContributions(){return this.#t.filter((e)=>e.project!==null)}byProjectKey(e){return this.#t.find((t)=>t.project?.key===e)}byPathsDiscriminator(e){return this.#t.find((t)=>t.capabilities.resolvePaths?.discriminator===e)}emitters(){return this.#t.filter((e)=>e.capabilities.emit!==void 0)}headProviders(){return this.#t.filter((e)=>e.capabilities.head!==void 0)}assetProviders(){return this.#t.filter((e)=>e.capabilities.assets!==void 0)}serverMounts(){return this.#t.filter((e)=>e.server!==null).toSorted((e,t)=>(e.server?.order??100)-(t.server?.order??100)||e.name.localeCompare(t.name))}connectors(){return this.#t.filter((e)=>e.connector!==null)}schemaFragments(e){return this.#e.map((t)=>t.schemas[e]).filter((t)=>typeof t==="string")}}async function fx(e,t,r){let n=[];for(let s of e??[])n.push(await CZ(s,t,r));return new gx(n)}async function CZ(e,t,r){let n;if(e.startsWith("./")||e.startsWith("../")){let i=t.resolvePath(r,e);n=t.resolvePath(`${i}/package.json`,`./${Wi}`)}else try{n=t.resolvePath(r,`${e}/${Wi}`)}catch(i){throw Error(`Extension "${e}" is not resolvable: it must export ${Wi} (is the package installed, and does its exports map include it?): ${YE(i)}`,{cause:i})}let s;try{s=await t.loadJson(n)}catch(i){throw Error(`Extension "${e}": cannot read manifest ${n}: ${YE(i)}`,{cause:i})}if(!s||typeof s!=="object"||typeof s.name!=="string")throw Error(`Extension "${e}": ${Wi} must declare a "name"`);if(!e.startsWith(".")&&s.name!==e)throw Error(`Extension "${e}": manifest name "${s.name}" does not match the specifier`);let A=[];for(let[i,a]of Object.entries(s.classes??{})){let c=t.resolvePath(n,a),l;try{l=await t.loadJson(c)}catch(u){throw Error(`Extension "${e}": cannot read class "${i}" at ${c}: ${YE(u)}`,{cause:u})}A.push(new Bl(i,c,l,t))}let o={};if(s.schemas?.project)o.project=t.resolvePath(n,s.schemas.project);if(s.schemas?.document)o.document=t.resolvePath(n,s.schemas.document);if(s.schemas?.fields)o.fields=t.resolvePath(n,s.schemas.fields);return{specifier:e,manifestPath:n,manifest:s,classes:A,schemas:o}}function YE(e){return e instanceof Error?e.message:String(e)}var Wi="jx-extension.json";var hx=P(()=>{HE();HE()});import{readFileSync as QZ}from"node:fs";import{dirname as BZ,isAbsolute as pZ,resolve as JE,sep as yl}from"node:path";import{pathToFileURL as yZ}from"node:url";import{createRequire as Ex}from"node:module";async function mZ(e){let t=[e],r=`${yl}src${yl}`,n=`${yl}dist${yl}`;if(e.includes(r))t.push(e.replace(r,n));if(e.endsWith(".js"))t.push(`${e.slice(0,-3)}.ts`);let s;for(let A of t)try{return await import(yZ(A).href)}catch(o){s=o}throw s instanceof Error?s:Error(String(s))}function SZ(e){return{importModule:(t)=>mZ(t),loadJson:async(t)=>JSON.parse(QZ(t,"utf8")),resolvePath:(t,r)=>{if(r.startsWith("./")||r.startsWith("../"))return JE(BZ(t),r);if(pZ(r))return r;let n=Ex(JE(e,"package.json"));try{return n.resolve(r)}catch{return Ex(import.meta.url).resolve(r)}}}}async function C3(e,t){return fx(t?.extensions,SZ(e),JE(e,"project.json"))}function ml(e,t){return Error(`No format class registered for "${t}" (${e}). Enable an extension providing this format in project.json "extensions", e.g. "@jxsuite/parser".`)}var $E=P(()=>{hx()});async function Qx(e,t={}){let{resolveElementPath:r,$media:n,formats:s}=t,A=[],o=new Set;async function i(a,c){let l,u;if(typeof a==="string"){let{readFileSync:w}=await import("node:fs"),{resolve:B}=await import("node:path");if(u=c?B(c,a):B(a),o.has(u))return;if(o.add(u),u.endsWith(".json"))l=Ql(w(u,"utf8"),u);else{let{extname:S}=await import("node:path"),p=S(u).toLowerCase(),m=s?.byExtension?.(p,"parse");if(!m){await Promise.resolve().then(() => $E());throw ml(u,p)}l=await m.call("parse",w(u,"utf8"))}}else{if(l=a,u=null,l.tagName&&o.has(l.tagName))return;if(l.tagName)o.add(l.tagName)}let{tagName:g}=l;if(!g||!g.includes("-"))throw Error(`compileElement: tagName "${g}" must contain a hyphen`);let{dirname:h}=await import("node:path"),f=u?h(u):null,d=[];if(Array.isArray(l.$elements))for(let w of l.$elements){let B=ve(w)?w.$ref:w;if(typeof B!=="string")continue;if(f)await i(B,f);let S=r?r(B,f):B.replace(/\.json$/,".js");d.push(S)}let E=ua(g);if(n)l.$media={...n,...l.$media};let{rewriteSrc:I}=t,C=qi(l,E,d,I?(w)=>I(w,f):void 0,ea(l,t.defaults)),Q=u?u.replace(/\.json$/,".js"):`${g}.js`;A.push({content:C,path:Q,tagName:g})}return await i(e,t.basePath??null),{files:A}}async function wZ(e,t={}){let{title:r="Jx App",reactivitySrc:n="https://esm.sh/@vue/reactivity@3.5.13",litHtmlSrc:s="https://esm.sh/lit-html@3.3.0"}=t,A=await Qx(e,t),o=A.files.at(-1);if(!o)throw Error("compileElementPage: no element modules were produced");let{basename:i}=await import("node:path"),a=i(o.path),c=`<!DOCTYPE html>
287
+ `,r:"\r",t:"\t"}});function hZ(e){return e instanceof Error?e.message:String(e)}function LE(e){if(typeof e==="string")return tx.test(e)?e.normalize("NFC"):e;if(Array.isArray(e))return e.map((t)=>LE(t));if(Ye(e)){let t={};for(let[r,n]of Object.entries(e))t[tx.test(r)?r.normalize("NFC"):r]=LE(n);return t}return e}function OE(e,t,r){let n;try{n=JSON.parse(e)}catch(A){throw Error(`Failed to parse ${r} at ${t}: ${hZ(A)}`,{cause:A})}if(!Ye(n))throw Error(`Invalid ${r} at ${t}: expected a JSON object`);let s=zb(e);if(s.length>0){let A=s.map((o)=>Xb(o)).join("; ");throw Error(`Invalid ${r} at ${t}: ${A}`)}return LE(n)}function Ql(e,t){return OE(e,t,"Jx document")}function n3(e,t){return OE(e,t,"project config")}function s3(e,t){return OE(e,t,"class definition")}var tx;var vE=P(()=>{xr();ex();tx=/[^\p{ASCII}]/u});function Ax(e){if(typeof e!=="string")return null;let[t,...r]=e.split(";"),n=(t??"").indexOf("/");if(n===-1)return null;let s=(t??"").slice(0,n).trim().toLowerCase(),A=(t??"").slice(n+1).trim().toLowerCase();if(!rx.test(s)||!rx.test(A))return null;let o=A.lastIndexOf("+"),i=o===-1?null:A.slice(o+1),a=o===-1?A:A.slice(0,o);if(i==="")return null;let c=a.indexOf("."),l=c===-1?null:a.slice(0,c),u=l!==null&&EZ.has(l)?l:null,g=u===null?a:a.slice(u.length+1);if(g==="")return null;let h={};for(let f of r){let d=f.indexOf("=");if(d===-1)return null;let E=f.slice(0,d).trim().toLowerCase(),I=f.slice(d+1).trim();if(E==="")return null;if(I.startsWith('"')&&I.endsWith('"')&&I.length>=2)I=I.slice(1,-1);h[E]=I}return{parameters:h,subtype:g,suffix:i,tree:u,type:s}}function ox(e){if(typeof e!=="string"||e.trim()==="")return"expected a media type string";if(!e.includes("/"))return`"${e}" has no "/" — a media type is type/subtype (RFC 6838 §4.2)`;if(Ax(e)===null)return`"${e}" is not a well-formed media type (RFC 6838 §4.2)`;return null}function ix(e){let t=Ax(e);if(t===null)return null;let r=t.tree===null?"":`${t.tree}.`,n=t.suffix===null?"":`+${t.suffix}`;return`${t.type}/${r}${t.subtype}${n}`}var rx,EZ,nx="text/markdown; variant=GFM",sx="application/yaml",o3;var ax=P(()=>{rx=/^[A-Za-z0-9][A-Za-z0-9!#$&^_.+-]{0,126}$/,EZ=new Set(["vnd","prs","x"]);o3={".markdown":`${nx}; charset=utf-8`,".md":`${nx}; charset=utf-8`,".yaml":`${sx}; charset=utf-8`,".yml":`${sx}; charset=utf-8`}});class Bl{name;classPath;classDef;extensions;mediaType;mediaTypeEssence;documentKinds;exportTarget;remote;studio;project;server;connector;capabilities;#e;#t=null;constructor(e,t,r,n){this.name=e,this.classPath=t,this.classDef=r,this.#e=n;let{format:s}=r;this.extensions=(s?.extensions??[]).map((A)=>A.toLowerCase()),this.mediaType=s?.mediaType??null,this.mediaTypeEssence=ix(this.mediaType),this.documentKinds=s?.documentKinds??[],this.exportTarget=s?.exportTarget===!0,this.remote=s?.remote===!0,this.studio=r.$studio??null,this.project=r.project??null,this.server=r.server??null,this.connector=r.connector??null,this.capabilities=IZ(r)}implementation(){if(!this.#t){let e=this.classDef.$implementation;if(!e)return Promise.reject(Error(`Format class "${this.name}" has no $implementation to import`));this.#t=this.#e.importModule(this.#e.resolvePath(this.classPath,e))}return this.#t}async call(e,...t){let r=this.capabilities[e];if(!r)throw Error(`Format class "${this.name}" does not declare a "${e}" capability`);let n=await this.implementation(),s=this.classDef.title??this.name,A=n[s]??n.default?.[s]??n.default,o=A?.[r.identifier];if(typeof o!=="function")throw TypeError(`Format class "${this.name}": implementation export "${s}" has no static "${r.identifier}" method`);return await o.call(A,...t)}}class pl{#e;constructor(e){this.#e=e;for(let t of e){if(t.mediaType===null)continue;let r=ox(t.mediaType);if(r!==null)throw Error(`Format "${t.name}" declares an invalid mediaType: ${r}`)}for(let t of lx){let r=new Map;for(let n of e){if(!n.capabilities[t])continue;for(let s of n.extensions){let A=r.get(s);if(A)throw Error(`Format conflict: "${A.name}" and "${n.name}" both claim "${s}" with the "${t}" capability`);r.set(s,n)}}}}get entries(){return this.#e}byExtension(e,t){let r=cx(e);return this.#e.find((n)=>n.extensions.includes(r)&&(!t||n.capabilities[t]))}byName(e){return this.#e.find((t)=>t.name===e)}withCapability(e){return this.#e.filter((t)=>t.capabilities[e])}documentExtensions(e){let t=new Set;for(let r of this.#e){if(e&&!r.documentKinds.includes(e))continue;for(let n of r.extensions)t.add(n)}return t.delete(".json"),[...t]}has(e){let t=cx(e);return this.#e.some((r)=>r.extensions.includes(t))}}function IZ(e){let t={},r=e.$defs?.methods??{};for(let[n,s]of Object.entries(r)){let A=s.role;if(!A||!ux.includes(A))continue;let o={identifier:s.identifier??n,timing:s.timing??dZ};if(typeof s.discriminator==="string")o.discriminator=s.discriminator;t[A]=o}return t}function cx(e){let t=e.toLowerCase();return t.startsWith(".")?t:`.${t}`}var lx,ux,dZ;var HE=P(()=>{ax();lx=["parse","serialize","rewrite","discover","load"],ux=[...lx,"projectData","resolvePaths","lower","emit","head","assets","mount","dialect","deploySchema","bindings","testConnection"],dZ=["compiler","server"]});class gx{#e;#t;#r;constructor(e){this.#e=e,this.#t=e.flatMap((s)=>s.classes);let t=new Map;for(let s of e)for(let A of s.classes){let o=t.get(A.name);if(o&&o!==s.specifier)throw Error(`Extension conflict: "${o}" and "${s.specifier}" both provide a class named "${A.name}"`);t.set(A.name,s.specifier)}let r=new Map;for(let s of this.#t){if(!s.project)continue;let A=r.get(s.project.key);if(A)throw Error(`Extension conflict: "${A.name}" and "${s.name}" both claim the project section "${s.project.key}"`);r.set(s.project.key,s)}let n=new Map;for(let s of this.#t){if(!s.server)continue;let{basePath:A}=s.server;if(!A.startsWith("/_jx/"))throw Error(`Extension "${s.name}": server basePath "${A}" must be under /_jx/`);let o=n.get(A);if(o)throw Error(`Extension conflict: "${o.name}" and "${s.name}" both mount "${A}"`);n.set(A,s)}this.#r=new pl(this.#t.filter((s)=>s.extensions.length>0))}get extensions(){return this.#e}get classes(){return this.#t}get formats(){return this.#r}byName(e){return this.#t.find((t)=>t.name===e)}projectContributions(){return this.#t.filter((e)=>e.project!==null)}byProjectKey(e){return this.#t.find((t)=>t.project?.key===e)}byPathsDiscriminator(e){return this.#t.find((t)=>t.capabilities.resolvePaths?.discriminator===e)}emitters(){return this.#t.filter((e)=>e.capabilities.emit!==void 0)}headProviders(){return this.#t.filter((e)=>e.capabilities.head!==void 0)}assetProviders(){return this.#t.filter((e)=>e.capabilities.assets!==void 0)}serverMounts(){return this.#t.filter((e)=>e.server!==null).toSorted((e,t)=>(e.server?.order??100)-(t.server?.order??100)||e.name.localeCompare(t.name))}connectors(){return this.#t.filter((e)=>e.connector!==null)}schemaFragments(e){return this.#e.map((t)=>t.schemas[e]).filter((t)=>typeof t==="string")}}async function fx(e,t,r){let n=[];for(let s of e??[])n.push(await CZ(s,t,r));return new gx(n)}async function CZ(e,t,r){let n;if(e.startsWith("./")||e.startsWith("../")){let i=t.resolvePath(r,e);n=t.resolvePath(`${i}/package.json`,`./${Wi}`)}else try{n=t.resolvePath(r,`${e}/${Wi}`)}catch(i){throw Error(`Extension "${e}" is not resolvable: it must export ${Wi} (is the package installed, and does its exports map include it?): ${YE(i)}`,{cause:i})}let s;try{s=await t.loadJson(n)}catch(i){throw Error(`Extension "${e}": cannot read manifest ${n}: ${YE(i)}`,{cause:i})}if(!s||typeof s!=="object"||typeof s.name!=="string")throw Error(`Extension "${e}": ${Wi} must declare a "name"`);if(!e.startsWith(".")&&s.name!==e)throw Error(`Extension "${e}": manifest name "${s.name}" does not match the specifier`);let A=[];for(let[i,a]of Object.entries(s.classes??{})){let c=t.resolvePath(n,a),l;try{l=await t.loadJson(c)}catch(u){throw Error(`Extension "${e}": cannot read class "${i}" at ${c}: ${YE(u)}`,{cause:u})}A.push(new Bl(i,c,l,t))}let o={};if(s.schemas?.project)o.project=t.resolvePath(n,s.schemas.project);if(s.schemas?.document)o.document=t.resolvePath(n,s.schemas.document);if(s.schemas?.fields)o.fields=t.resolvePath(n,s.schemas.fields);return{specifier:e,manifestPath:n,manifest:s,classes:A,schemas:o}}function YE(e){return e instanceof Error?e.message:String(e)}var Wi="jx-extension.json";var hx=P(()=>{HE();HE()});import{readFileSync as QZ}from"node:fs";import{dirname as BZ,isAbsolute as pZ,resolve as JE,sep as yl}from"node:path";import{pathToFileURL as yZ}from"node:url";import{createRequire as Ex}from"node:module";async function mZ(e){let t=[e],r=`${yl}src${yl}`,n=`${yl}dist${yl}`;if(e.includes(r)){let A=e.replace(r,n);if(t.push(A),A.endsWith(".ts"))t.push(`${A.slice(0,-3)}.js`)}if(e.endsWith(".js"))t.push(`${e.slice(0,-3)}.ts`);let s;for(let A of t)try{return await import(yZ(A).href)}catch(o){s=o}throw s instanceof Error?s:Error(String(s))}function SZ(e){return{importModule:(t)=>mZ(t),loadJson:async(t)=>JSON.parse(QZ(t,"utf8")),resolvePath:(t,r)=>{if(r.startsWith("./")||r.startsWith("../"))return JE(BZ(t),r);if(pZ(r))return r;let n=Ex(JE(e,"package.json"));try{return n.resolve(r)}catch{return Ex(import.meta.url).resolve(r)}}}}async function C3(e,t){return fx(t?.extensions,SZ(e),JE(e,"project.json"))}function ml(e,t){return Error(`No format class registered for "${t}" (${e}). Enable an extension providing this format in project.json "extensions", e.g. "@jxsuite/parser".`)}var $E=P(()=>{hx()});async function Qx(e,t={}){let{resolveElementPath:r,$media:n,formats:s}=t,A=[],o=new Set;async function i(a,c){let l,u;if(typeof a==="string"){let{readFileSync:w}=await import("node:fs"),{resolve:B}=await import("node:path");if(u=c?B(c,a):B(a),o.has(u))return;if(o.add(u),u.endsWith(".json"))l=Ql(w(u,"utf8"),u);else{let{extname:S}=await import("node:path"),p=S(u).toLowerCase(),m=s?.byExtension?.(p,"parse");if(!m){await Promise.resolve().then(() => $E());throw ml(u,p)}l=await m.call("parse",w(u,"utf8"))}}else{if(l=a,u=null,l.tagName&&o.has(l.tagName))return;if(l.tagName)o.add(l.tagName)}let{tagName:g}=l;if(!g||!g.includes("-"))throw Error(`compileElement: tagName "${g}" must contain a hyphen`);let{dirname:h}=await import("node:path"),f=u?h(u):null,d=[];if(Array.isArray(l.$elements))for(let w of l.$elements){let B=ve(w)?w.$ref:w;if(typeof B!=="string")continue;if(f)await i(B,f);let S=r?r(B,f):B.replace(/\.json$/,".js");d.push(S)}let E=ua(g);if(n)l.$media={...n,...l.$media};let{rewriteSrc:I}=t,C=qi(l,E,d,I?(w)=>I(w,f):void 0,ea(l,t.defaults)),Q=u?u.replace(/\.json$/,".js"):`${g}.js`;A.push({content:C,path:Q,tagName:g})}return await i(e,t.basePath??null),{files:A}}async function wZ(e,t={}){let{title:r="Jx App",reactivitySrc:n="https://esm.sh/@vue/reactivity@3.5.13",litHtmlSrc:s="https://esm.sh/lit-html@3.3.0"}=t,A=await Qx(e,t),o=A.files.at(-1);if(!o)throw Error("compileElementPage: no element modules were produced");let{basename:i}=await import("node:path"),a=i(o.path),c=`<!DOCTYPE html>
288
288
  <html lang="en">
289
289
  <head>
290
290
  <meta charset="utf-8">
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jxsuite/compiler",
3
- "version": "5.0.0",
3
+ "version": "5.1.1",
4
4
  "description": "Jx static HTML compiler, island detector, and site builder",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -22,7 +22,9 @@ export type { FormatRegistry, FormatEntry } from "@jxsuite/schema/format-registr
22
22
  * Import a class $implementation module by absolute path, tolerating source-vs-built layouts. A
23
23
  * `.class.json` typically declares `"$implementation": "./markdown.js"` next to its TypeScript
24
24
  * source: under Bun the sibling `.ts` resolves directly, while under node the built module lives in
25
- * the package's `dist/` directory. Tries, in order: the literal path, src/ → dist/, .js → .ts.
25
+ * the package's `dist/` directory. Tries, in order: the literal path, src/ → dist/, that same file
26
+ * with its `.ts` extension read as `.js` (a descriptor that names its TypeScript source, whose
27
+ * built module is JavaScript), and .js → .ts.
26
28
  *
27
29
  * @param {string} path - Absolute path to the implementation module
28
30
  * @returns {Promise<Record<string, unknown>>}
@@ -32,7 +34,11 @@ export async function importImplementation(path: string): Promise<Record<string,
32
34
  const srcSeg = `${sep}src${sep}`;
33
35
  const distSeg = `${sep}dist${sep}`;
34
36
  if (path.includes(srcSeg)) {
35
- candidates.push(path.replace(srcSeg, distSeg));
37
+ const built = path.replace(srcSeg, distSeg);
38
+ candidates.push(built);
39
+ if (built.endsWith(".ts")) {
40
+ candidates.push(`${built.slice(0, -3)}.js`);
41
+ }
36
42
  }
37
43
  if (path.endsWith(".js")) {
38
44
  candidates.push(`${path.slice(0, -3)}.ts`);
package/src/site/i18n.ts CHANGED
@@ -20,6 +20,8 @@ import {
20
20
  } from "@jxsuite/schema/locale";
21
21
  import type { ResolvedI18n, TextDirection } from "@jxsuite/schema/locale";
22
22
 
23
+ import { servedRoute } from "./static-host.ts";
24
+
23
25
  /*
24
26
  * `resolveI18n` and the two types beside it live in `@jxsuite/schema/locale` rather than here.
25
27
  * Studio needs the same answer and cannot import this package — there is no `./site/i18n` export,
@@ -278,12 +280,15 @@ export function translationSets(
278
280
  * @param {readonly TranslationRoute[]} routes - Concrete routes only
279
281
  * @param {ResolvedI18n | null} i18n
280
282
  * @param {string} siteUrl - Absolute site URL; alternates must be absolute
283
+ * @param {string} [trailingSlash] - `build.trailingSlash`: the alternates name the URL the host
284
+ * serves, so they agree with the canonical link and the sitemap
281
285
  * @returns {Map<string, LocaleAlternate[]>} Keyed by `urlPattern`
282
286
  */
283
287
  export function localeAlternates(
284
288
  routes: readonly TranslationRoute[],
285
289
  i18n: ResolvedI18n | null,
286
290
  siteUrl: string,
291
+ trailingSlash?: string,
287
292
  ): Map<string, LocaleAlternate[]> {
288
293
  const out = new Map<string, LocaleAlternate[]>();
289
294
  if (i18n === null || siteUrl === "") {
@@ -299,13 +304,13 @@ export function localeAlternates(
299
304
  let alternates = built.get(members);
300
305
  if (alternates === undefined) {
301
306
  alternates = members.map((m) => ({
302
- href: siteAbsoluteUrl(m.urlPattern, siteUrl),
307
+ href: siteAbsoluteUrl(servedRoute(m.urlPattern, trailingSlash), siteUrl),
303
308
  hreflang: m.locale,
304
309
  }));
305
310
  const fallback = members.find((m) => m.locale === i18n.defaultLocale);
306
311
  if (fallback !== undefined) {
307
312
  alternates.push({
308
- href: siteAbsoluteUrl(fallback.urlPattern, siteUrl),
313
+ href: siteAbsoluteUrl(servedRoute(fallback.urlPattern, trailingSlash), siteUrl),
309
314
  hreflang: "x-default",
310
315
  });
311
316
  }
@@ -39,6 +39,7 @@ import {
39
39
  resolveSidecarEntry,
40
40
  } from "./bundler.ts";
41
41
  import { loadProjectConfig } from "./site-loader.ts";
42
+ import type { ResolvedProjectConfig } from "./site-loader.ts";
42
43
  import {
43
44
  discoverPages,
44
45
  expandDynamicRoutes,
@@ -95,6 +96,13 @@ import {
95
96
  registrationScript,
96
97
  tombstoneServiceWorker,
97
98
  } from "./service-worker.ts";
99
+ import {
100
+ htmlDeclaresNoindex,
101
+ isNotFoundRoute,
102
+ outputUrlPath,
103
+ servedRoute,
104
+ withAlternateLink,
105
+ } from "./static-host.ts";
98
106
  import {
99
107
  buildManifest,
100
108
  buildSecurityTxt,
@@ -579,7 +587,7 @@ export async function buildSite(
579
587
  translationKey: declaredKeys.get(r.urlPattern),
580
588
  urlPattern: r.urlPattern,
581
589
  }));
582
- const alternateMap = localeAlternates(keyedRoutes, i18n, siteUrl ?? "");
590
+ const alternateMap = localeAlternates(keyedRoutes, i18n, siteUrl ?? "", trailingSlash);
583
591
  /*
584
592
  * The same sets, site-absolute, for `$page.alternates`. Computed from the whole table for the
585
593
  * same reason the alternates are — and separately from them because a switcher must work in a
@@ -745,16 +753,16 @@ export async function buildSite(
745
753
  // Determine output path
746
754
  const outPath = routeToOutputPath(route.urlPattern, outDir, trailingSlash);
747
755
  mkdirSync(dirname(outPath), { recursive: true });
748
- /* Last, after the scans above: they ask what this page REFERENCES, and the answer is a
749
- build-output path, not a deployed URL. Re-rooting first would make every one of them miss. */
750
- writeFileSync(outPath, rewriteHtmlBase(result.html, basePath), "utf8");
751
- fileCount += 1;
752
-
753
756
  // Record a sitemap entry for this concrete page (skip unexpanded dynamic routes and
754
757
  // Pages that opted out via $sitemap: false). <loc> is built like the canonical URL so
755
758
  // The two always agree.
756
759
  const isConcrete = !route.urlPattern.includes(":") && !route.urlPattern.includes("*");
757
- if (sitemapEnabled && !result.excludeFromSitemap && isConcrete) {
760
+ /*
761
+ * A page the sitemap would list and the page itself disowns sends two answers to one question,
762
+ * so a page that asks crawlers for `noindex` is left out, and so is the not-found page.
763
+ */
764
+ const indexable = !isNotFoundRoute(route.urlPattern) && !htmlDeclaresNoindex(result.html);
765
+ if (sitemapEnabled && !result.excludeFromSitemap && indexable && isConcrete) {
758
766
  const routeAlternates = alternateMap.get(route.urlPattern) ?? [];
759
767
  sitemapEntries.push({
760
768
  /*
@@ -768,14 +776,20 @@ export async function buildSite(
768
776
  typeof route.sourceMtime === "string" && route.sourceMtime !== ""
769
777
  ? route.sourceMtime
770
778
  : toRfc3339(statSync(route.sourcePath).mtime),
771
- loc: siteAbsoluteUrl(route.urlPattern, siteUrl),
779
+ loc: siteAbsoluteUrl(servedRoute(route.urlPattern, trailingSlash), siteUrl),
772
780
  ...(routeAlternates.length > 0 && { alternates: routeAlternates }),
773
781
  });
774
782
  }
775
783
 
776
- // Write serialized export sidecars alongside HTML (formats with exportTarget: true)
784
+ /*
785
+ * Serialize the export sidecars (formats with exportTarget: true, e.g. Markdown) BEFORE the page
786
+ * is written, so its `<head>` advertises exactly the twins that exist: a link to a file the
787
+ * build did not write is worse than no link. The not-found page has none: it is served for
788
+ * whatever URL was mistyped, so there is nothing for a twin to be a twin of.
789
+ */
790
+ const sidecars: { content: string; path: string; type: string | null }[] = [];
777
791
  for (const fmt of formatRegistry.withCapability("serialize")) {
778
- if (!fmt.exportTarget) {
792
+ if (!fmt.exportTarget || isNotFoundRoute(route.urlPattern)) {
779
793
  continue;
780
794
  }
781
795
  try {
@@ -793,14 +807,30 @@ export async function buildSite(
793
807
  })) as string;
794
808
  if (content) {
795
809
  const sidecarPath = outPath.replace(/\.html$/, fmt.extensions[0]!);
796
- writeFileSync(sidecarPath, content, "utf8");
797
- fileCount += 1;
810
+ sidecars.push({ content, path: sidecarPath, type: fmt.mediaTypeEssence });
798
811
  }
799
812
  } catch (error) {
800
813
  const err = error as Error;
801
814
  errors.push(`Error exporting ${fmt.name} for ${route.urlPattern}: ${err.message}`);
802
815
  }
803
816
  }
817
+ for (const sidecar of sidecars) {
818
+ if (sidecar.type !== null) {
819
+ result.html = withAlternateLink(result.html, {
820
+ href: outputUrlPath(outDir, sidecar.path),
821
+ type: sidecar.type,
822
+ });
823
+ }
824
+ }
825
+
826
+ /* Last, after the scans above: they ask what this page REFERENCES, and the answer is a
827
+ build-output path, not a deployed URL. Re-rooting first would make every one of them miss. */
828
+ writeFileSync(outPath, rewriteHtmlBase(result.html, basePath), "utf8");
829
+ fileCount += 1;
830
+ for (const sidecar of sidecars) {
831
+ writeFileSync(sidecar.path, sidecar.content, "utf8");
832
+ fileCount += 1;
833
+ }
804
834
 
805
835
  // Write any additional files (island modules, etc.)
806
836
  for (const file of result.files) {
@@ -1239,7 +1269,7 @@ export async function buildSite(
1239
1269
  */
1240
1270
  async function compilePage(
1241
1271
  route: SiteRoute,
1242
- projectConfig: ProjectConfig,
1272
+ projectConfig: ResolvedProjectConfig,
1243
1273
  projectRoot: string,
1244
1274
  sections: Record<string, unknown>,
1245
1275
  imageCache: CacheManifest | null,
@@ -1427,7 +1457,14 @@ async function compilePage(
1427
1457
  ...(projectConfig.name != null && { siteName: projectConfig.name }),
1428
1458
  ...(projectConfig.url != null && { siteUrl: projectConfig.url }),
1429
1459
  ...(locale.alternates.length > 0 && { alternates: locale.alternates }),
1430
- pageUrl: route.urlPattern,
1460
+ /*
1461
+ * The canonical link and og:url name the URL the host answers with the page: the route as
1462
+ * `build.trailingSlash` serves it. The not-found page gets neither, since the URL it answers at
1463
+ * is whatever the visitor mistyped.
1464
+ */
1465
+ ...(!isNotFoundRoute(route.urlPattern) && {
1466
+ pageUrl: servedRoute(route.urlPattern, projectConfig.build.trailingSlash),
1467
+ }),
1431
1468
  });
1432
1469
 
1433
1470
  // Merge project-level $media into the layout document so responsive queries are available
@@ -2400,7 +2437,7 @@ function injectHead(
2400
2437
  * Convert a URL pattern to an output file path.
2401
2438
  *
2402
2439
  * "/" → dist/index.html "/about" → dist/about/index.html (with trailingSlash: "always")
2403
- * "/blog/hello" → dist/blog/hello/index.html
2440
+ * "/blog/hello" → dist/blog/hello/index.html "/404" → dist/404.html
2404
2441
  *
2405
2442
  * @param {string} urlPattern
2406
2443
  * @param {string} outDir
@@ -2412,6 +2449,12 @@ function routeToOutputPath(urlPattern: string, outDir: string, trailingSlash: st
2412
2449
  return join(outDir, "index.html");
2413
2450
  }
2414
2451
 
2452
+ // The not-found page is the one file a static host looks for by name (specs/site-architecture.md
2453
+ // §8.4.2), whatever the trailingSlash setting.
2454
+ if (isNotFoundRoute(urlPattern)) {
2455
+ return join(outDir, "404.html");
2456
+ }
2457
+
2415
2458
  // Remove leading slash
2416
2459
  const segments = urlPattern.replace(/^\//, "");
2417
2460
 
@@ -0,0 +1,153 @@
1
+ /**
2
+ * What a static host and a crawler are told about each page, decided in one place.
3
+ *
4
+ * Four facts, each of which the build used to leave to the author or get wrong:
5
+ *
6
+ * - The path a host serves a route at, which `build.trailingSlash` decides, so the canonical link,
7
+ * `og:url`, the sitemap `<loc>` and the `hreflang` alternates name the URL that answers with the
8
+ * page and not one that redirects to it ({@link servedRoute});
9
+ * - Which route is the site's not-found page ({@link isNotFoundRoute}), which hosts expect at
10
+ * `/404.html` and which is never a page to index;
11
+ * - Whether a built page asks crawlers not to index it ({@link htmlDeclaresNoindex}), because a
12
+ * sitemap that lists a page the page itself disowns sends two answers to the same question;
13
+ * - Where a page's machine-readable twin lives, and the `<link rel="alternate">` that tells a reader
14
+ * of the HTML about it ({@link withAlternateLink}), because a file nothing points to is a file
15
+ * nobody finds.
16
+ *
17
+ * @docs framework/site/deployment
18
+ * @docs framework/agents/machine-readable
19
+ */
20
+
21
+ import { relative, sep } from "node:path";
22
+ import { escapeHtml } from "../shared.ts";
23
+
24
+ /**
25
+ * The route of the site's not-found page, written to `404.html` (specs/site-architecture.md
26
+ * §8.4.2).
27
+ */
28
+ export const NOT_FOUND_ROUTE = "/404";
29
+
30
+ /** Whether `urlPattern` is the site's not-found page. */
31
+ export function isNotFoundRoute(urlPattern: string): boolean {
32
+ return urlPattern === NOT_FOUND_ROUTE;
33
+ }
34
+
35
+ /**
36
+ * The path a host serves `urlPattern` at, given `build.trailingSlash`.
37
+ *
38
+ * `"always"` writes `/about` as `about/index.html`, which hosts serve at `/about/` and answer
39
+ * `/about` with a redirect to it; `"never"` writes `about.html`, served at `/about`. The root is
40
+ * `/` either way, and any other setting leaves the route as written.
41
+ *
42
+ * @param {string} urlPattern - A concrete route (`/about`, `/kb/a/b`)
43
+ * @param {string} [trailingSlash] - `build.trailingSlash`
44
+ * @returns {string} The route as the host serves it
45
+ */
46
+ export function servedRoute(urlPattern: string, trailingSlash?: string): string {
47
+ if (urlPattern === "/") {
48
+ return "/";
49
+ }
50
+ if (trailingSlash === "always") {
51
+ return urlPattern.endsWith("/") ? urlPattern : `${urlPattern}/`;
52
+ }
53
+ if (trailingSlash === "never") {
54
+ return urlPattern.replace(/\/+$/, "") || "/";
55
+ }
56
+ return urlPattern;
57
+ }
58
+
59
+ /** The value of one attribute in a start tag, quoted either way or bare; `null` when absent. */
60
+ function attributeOf(tag: string, name: string): string | null {
61
+ const match = new RegExp(`\\s${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s"'>]+))`, "i").exec(
62
+ tag,
63
+ );
64
+ return match === null ? null : (match[1] ?? match[2] ?? match[3] ?? "");
65
+ }
66
+
67
+ /**
68
+ * Whether the `<head>` of a built page carries `<meta name="robots">` with `noindex` (or `none`,
69
+ * which means `noindex, nofollow`). Reads the finished HTML, like the CSP scan: a page can set the
70
+ * tag from a layout, a state entry or `$head`, and only the output says what it ended up with.
71
+ *
72
+ * @param {string} html - A built page
73
+ * @returns {boolean} True when crawlers are asked to leave the page out of their index
74
+ */
75
+ export function htmlDeclaresNoindex(html: string): boolean {
76
+ const end = html.search(/<\/head\s*>/i);
77
+ const head = end === -1 ? html : html.slice(0, end);
78
+ for (const [tag] of head.matchAll(/<meta\b[^>]*>/gi)) {
79
+ if (attributeOf(tag, "name")?.trim().toLowerCase() !== "robots") {
80
+ continue;
81
+ }
82
+ const directives = new Set(
83
+ (attributeOf(tag, "content") ?? "")
84
+ .toLowerCase()
85
+ .split(",")
86
+ .map((part) => part.trim()),
87
+ );
88
+ if (directives.has("noindex") || directives.has("none")) {
89
+ return true;
90
+ }
91
+ }
92
+ return false;
93
+ }
94
+
95
+ /** The `<head>` of a built page, or the whole string when it has no closing tag. */
96
+ function headOf(html: string): { head: string; end: number } {
97
+ const end = html.search(/<\/head\s*>/i);
98
+ return { end, head: end === -1 ? html : html.slice(0, end) };
99
+ }
100
+
101
+ /**
102
+ * Whether a page's `<head>` already has a `<link rel="alternate">` of this media type, one the
103
+ * author wrote or an earlier step added. `type` is compared by essence and without regard to case.
104
+ *
105
+ * @param {string} html - A built page
106
+ * @param {string} type - A media type essence, `text/markdown`
107
+ * @returns {boolean} True when the page already advertises an alternate of that type
108
+ */
109
+ export function hasAlternateLink(html: string, type: string): boolean {
110
+ const wanted = type.trim().toLowerCase();
111
+ for (const [tag] of headOf(html).head.matchAll(/<link\b[^>]*>/gi)) {
112
+ const rel = (attributeOf(tag, "rel") ?? "").toLowerCase().split(/\s+/);
113
+ const linked = (attributeOf(tag, "type") ?? "").split(";")[0]?.trim().toLowerCase();
114
+ if (rel.includes("alternate") && linked === wanted) {
115
+ return true;
116
+ }
117
+ }
118
+ return false;
119
+ }
120
+
121
+ /**
122
+ * Adds `<link rel="alternate" type="…" href="…">` to the end of a built page's `<head>`, unless the
123
+ * page already has an alternate of that type (an author's own wins, like every auto-injected entry,
124
+ * specs/site-architecture.md §8.4) or has no `<head>` to put it in.
125
+ *
126
+ * This is the discovery mechanism the llms.txt proposal recommends for a Markdown twin
127
+ * (`rel="alternate" type="text/markdown"`): the twin's URL is not one a reader can guess, and
128
+ * `/templates/` is not `/templates/index.md` to anyone who has not been told.
129
+ *
130
+ * @param {string} html - A built page
131
+ * @param {{ href: string; type: string }} link - Site-absolute `href`, and the media type essence
132
+ * @returns {string} The page, with the link when it was needed
133
+ */
134
+ export function withAlternateLink(html: string, link: { href: string; type: string }): string {
135
+ const { end } = headOf(html);
136
+ if (end === -1 || hasAlternateLink(html, link.type)) {
137
+ return html;
138
+ }
139
+ const tag = `<link href="${escapeHtml(link.href)}" rel="alternate" type="${escapeHtml(link.type)}">`;
140
+ return `${html.slice(0, end)}${tag}\n${html.slice(end)}`;
141
+ }
142
+
143
+ /**
144
+ * The site-absolute URL path a file in the build output is served at, percent-encoded the way a
145
+ * `<link href>` and a canonical URL are (`/kb/a b/index.md` is `/kb/a%20b/index.md`).
146
+ *
147
+ * @param {string} outDir - The build output directory
148
+ * @param {string} file - A file inside it
149
+ * @returns {string} `/templates/index.md`
150
+ */
151
+ export function outputUrlPath(outDir: string, file: string): string {
152
+ return new URL(`/${relative(outDir, file).split(sep).join("/")}`, "http://localhost").pathname;
153
+ }