kempo-server 3.0.6 → 3.0.7

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.
@@ -1 +1 @@
1
- import{readFile,writeFile,mkdir,readdir}from"fs/promises";import path from"path";import{extractAttrs,extractContentBlocks,mergeContentBlocks,replaceLocations,resolveVars,resolveIfs,resolveForeach,resolveFragmentTags}from"./parse.js";import{readFileSync,statSync}from"fs";const findFileUpSync=(filename,startDir,rootDir)=>{let dir=startDir;const root=path.resolve(rootDir);for(;;){const candidate=path.join(dir,filename);try{return statSync(candidate),candidate}catch(e){}if(path.resolve(dir)===root)return null;const parent=path.dirname(dir);if(parent===dir)return null;dir=parent}},loadVersion=rootDir=>{try{return JSON.parse(readFileSync(path.join(rootDir,"package.json"),"utf8")).version||""}catch(e){return""}},walkGlobals=async dir=>{const entries=await readdir(dir,{withFileTypes:!0}),results=[];for(const entry of entries){const full=path.join(dir,entry.name);entry.isDirectory()?results.push(...await walkGlobals(full)):entry.name.endsWith(".global.html")&&results.push(full)}return results},loadGlobalContent=async rootDir=>{const files=await walkGlobals(rootDir),maps=await Promise.all(files.map(async f=>extractContentBlocks(await readFile(f,"utf8"))));return mergeContentBlocks(...maps)},renderPage=async(pageFilePath,rootDir,globals={},state={},maxDepth=10,preloadedGlobalContent=null)=>{const pageContent=await readFile(pageFilePath,"utf8"),pageTagMatch=pageContent.match(/^[\s\S]*?<page\s([^>]*)>/);if(!pageTagMatch)throw new Error(`Invalid page file: missing <page> root element in ${pageFilePath}`);const pageAttrs=extractAttrs(pageTagMatch[1]),templateName=pageAttrs.template||"default";delete pageAttrs.template;const pageDir=path.dirname(pageFilePath),templateFile=findFileUpSync(`${templateName}.template.html`,pageDir,rootDir);if(!templateFile)throw new Error(`Template not found: ${templateName}.template.html (searched from ${pageDir} to ${rootDir})`);const globalContent=preloadedGlobalContent??await loadGlobalContent(rootDir),rawPageBlocks=extractContentBlocks(pageContent),pageBlocks={};for(const[name,entries]of Object.entries(rawPageBlocks))pageBlocks[name]=entries.map(e=>({...e,html:replaceLocations(e.html,globalContent)}));const contentBlocks=mergeContentBlocks(pageBlocks,globalContent);let templateHtml=readFileSync(templateFile,"utf8");templateHtml=resolveFragmentTags(templateHtml,name=>{const filePath=findFileUpSync(name+".fragment.html",pageDir,rootDir);return filePath?readFileSync(filePath,"utf8"):null},0,maxDepth),templateHtml=replaceLocations(templateHtml,contentBlocks);const rel=path.relative(rootDir,path.dirname(pageFilePath)),depth=rel?rel.split(path.sep).length:0,now=new Date,vars={pathToRoot:depth>0?"../".repeat(depth):"./",year:String(now.getFullYear()),date:now.toISOString().slice(0,10),datetime:now.toISOString(),timestamp:String(Date.now()),version:loadVersion(rootDir),env:process.env.NODE_ENV||"",...globals,...state,...pageAttrs};for(const[key,val]of Object.entries(vars))"function"==typeof val&&(vars[key]=val());return templateHtml=resolveIfs(templateHtml,vars),templateHtml=resolveForeach(templateHtml,vars),templateHtml=resolveVars(templateHtml,vars),templateHtml},walkPages=async dir=>{const entries=await readdir(dir,{withFileTypes:!0}),results=[];for(const entry of entries){const full=path.join(dir,entry.name);entry.isDirectory()?results.push(...await walkPages(full)):entry.name.endsWith(".page.html")&&results.push(full)}return results},renderDir=async(inputDir,outputDir,globals={},state={},maxDepth=10)=>{const[pages,globalContent]=await Promise.all([walkPages(inputDir),loadGlobalContent(inputDir)]);let count=0;for(const page of pages){const outRel=path.relative(inputDir,page).replace(/\.page\.html$/,".html"),outPath=path.join(outputDir,outRel);await mkdir(path.dirname(outPath),{recursive:!0});const html=await renderPage(page,inputDir,globals,state,maxDepth,globalContent);await writeFile(outPath,html,"utf8"),count++}return count};export{renderPage,renderDir};
1
+ import{readFile,writeFile,mkdir,readdir}from"fs/promises";import path from"path";import{extractAttrs,extractContentBlocks,mergeContentBlocks,replaceLocations,resolveVars,resolveIfs,resolveForeach,resolveFragmentTags}from"./parse.js";import{readFileSync,statSync}from"fs";const findFileUpSync=(filename,startDir,rootDir)=>{let dir=startDir;const root=path.resolve(rootDir);for(;;){const candidate=path.join(dir,filename);try{return statSync(candidate),candidate}catch(e){}if(path.resolve(dir)===root)return null;const parent=path.dirname(dir);if(parent===dir)return null;dir=parent}},loadVersion=rootDir=>{try{return JSON.parse(readFileSync(path.join(rootDir,"package.json"),"utf8")).version||""}catch(e){return""}},walkGlobals=async dir=>{const entries=await readdir(dir,{withFileTypes:!0}),results=[];for(const entry of entries){const full=path.join(dir,entry.name);entry.isDirectory()?results.push(...await walkGlobals(full)):entry.name.endsWith(".global.html")&&results.push(full)}return results},loadGlobalContent=async rootDir=>{const files=await walkGlobals(rootDir),maps=await Promise.all(files.map(async f=>extractContentBlocks(await readFile(f,"utf8"))));return mergeContentBlocks(...maps)},renderPage=async(pageFilePath,rootDir,globals={},state={},maxDepth=10,preloadedGlobalContent=null)=>{const pageContent=await readFile(pageFilePath,"utf8"),pageTagMatch=pageContent.match(/^[\s\S]*?<page((?:[^>"']|"[^"]*"|'[^']*')*)>/);if(!pageTagMatch)throw new Error(`Invalid page file: missing <page> root element in ${pageFilePath}`);const pageAttrs=extractAttrs(pageTagMatch[1]),templateName=pageAttrs.template||"default";delete pageAttrs.template;const pageDir=path.dirname(pageFilePath),templateFile=findFileUpSync(`${templateName}.template.html`,pageDir,rootDir);if(!templateFile)throw new Error(`Template not found: ${templateName}.template.html (searched from ${pageDir} to ${rootDir})`);const globalContent=preloadedGlobalContent??await loadGlobalContent(rootDir),rawPageBlocks=extractContentBlocks(pageContent),pageBlocks={};for(const[name,entries]of Object.entries(rawPageBlocks))pageBlocks[name]=entries.map(e=>({...e,html:replaceLocations(e.html,globalContent)}));const contentBlocks=mergeContentBlocks(pageBlocks,globalContent);let templateHtml=readFileSync(templateFile,"utf8");templateHtml=resolveFragmentTags(templateHtml,name=>{const filePath=findFileUpSync(name+".fragment.html",pageDir,rootDir);return filePath?readFileSync(filePath,"utf8"):null},0,maxDepth),templateHtml=replaceLocations(templateHtml,contentBlocks);const rel=path.relative(rootDir,path.dirname(pageFilePath)),depth=rel?rel.split(path.sep).length:0,now=new Date,vars={pathToRoot:depth>0?"../".repeat(depth):"./",year:String(now.getFullYear()),date:now.toISOString().slice(0,10),datetime:now.toISOString(),timestamp:String(Date.now()),version:loadVersion(rootDir),env:process.env.NODE_ENV||"",...globals,...state,...pageAttrs};for(const[key,val]of Object.entries(vars))"function"==typeof val&&(vars[key]=val());return templateHtml=resolveIfs(templateHtml,vars),templateHtml=resolveForeach(templateHtml,vars),templateHtml=resolveVars(templateHtml,vars),templateHtml},walkPages=async dir=>{const entries=await readdir(dir,{withFileTypes:!0}),results=[];for(const entry of entries){const full=path.join(dir,entry.name);entry.isDirectory()?results.push(...await walkPages(full)):entry.name.endsWith(".page.html")&&results.push(full)}return results},renderDir=async(inputDir,outputDir,globals={},state={},maxDepth=10)=>{const[pages,globalContent]=await Promise.all([walkPages(inputDir),loadGlobalContent(inputDir)]);let count=0;for(const page of pages){const outRel=path.relative(inputDir,page).replace(/\.page\.html$/,".html"),outPath=path.join(outputDir,outRel);await mkdir(path.dirname(outPath),{recursive:!0});const html=await renderPage(page,inputDir,globals,state,maxDepth,globalContent);await writeFile(outPath,html,"utf8"),count++}return count};export{renderPage,renderDir};
@@ -1 +1 @@
1
- const extractAttrs=tagString=>{const attrs={},re=/(\w[\w-]*)=(?:"([^"]*)"|'([^']*)')/g;let match;for(;null!==(match=re.exec(tagString));)attrs[match[1]]=match[2]??match[3];return attrs},extractContentBlocks=xml=>{const blocks={},re=/<content(?:\s+([^>]*))?\s*>([\s\S]*?)<\/content>/g;let match;for(;null!==(match=re.exec(xml));){const attrs=extractAttrs(match[1]||""),name=attrs.location||"default",priority=parseInt(attrs.priority||"0",10);blocks[name]||(blocks[name]=[]),blocks[name].push({html:match[2],priority:priority})}return blocks},mergeContentBlocks=(...maps)=>{const merged={};for(const map of maps)for(const[name,entries]of Object.entries(map))merged[name]||(merged[name]=[]),merged[name].push(...entries);return merged},resolveLocation=entries=>entries?.length?[...entries].sort((a,b)=>b.priority-a.priority).map(e=>e.html).join(""):null,replaceLocations=(html,contentMap)=>html.replace(/<location(?:\s+name="([^"]*)")?>([\s\S]*?)<\/location>/g,(_,name,fallback)=>resolveLocation(contentMap[name||"default"])??fallback).replace(/<location(?:\s+name="([^"]*)")?\s*\/>/g,(_,name)=>resolveLocation(contentMap[name||"default"])??""),stripFragmentWrapper=xml=>{const match=xml.match(/^\s*<fragment\b[^>]*>([\s\S]*)<\/fragment>\s*$/);return match?match[1]:xml},resolvePath=(obj,dotPath)=>dotPath.split(".").reduce((cur,key)=>cur?.[key],obj),resolveVars=(html,vars)=>html.replace(/\{\{([^}]+)\}\}/g,(_,key)=>{const trimmed=key.trim(),val=resolvePath(vars,trimmed);return"function"==typeof val?val():val??""}),resolveIfs=(html,vars)=>{const re=/<if\s+condition="([^"]+)">([\s\S]*?)<\/if>/g;let prev,result=html;do{prev=result,result=result.replace(re,(_,condition,inner)=>evalCondition(condition,vars)?inner:"")}while(result!==prev);return result},resolveForeach=(html,vars)=>{const re=/<foreach\s+in="([^"]+)"\s+as="([^"]+)">([\s\S]*?)<\/foreach>/g;let prev,result=html;do{prev=result,result=result.replace(re,(_,inAttr,asAttr,inner)=>{const arr=resolvePath(vars,inAttr.trim());return Array.isArray(arr)?arr.map(item=>{const scopedVars={...vars,[asAttr]:item};return resolveVars(inner,scopedVars)}).join(""):""})}while(result!==prev);return result},resolveFragmentTags=(html,findFragmentFile,depth,maxDepth)=>{if(depth>maxDepth)throw new Error(`Fragment depth exceeded maximum of ${maxDepth}`);return html.replace(/<fragment\s+name="([^"]+)"(?:\s*\/>|>([\s\S]*?)<\/fragment>)/g,(_,name,fallback)=>{const content=findFragmentFile(name);if(null===content)return fallback??"";const stripped=stripFragmentWrapper(content);return resolveFragmentTags(stripped,findFragmentFile,depth+1,maxDepth)})},TOKEN_TYPES_NUMBER="NUMBER",TOKEN_TYPES_STRING="STRING",TOKEN_TYPES_BOOLEAN="BOOLEAN",TOKEN_TYPES_IDENTIFIER="IDENTIFIER",TOKEN_TYPES_OPERATOR="OPERATOR",TOKEN_TYPES_NOT="NOT",TOKEN_TYPES_LPAREN="LPAREN",TOKEN_TYPES_RPAREN="RPAREN",evalCondition=(expression,vars)=>!!((tokens,vars)=>{let pos=0;const peek=()=>tokens[pos],advance=()=>tokens[pos++],parsePrimary=()=>{const tok=peek();if(!tok)throw new Error("Unexpected end of expression");if(tok.type===TOKEN_TYPES_NOT)return advance(),!parsePrimary();if(tok.type===TOKEN_TYPES_LPAREN){advance();const val=parseOr();if(!peek()||peek().type!==TOKEN_TYPES_RPAREN)throw new Error("Missing closing parenthesis");return advance(),val}if(tok.type===TOKEN_TYPES_NUMBER||tok.type===TOKEN_TYPES_STRING||tok.type===TOKEN_TYPES_BOOLEAN)return advance(),tok.value;if(tok.type===TOKEN_TYPES_IDENTIFIER)return advance(),resolvePath(vars,tok.value);throw new Error(`Unexpected token: ${JSON.stringify(tok)}`)},parseComparison=()=>{let left=parsePrimary();for(;peek()&&peek().type===TOKEN_TYPES_OPERATOR&&["===","!==",">","<",">=","<="].includes(peek().value);){const op=advance().value,right=parsePrimary();switch(op){case"===":left=left===right;break;case"!==":left=left!==right;break;case">":left=left>right;break;case"<":left=left<right;break;case">=":left=left>=right;break;case"<=":left=left<=right}}return left},parseAnd=()=>{let left=parseComparison();for(;peek()&&peek().type===TOKEN_TYPES_OPERATOR&&"&&"===peek().value;){advance();const right=parseComparison();left=left&&right}return left},parseOr=()=>{let left=parseAnd();for(;peek()&&peek().type===TOKEN_TYPES_OPERATOR&&"||"===peek().value;){advance();const right=parseAnd();left=left||right}return left},result=parseOr();if(pos<tokens.length)throw new Error(`Unexpected token after expression: ${JSON.stringify(tokens[pos])}`);return result})((expr=>{const tokens=[];let i=0;for(;i<expr.length;){if(/\s/.test(expr[i])){i++;continue}if("("===expr[i]){tokens.push({type:TOKEN_TYPES_LPAREN}),i++;continue}if(")"===expr[i]){tokens.push({type:TOKEN_TYPES_RPAREN}),i++;continue}if("!"===expr[i]&&"="!==expr[i+1]){tokens.push({type:TOKEN_TYPES_NOT}),i++;continue}const opMatch=expr.slice(i).match(/^(===|!==|>=|<=|&&|\|\||>|<)/);if(opMatch){tokens.push({type:TOKEN_TYPES_OPERATOR,value:opMatch[1]}),i+=opMatch[1].length;continue}if('"'===expr[i]||"'"===expr[i]){const quote=expr[i];let str="";for(i++;i<expr.length&&expr[i]!==quote;)str+=expr[i],i++;if(i>=expr.length)throw new Error(`Unterminated string in condition: ${expr}`);i++,tokens.push({type:TOKEN_TYPES_STRING,value:str});continue}const numMatch=expr.slice(i).match(/^(\d+(\.\d+)?)/);if(numMatch){tokens.push({type:TOKEN_TYPES_NUMBER,value:Number(numMatch[1])}),i+=numMatch[1].length;continue}const idMatch=expr.slice(i).match(/^([a-zA-Z_$][\w$.]*)/);if(idMatch){const id=idMatch[1];"true"===id||"false"===id?tokens.push({type:TOKEN_TYPES_BOOLEAN,value:"true"===id}):tokens.push({type:TOKEN_TYPES_IDENTIFIER,value:id}),i+=id.length;continue}throw new Error(`Unexpected character '${expr[i]}' in condition: ${expr}`)}return tokens})(expression),vars);export{extractAttrs,extractContentBlocks,mergeContentBlocks,replaceLocations,stripFragmentWrapper,resolveVars,resolveIfs,resolveForeach,resolveFragmentTags,evalCondition,resolvePath};
1
+ const extractAttrs=tagString=>{const attrs={},re=/(\w[\w-]*)=(?:"([^"]*)"|'([^']*)')/g;let match;for(;null!==(match=re.exec(tagString));)attrs[match[1]]=match[2]??match[3];return attrs},extractContentBlocks=xml=>{const blocks={},re=/<content((?:[^>"']|"[^"]*"|'[^']*')*)>([\s\S]*?)<\/content>/g;let match;for(;null!==(match=re.exec(xml));){const attrs=extractAttrs(match[1]||""),name=attrs.location||"default",priority=parseInt(attrs.priority||"0",10);blocks[name]||(blocks[name]=[]),blocks[name].push({html:match[2],priority:priority})}return blocks},mergeContentBlocks=(...maps)=>{const merged={};for(const map of maps)for(const[name,entries]of Object.entries(map))merged[name]||(merged[name]=[]),merged[name].push(...entries);return merged},replaceLocations=(html,contentMap)=>html.replace(/<location((?:[^>"']|"[^"]*"|'[^']*')*?)(?:\s*\/>|>([\s\S]*?)<\/location>)/g,(_,attrStr,fallback)=>{return entries=contentMap[extractAttrs(attrStr).name||"default"],(entries?.length?[...entries].sort((a,b)=>b.priority-a.priority).map(e=>e.html).join(""):null)??fallback??"";var entries}),stripFragmentWrapper=xml=>{const match=xml.match(/^\s*<fragment(?:[^>"']|"[^"]*"|'[^']*')*>([\s\S]*)<\/fragment>\s*$/);return match?match[1]:xml},resolvePath=(obj,dotPath)=>dotPath.split(".").reduce((cur,key)=>cur?.[key],obj),resolveVars=(html,vars)=>html.replace(/\{\{([^}]+)\}\}/g,(_,key)=>{const trimmed=key.trim(),val=resolvePath(vars,trimmed);return"function"==typeof val?val():val??""}),resolveIfs=(html,vars)=>{const re=/<if((?:[^>"']|"[^"]*"|'[^']*')*)>([\s\S]*?)<\/if>/g;let prev,result=html;do{prev=result,result=result.replace(re,(_,attrStr,inner)=>evalCondition(extractAttrs(attrStr).condition||"",vars)?inner:"")}while(result!==prev);return result},resolveForeach=(html,vars)=>{const re=/<foreach((?:[^>"']|"[^"]*"|'[^']*')*)>([\s\S]*?)<\/foreach>/g;let prev,result=html;do{prev=result,result=result.replace(re,(_,attrStr,inner)=>{const attrs=extractAttrs(attrStr),arr=resolvePath(vars,(attrs.in||"").trim());return Array.isArray(arr)?arr.map(item=>{const scopedVars={...vars,[attrs.as]:item};return resolveVars(inner,scopedVars)}).join(""):""})}while(result!==prev);return result},resolveFragmentTags=(html,findFragmentFile,depth,maxDepth)=>{if(depth>maxDepth)throw new Error(`Fragment depth exceeded maximum of ${maxDepth}`);return html.replace(/<fragment((?:[^>"']|"[^"]*"|'[^']*')*?)(?:\s*\/>|>([\s\S]*?)<\/fragment>)/g,(_,attrStr,fallback)=>{const name=extractAttrs(attrStr||"").name,content=findFragmentFile(name);if(null===content)return fallback??"";const stripped=stripFragmentWrapper(content);return resolveFragmentTags(stripped,findFragmentFile,depth+1,maxDepth)})},TOKEN_TYPES_NUMBER="NUMBER",TOKEN_TYPES_STRING="STRING",TOKEN_TYPES_BOOLEAN="BOOLEAN",TOKEN_TYPES_IDENTIFIER="IDENTIFIER",TOKEN_TYPES_OPERATOR="OPERATOR",TOKEN_TYPES_NOT="NOT",TOKEN_TYPES_LPAREN="LPAREN",TOKEN_TYPES_RPAREN="RPAREN",evalCondition=(expression,vars)=>!!((tokens,vars)=>{let pos=0;const peek=()=>tokens[pos],advance=()=>tokens[pos++],parsePrimary=()=>{const tok=peek();if(!tok)throw new Error("Unexpected end of expression");if(tok.type===TOKEN_TYPES_NOT)return advance(),!parsePrimary();if(tok.type===TOKEN_TYPES_LPAREN){advance();const val=parseOr();if(!peek()||peek().type!==TOKEN_TYPES_RPAREN)throw new Error("Missing closing parenthesis");return advance(),val}if(tok.type===TOKEN_TYPES_NUMBER||tok.type===TOKEN_TYPES_STRING||tok.type===TOKEN_TYPES_BOOLEAN)return advance(),tok.value;if(tok.type===TOKEN_TYPES_IDENTIFIER)return advance(),resolvePath(vars,tok.value);throw new Error(`Unexpected token: ${JSON.stringify(tok)}`)},parseComparison=()=>{let left=parsePrimary();for(;peek()&&peek().type===TOKEN_TYPES_OPERATOR&&["===","!==",">","<",">=","<="].includes(peek().value);){const op=advance().value,right=parsePrimary();switch(op){case"===":left=left===right;break;case"!==":left=left!==right;break;case">":left=left>right;break;case"<":left=left<right;break;case">=":left=left>=right;break;case"<=":left=left<=right}}return left},parseAnd=()=>{let left=parseComparison();for(;peek()&&peek().type===TOKEN_TYPES_OPERATOR&&"&&"===peek().value;){advance();const right=parseComparison();left=left&&right}return left},parseOr=()=>{let left=parseAnd();for(;peek()&&peek().type===TOKEN_TYPES_OPERATOR&&"||"===peek().value;){advance();const right=parseAnd();left=left||right}return left},result=parseOr();if(pos<tokens.length)throw new Error(`Unexpected token after expression: ${JSON.stringify(tokens[pos])}`);return result})((expr=>{const tokens=[];let i=0;for(;i<expr.length;){if(/\s/.test(expr[i])){i++;continue}if("("===expr[i]){tokens.push({type:TOKEN_TYPES_LPAREN}),i++;continue}if(")"===expr[i]){tokens.push({type:TOKEN_TYPES_RPAREN}),i++;continue}if("!"===expr[i]&&"="!==expr[i+1]){tokens.push({type:TOKEN_TYPES_NOT}),i++;continue}const opMatch=expr.slice(i).match(/^(===|!==|>=|<=|&&|\|\||>|<)/);if(opMatch){tokens.push({type:TOKEN_TYPES_OPERATOR,value:opMatch[1]}),i+=opMatch[1].length;continue}if('"'===expr[i]||"'"===expr[i]){const quote=expr[i];let str="";for(i++;i<expr.length&&expr[i]!==quote;)str+=expr[i],i++;if(i>=expr.length)throw new Error(`Unterminated string in condition: ${expr}`);i++,tokens.push({type:TOKEN_TYPES_STRING,value:str});continue}const numMatch=expr.slice(i).match(/^(\d+(\.\d+)?)/);if(numMatch){tokens.push({type:TOKEN_TYPES_NUMBER,value:Number(numMatch[1])}),i+=numMatch[1].length;continue}const idMatch=expr.slice(i).match(/^([a-zA-Z_$][\w$.]*)/);if(idMatch){const id=idMatch[1];"true"===id||"false"===id?tokens.push({type:TOKEN_TYPES_BOOLEAN,value:"true"===id}):tokens.push({type:TOKEN_TYPES_IDENTIFIER,value:id}),i+=id.length;continue}throw new Error(`Unexpected character '${expr[i]}' in condition: ${expr}`)}return tokens})(expression),vars);export{extractAttrs,extractContentBlocks,mergeContentBlocks,replaceLocations,stripFragmentWrapper,resolveVars,resolveIfs,resolveForeach,resolveFragmentTags,evalCondition,resolvePath};
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "kempo-server",
3
3
  "type": "module",
4
- "version": "3.0.6",
4
+ "version": "3.0.7",
5
5
  "description": "A lightweight, zero-dependency, file based routing server.",
6
6
  "exports": {
7
7
  "./rescan": "./dist/rescan.js",
@@ -67,7 +67,7 @@ const loadGlobalContent = async rootDir => {
67
67
  */
68
68
  const renderPage = async (pageFilePath, rootDir, globals = {}, state = {}, maxDepth = 10, preloadedGlobalContent = null) => {
69
69
  const pageContent = await readFile(pageFilePath, 'utf8');
70
- const pageTagMatch = pageContent.match(/^[\s\S]*?<page\s([^>]*)>/);
70
+ const pageTagMatch = pageContent.match(/^[\s\S]*?<page((?:[^>"']|"[^"]*"|'[^']*')*)>/);
71
71
  if(!pageTagMatch) throw new Error(`Invalid page file: missing <page> root element in ${pageFilePath}`);
72
72
  const pageAttrs = extractAttrs(pageTagMatch[1]);
73
73
  const templateName = pageAttrs.template || 'default';
@@ -16,7 +16,7 @@ const extractAttrs = tagString => {
16
16
  */
17
17
  const extractContentBlocks = xml => {
18
18
  const blocks = {};
19
- const re = /<content(?:\s+([^>]*))?\s*>([\s\S]*?)<\/content>/g;
19
+ const re = /<content((?:[^>"']|"[^"]*"|'[^']*')*)>([\s\S]*?)<\/content>/g;
20
20
  let match;
21
21
  while((match = re.exec(xml)) !== null){
22
22
  const attrs = extractAttrs(match[1] || '');
@@ -51,19 +51,15 @@ const resolveLocation = (entries) => {
51
51
  };
52
52
 
53
53
  const replaceLocations = (html, contentMap) =>
54
- html
55
- .replace(/<location(?:\s+name="([^"]*)")?>([\s\S]*?)<\/location>/g, (_, name, fallback) =>
56
- resolveLocation(contentMap[name || 'default']) ?? fallback
57
- )
58
- .replace(/<location(?:\s+name="([^"]*)")?\s*\/>/g, (_, name) =>
59
- resolveLocation(contentMap[name || 'default']) ?? ''
60
- );
54
+ html.replace(/<location((?:[^>"']|"[^"]*"|'[^']*')*?)(?:\s*\/>|>([\s\S]*?)<\/location>)/g, (_, attrStr, fallback) =>
55
+ resolveLocation(contentMap[extractAttrs(attrStr).name || 'default']) ?? fallback ?? ''
56
+ );
61
57
 
62
58
  /*
63
59
  Fragment Wrapper Stripping
64
60
  */
65
61
  const stripFragmentWrapper = xml => {
66
- const match = xml.match(/^\s*<fragment\b[^>]*>([\s\S]*)<\/fragment>\s*$/);
62
+ const match = xml.match(/^\s*<fragment(?:[^>"']|"[^"]*"|'[^']*')*>([\s\S]*)<\/fragment>\s*$/);
67
63
  return match ? match[1] : xml;
68
64
  };
69
65
 
@@ -88,13 +84,13 @@ const resolveVars = (html, vars) =>
88
84
  If-Block Resolution
89
85
  */
90
86
  const resolveIfs = (html, vars) => {
91
- const re = /<if\s+condition="([^"]+)">([\s\S]*?)<\/if>/g;
87
+ const re = /<if((?:[^>"']|"[^"]*"|'[^']*')*)>([\s\S]*?)<\/if>/g;
92
88
  let result = html;
93
89
  let prev;
94
90
  do {
95
91
  prev = result;
96
- result = result.replace(re, (_, condition, inner) =>
97
- evalCondition(condition, vars) ? inner : ''
92
+ result = result.replace(re, (_, attrStr, inner) =>
93
+ evalCondition(extractAttrs(attrStr).condition || '', vars) ? inner : ''
98
94
  );
99
95
  } while(result !== prev);
100
96
  return result;
@@ -104,16 +100,17 @@ const resolveIfs = (html, vars) => {
104
100
  Foreach-Block Resolution
105
101
  */
106
102
  const resolveForeach = (html, vars) => {
107
- const re = /<foreach\s+in="([^"]+)"\s+as="([^"]+)">([\s\S]*?)<\/foreach>/g;
103
+ const re = /<foreach((?:[^>"']|"[^"]*"|'[^']*')*)>([\s\S]*?)<\/foreach>/g;
108
104
  let result = html;
109
105
  let prev;
110
106
  do {
111
107
  prev = result;
112
- result = result.replace(re, (_, inAttr, asAttr, inner) => {
113
- const arr = resolvePath(vars, inAttr.trim());
108
+ result = result.replace(re, (_, attrStr, inner) => {
109
+ const attrs = extractAttrs(attrStr);
110
+ const arr = resolvePath(vars, (attrs['in'] || '').trim());
114
111
  if(!Array.isArray(arr)) return '';
115
112
  return arr.map(item => {
116
- const scopedVars = {...vars, [asAttr]: item};
113
+ const scopedVars = {...vars, [attrs.as]: item};
117
114
  return resolveVars(inner, scopedVars);
118
115
  }).join('');
119
116
  });
@@ -126,8 +123,9 @@ const resolveForeach = (html, vars) => {
126
123
  */
127
124
  const resolveFragmentTags = (html, findFragmentFile, depth, maxDepth) => {
128
125
  if(depth > maxDepth) throw new Error(`Fragment depth exceeded maximum of ${maxDepth}`);
129
- const re = /<fragment\s+name="([^"]+)"(?:\s*\/>|>([\s\S]*?)<\/fragment>)/g;
130
- return html.replace(re, (_, name, fallback) => {
126
+ const re = /<fragment((?:[^>"']|"[^"]*"|'[^']*')*?)(?:\s*\/>|>([\s\S]*?)<\/fragment>)/g;
127
+ return html.replace(re, (_, attrStr, fallback) => {
128
+ const name = extractAttrs(attrStr || '').name;
131
129
  const content = findFragmentFile(name);
132
130
  if(content === null) return fallback ?? '';
133
131
  const stripped = stripFragmentWrapper(content);
@@ -273,5 +273,86 @@ export default {
273
273
  const result = replaceLocations('<location>fallback</location>', {});
274
274
  if(result !== 'fallback') return fail(`got: ${result}`);
275
275
  pass();
276
+ },
277
+ 'replaceLocations handles custom attributes on self-closing location': ({pass, fail}) => {
278
+ const result = replaceLocations('<location name="main" label="Main Content" />', {main: [{html: '<p>Hi</p>', priority: 0}]});
279
+ if(result !== '<p>Hi</p>') return fail(`got: ${result}`);
280
+ pass();
281
+ },
282
+ 'replaceLocations handles custom attributes on block location': ({pass, fail}) => {
283
+ const result = replaceLocations('<location name="sidebar" label="Sidebar" editable="true">fallback</location>', {sidebar: [{html: 'real', priority: 0}]});
284
+ if(result !== 'real') return fail(`got: ${result}`);
285
+ pass();
286
+ },
287
+ 'replaceLocations handles custom attributes with fallback': ({pass, fail}) => {
288
+ const result = replaceLocations('<location name="missing" label="Test">fallback</location>', {});
289
+ if(result !== 'fallback') return fail(`got: ${result}`);
290
+ pass();
291
+ },
292
+ 'replaceLocations handles custom attributes on nameless location': ({pass, fail}) => {
293
+ const result = replaceLocations('<location label="Default" />', {default: [{html: 'content', priority: 0}]});
294
+ if(result !== 'content') return fail(`got: ${result}`);
295
+ pass();
296
+ },
297
+ 'resolveFragmentTags handles custom attributes on fragment': ({pass, fail}) => {
298
+ const html = '<fragment name="nav" label="Navigation" />';
299
+ const finder = name => name === 'nav' ? '<nav>Link</nav>' : null;
300
+ const result = resolveFragmentTags(html, finder, 0, 10);
301
+ if(result !== '<nav>Link</nav>') return fail(`got: ${result}`);
302
+ pass();
303
+ },
304
+ 'resolveFragmentTags handles custom attributes with fallback': ({pass, fail}) => {
305
+ const html = '<fragment name="missing" label="Test">fallback</fragment>';
306
+ const finder = () => null;
307
+ const result = resolveFragmentTags(html, finder, 0, 10);
308
+ if(result !== 'fallback') return fail(`got: ${result}`);
309
+ pass();
310
+ },
311
+ 'resolveFragmentTags handles excessive whitespace': ({pass, fail}) => {
312
+ const html = '<fragment name="nav" />';
313
+ const finder = name => name === 'nav' ? '<nav>Link</nav>' : null;
314
+ const result = resolveFragmentTags(html, finder, 0, 10);
315
+ if(result !== '<nav>Link</nav>') return fail(`got: ${result}`);
316
+ pass();
317
+ },
318
+ 'replaceLocations handles excessive whitespace on self-closing': ({pass, fail}) => {
319
+ const result = replaceLocations('<location name="main" />', {main: [{html: 'hi', priority: 0}]});
320
+ if(result !== 'hi') return fail(`got: ${result}`);
321
+ pass();
322
+ },
323
+ 'replaceLocations handles excessive whitespace on block location': ({pass, fail}) => {
324
+ const result = replaceLocations('<location name="main" >fallback</location>', {main: [{html: 'hi', priority: 0}]});
325
+ if(result !== 'hi') return fail(`got: ${result}`);
326
+ pass();
327
+ },
328
+ 'resolveIfs handles extra attributes before condition': ({pass, fail}) => {
329
+ const result = resolveIfs('<if label="test" condition="show">visible</if>', {show: true});
330
+ if(result !== 'visible') return fail(`got: ${result}`);
331
+ pass();
332
+ },
333
+ 'resolveIfs handles extra attributes after condition': ({pass, fail}) => {
334
+ const result = resolveIfs('<if condition="show" label="test">visible</if>', {show: false});
335
+ if(result !== '') return fail(`got: ${result}`);
336
+ pass();
337
+ },
338
+ 'resolveForeach handles reversed attribute order': ({pass, fail}) => {
339
+ const result = resolveForeach('<foreach as="item" in="items">{{item}},</foreach>', {items: ['a', 'b']});
340
+ if(result !== 'a,b,') return fail(`got: ${result}`);
341
+ pass();
342
+ },
343
+ 'resolveForeach handles extra attributes': ({pass, fail}) => {
344
+ const result = resolveForeach('<foreach in="items" as="item" label="List">{{item}}</foreach>', {items: ['x', 'y']});
345
+ if(result !== 'xy') return fail(`got: ${result}`);
346
+ pass();
347
+ },
348
+ 'extractContentBlocks handles excessive whitespace': ({pass, fail}) => {
349
+ const blocks = extractContentBlocks('<content location="main" >Hello</content>');
350
+ if(!Array.isArray(blocks.main) || blocks.main[0].html !== 'Hello') return fail(`got: ${JSON.stringify(blocks.main)}`);
351
+ pass();
352
+ },
353
+ 'extractContentBlocks handles extra custom attributes': ({pass, fail}) => {
354
+ const blocks = extractContentBlocks('<content location="main" label="Main" editable="true">Hello</content>');
355
+ if(!Array.isArray(blocks.main) || blocks.main[0].html !== 'Hello') return fail(`got: ${JSON.stringify(blocks.main)}`);
356
+ pass();
276
357
  }
277
358
  };