UNPKG

@11ty/webc

Version:

Single File Web Components

255 lines (211 loc) 7.8 kB
import { createHash } from "crypto"; import { WebC } from "../webc.js"; import { AstQuery } from "./astQuery.js"; import { AstModify } from "./astModify.js"; import { AstSerializer } from "./ast.js"; import { ModuleScript } from "./moduleScript.cjs"; class ComponentManager { constructor() { this.parsingPromises = {}; this.components = {}; this.hashOverrides = {}; } static getNewLineStartIndeces(content) { let lineStarts = []; let sum = 0; let lineEnding = "\n"; // this should work okay with \r\n too, \r will just be treated as another character for(let line of content.split(lineEnding)) { lineStarts.push(sum); sum += line.length + lineEnding.length; } return lineStarts; } async getSetupScriptValue(component, filePath, dataCascade) { // <style webc:scoped> must be nested at the root let setupScriptNode = AstQuery.getFirstTopLevelNode(component, false, AstSerializer.attrs.SETUP); if(setupScriptNode) { let content = AstQuery.getTextContent(setupScriptNode).toString(); // importantly for caching: this has no attributes or context sensitive things, only global helpers and global data let data = dataCascade.getData(true); // async-friendly return ModuleScript.evaluateScriptAndReturnAllGlobals(content, filePath, data); } } getRootMode(topLevelNodes) { // Has <* webc:root> (has to be a root child, not script/style) for(let child of topLevelNodes) { let rootNodeMode = AstQuery.getRootNodeMode(child); if(rootNodeMode) { return rootNodeMode; } } } ignoreComponentParentTag(topLevelNodes, rootAttributeMode, hasDeclarativeShadowDom) { if(rootAttributeMode) { // do not use parent tag if webc:root="override" if(rootAttributeMode === "override") { return true; } // use parent tag if webc:root (and not webc:root="override") return false; } // use parent tag if <style> or <script> in component definition (unless <style webc:root> or <script webc:root>) // TODO <script webc:type="js"> with implied webc:is="template" https://github.com/11ty/webc/issues/135 for(let child of topLevelNodes) { let tagName = AstQuery.getTagName(child); if(tagName !== "script" && tagName !== "style" && !AstQuery.isLinkStylesheetNode(tagName, child) || AstQuery.hasAttribute(child, AstSerializer.attrs.SETUP)) { continue; } if(AstQuery.hasTextContent(child)) { return false; // use parent tag if script/style has non-empty values } // <script src=""> or <link rel="stylesheet" href=""> if(AstQuery.getExternalSource(tagName, child)) { return false; // use parent tag if script/link have external file refs } } // Use parent tag if has declarative shadow dom node (can be anywhere in the component body) // We already did the AstQuery.hasDeclarativeShadowDomChild search upstream. if(hasDeclarativeShadowDom) { return false; } // Do not use parent tag return true; } // Support for `base64url` needs gating e.g. is not available on Stackblitz on Node 16 // https://github.com/nodejs/node/issues/26512 getDigest(hash) { let prefix = "w"; let hashLength = 8; let digest; if(Buffer.isEncoding('base64url')) { digest = hash.digest("base64url"); } else { // https://github.com/11ty/eleventy-img/blob/e51ad8e1da4a7e6528f3cc8f4b682972ba402a67/img.js#L343 digest = hash.digest('base64').replace(/=/g, "").replace(/\+/g, "-").replace(/\//g, "_"); } return prefix + digest.toLowerCase().slice(0, hashLength); } getScopedStyleHash(component, filePath) { let hash = createHash("sha256"); // <style webc:scoped> must be nested at the root let styleNodes = AstQuery.getTopLevelNodes(component, [], [AstSerializer.attrs.SCOPED]); for(let node of styleNodes) { let tagName = AstQuery.getTagName(node); if(tagName !== "style" && !AstQuery.isLinkStylesheetNode(tagName, node)) { continue; } // Override hash with scoped="override" let override = AstQuery.getAttributeValue(node, AstSerializer.attrs.SCOPED); if(override) { if(this.hashOverrides[override]) { if(this.hashOverrides[override] !== filePath) { throw new Error(`You have \`webc:scoped\` override collisions! See ${this.hashOverrides[override]} and ${filePath}`); } } else { this.hashOverrides[override] = filePath; } return override; } if(tagName === "style") { // hash based on the text content // NOTE this does *not* process script e.g. <script webc:type="render" webc:is="style" webc:scoped> (see render-css.webc) let hashContent = AstQuery.getTextContent(node).toString(); hash.update(hashContent); } else { // link stylesheet // hash based on the file name hash.update(AstQuery.getAttributeValue(node, "href")); } } if(styleNodes.length) { // don’t return a hash if empty // `base64url` is not available on StackBlitz return this.getDigest(hash); } } /* Careful, this one mutates */ static addImpliedWebCAttributes(node) { if(node._webcImpliedAttributesAdded) { return; } node._webcImpliedAttributesAdded = true; if(AstQuery.isDeclarativeShadowDomNode(node)) { AstModify.addAttribute(node, AstSerializer.attrs.NOBUNDLE, ""); } // webc:type="js" (WebC v0.9.0+) has implied webc:is="template" webc:nokeep if(AstQuery.getAttributeValue(node, AstSerializer.attrs.TYPE) === AstSerializer.transformTypes.JS) { // this check is perhaps unnecessary since KEEP has a higher precedence than NOKEEP if(!AstQuery.hasAttribute(node, AstSerializer.attrs.KEEP)) { AstModify.addAttribute(node, AstSerializer.attrs.NOKEEP, ""); } if(!AstQuery.hasAttribute(node, AstSerializer.attrs.IS)) { AstModify.addAttribute(node, AstSerializer.attrs.IS, "template"); } } } has(filePath) { return filePath in this.components; } get(filePath) { return this.components[filePath]; } async parse(filePath, mode, dataCascade, ast, content) { if(this.components[filePath]) { // already parsed return; } // parsing in progress if(this.parsingPromises[filePath]) { return this.parsingPromises[filePath]; } let parsingResolve; this.parsingPromises[filePath] = new Promise((resolve) => { parsingResolve = resolve; }); let isTopLevelComponent = !!ast; // ast is passed in for Top Level components // if ast is provided, this is the top level component if(!isTopLevelComponent) { mode = "component"; } if(!ast) { let parsed = await WebC.getFromFilePath(filePath); ast = parsed.ast; content = parsed.content; mode = parsed.mode; } let scopedStyleHash = this.getScopedStyleHash(ast, filePath); // only executes once per component let setupScript = await this.getSetupScriptValue(ast, filePath, dataCascade); let hasDeclarativeShadowDom = AstQuery.hasDeclarativeShadowDomChild(ast); let topLevelNodes = AstQuery.getTopLevelNodes(ast); // important for ignoreComponentParentTag, issue #135 for(let node of topLevelNodes) { ComponentManager.addImpliedWebCAttributes(node); } let rootAttributeMode = this.getRootMode(topLevelNodes); let ignoreRootTag = this.ignoreComponentParentTag(topLevelNodes, rootAttributeMode, hasDeclarativeShadowDom); let slotTargets = AstQuery.getSlotTargets(ast); this.components[filePath] = { filePath, ast, content, get newLineStartIndeces() { if(!this._lineStarts) { this._lineStarts = ComponentManager.getNewLineStartIndeces(content); } return this._lineStarts; }, mode, isTopLevelComponent, hasDeclarativeShadowDom, ignoreRootTag, scopedStyleHash, rootAttributeMode, rootAttributes: AstQuery.getRootAttributes(ast, scopedStyleHash), slotTargets: slotTargets, setupScript, }; parsingResolve(); } } export { ComponentManager };