Skip to content

Commit

Permalink
Merge pull request #131 from 11ty/component-cache
Browse files Browse the repository at this point in the history
Adds a top level WebC component parsing cache and huge performance wins from Node vm context improvements
  • Loading branch information
zachleat authored Mar 15, 2023
2 parents 95aaac3 + f6c0c7f commit 13b46e6
Show file tree
Hide file tree
Showing 13 changed files with 582 additions and 360 deletions.
312 changes: 50 additions & 262 deletions src/ast.js

Large diffs are not rendered by default.

64 changes: 64 additions & 0 deletions src/astQuery.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,36 @@ class AstQuery {
return AstQuery.voidElements[tagName] || false;
}

/* Specific queries */
static getSlotTargets(node) {
let targetNodes = AstQuery.findAllElements(node, "slot");
let map = {};
for(let target of targetNodes) {
let name = AstQuery.getAttributeValue(target, "name") || "default";
map[name] = true;
}
return map;
}

static isLinkStylesheetNode(tagName, node) {
return tagName === "link" && AstQuery.getAttributeValue(node, "rel") === "stylesheet";
}

// filter out webc:setup
static isScriptNode(tagName, node) {
return tagName === "script" && !AstQuery.hasAttribute(node, AstSerializer.attrs.SETUP);
}

static getExternalSource(tagName, node) {
if(AstQuery.isLinkStylesheetNode(tagName, node)) {
return AstQuery.getAttributeValue(node, "href");
}

if(AstQuery.isScriptNode(tagName, node)) {
return AstQuery.getAttributeValue(node, "src");
}
}

/* Attributes */
static hasAttribute(node, attributeName) {
return (node.attrs || []).find(({name}) => name === attributeName) !== undefined;
Expand All @@ -63,6 +93,40 @@ class AstQuery {
return nameAttr?.value;
}

static getRootNodeMode(node) {
// override is when child component definitions override the host component tag
let rootAttributeValue = AstQuery.getAttributeValue(node, AstSerializer.attrs.ROOT);
if(rootAttributeValue) {
return rootAttributeValue;
}
// merge is when webc:root attributes flow up to the host component (and the child component tag is ignored)
if(rootAttributeValue === "") {
return "merge";
}
return false;
}

static getRootAttributes(component, scopedStyleHash) {
let attrs = [];

// webc:root Attributes
let tops = AstQuery.getTopLevelNodes(component, [], [AstSerializer.attrs.ROOT]);
for(let root of tops) {
for(let attr of root.attrs) {
if(attr.name !== AstSerializer.attrs.ROOT) {
attrs.push({ name: attr.name, value: attr.value });
}
}
}

if(scopedStyleHash) {
// it’s okay if there are other `class` attributes, we merge them later
attrs.push({ name: "class", value: scopedStyleHash });
}

return attrs;
}

/* Declarative Shadow DOM */
static isDeclarativeShadowDomNode(node) {
let tagName = AstQuery.getTagName(node);
Expand Down
68 changes: 35 additions & 33 deletions src/attributeSerializer.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,10 @@ class AttributeSerializer {
continue;
}

for(let splitVal of value.split(merged[name].splitDelimiter)) {
if(typeof value !== "string") {
value = value.toString();
}
for(let splitVal of value.toString().split(merged[name].splitDelimiter)) {
splitVal = splitVal.trim();
if(splitVal) {
merged[name].value.push(splitVal);
Expand Down Expand Up @@ -113,24 +116,6 @@ class AttributeSerializer {
};
}

static async evaluateAttribute(rawName, value, data) {
let {name, evaluation, privacy} = AttributeSerializer.peekAttribute(rawName);
let evaluatedValue = value;
if(evaluation === "script") {
let { returns } = await ModuleScript.evaluateScript(value, data, `Check the dynamic attribute: \`${rawName}="${value}"\`.`);
evaluatedValue = returns;
}

return {
name,
rawName,
value: evaluatedValue,
rawValue: value,
evaluation,
privacy,
};
}

// Remove props prefixes, swaps dash to camelcase
// Keeps private entries (used in data)
static async normalizeAttributesForData(attrs) {
Expand Down Expand Up @@ -168,26 +153,43 @@ class AttributeSerializer {
return newData;
}

static async evaluateAttribute(rawName, value, data, scriptContextKey) {
let {name, evaluation, privacy} = AttributeSerializer.peekAttribute(rawName);
let evaluatedValue = value;
if(evaluation === "script") {
let { returns } = await ModuleScript.evaluateScriptInline(value, data, `Evaluating a dynamic attribute failed: \`${rawName}="${value}"\`.`, scriptContextKey);
evaluatedValue = returns;
}

return {
name,
rawName,
value: evaluatedValue,
rawValue: value,
evaluation,
privacy,
};
}

// attributesArray: parse5 format, Array of [{name, value}]
// returns: same array with additional properties added
static async evaluateAttributesArray(attributesArray, data) {
static async evaluateAttributesArray(attributesArray, data, scriptContextKey) {
let evaluated = [];
// TODO promise.all
for(let attr of attributesArray) {
let entry = {};
let {name, rawName, value, rawValue, evaluation, privacy} = await AttributeSerializer.evaluateAttribute(attr.name, attr.value, data);

entry.rawName = rawName;
entry.rawValue = rawValue;

entry.name = name;
entry.value = value;
entry.privacy = privacy;
entry.evaluation = evaluation;
evaluated.push(entry);
evaluated.push(AttributeSerializer.evaluateAttribute(attr.name, attr.value, data, scriptContextKey).then((result) => {
let { name, rawName, value, rawValue, evaluation, privacy } = result;
let entry = {};
entry.rawName = rawName;
entry.rawValue = rawValue;

entry.name = name;
entry.value = value;
entry.privacy = privacy;
entry.evaluation = evaluation;
return entry;
}));
}
return evaluated;
return Promise.all(evaluated);
}

static setKeyPrivacy(obj, name, privacy) {
Expand Down
198 changes: 198 additions & 0 deletions src/componentManager.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
import { createHash } from "crypto";

import { WebC } from "../webc.js";
import { AstQuery } from "./astQuery.js";
import { AstSerializer } from "./ast.js";
import { ModuleScript } from "./moduleScript.cjs";

class ComponentManager {
constructor() {
this.parsingPromises = {};
this.components = {};
this.hashOverrides = {};
}

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();

// async-friendly
return ModuleScript.evaluateScriptAndReturnAllGlobals(content, filePath, data);
}
}

ignoreComponentParentTag(component, hasDeclarativeShadowDom) {
// Has <* webc:root> (has to be a root child, not script/style)
let tops = AstQuery.getTopLevelNodes(component);
for(let child of tops) {
let rootNodeMode = AstQuery.getRootNodeMode(child);
if(rootNodeMode) {
// do not use parent tag if webc:root="override"
if(rootNodeMode === "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>)
for(let child of tops) {
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);
}
}

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);

this.components[filePath] = {
filePath,
ast,
content,
get newLineStartIndeces() {
if(!this._lineStarts) {
this._lineStarts = AstSerializer.getNewLineStartIndeces(content);
}
return this._lineStarts;
},

mode,
hasDeclarativeShadowDom,
ignoreRootTag: this.ignoreComponentParentTag(ast, hasDeclarativeShadowDom),
scopedStyleHash,
rootAttributes: AstQuery.getRootAttributes(ast, scopedStyleHash),
slotTargets: AstQuery.getSlotTargets(ast),
setupScript,
};

parsingResolve();
}
}

export { ComponentManager };
5 changes: 3 additions & 2 deletions src/dataCascade.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,10 @@ class DataCascade {
return this.helpers;
}

getData(attributes, hostAttributes, setupScript) {
getData(attributes, ...additionalObjects) {
// TODO improve perf by re-using a merged object of the global stuff
return Object.assign({}, this.globalData, this.helpers, setupScript, hostAttributes, attributes, {
let objs = additionalObjects.reverse();
return Object.assign({}, this.globalData, this.helpers, ...objs, attributes, {
webc: {
attributes,
...this.webcGlobals,
Expand Down
Loading

0 comments on commit 13b46e6

Please sign in to comment.