Skip to content

Commit

Permalink
refactor: move stylesheets to template-compiler
Browse files Browse the repository at this point in the history
  • Loading branch information
jmsjtu committed Apr 12, 2024
1 parent a38458b commit 16d13f3
Show file tree
Hide file tree
Showing 13 changed files with 305 additions and 145 deletions.
119 changes: 5 additions & 114 deletions packages/@lwc/compiler/src/transformers/template.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,6 @@
* SPDX-License-Identifier: MIT
* For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/MIT
*/
import * as path from 'path';
import { APIFeature, APIVersion, isAPIFeatureEnabled } from '@lwc/shared';
import {
CompilerError,
normalizeToCompilerError,
Expand Down Expand Up @@ -52,7 +50,9 @@ export default function templateTransform(

let result;
try {
result = compile(src, {
result = compile(src, filename, {
name,
namespace,
experimentalDynamicDirective,
// TODO [#3370]: remove experimental template expression flag
experimentalComplexExpressions,
Expand All @@ -76,121 +76,12 @@ export default function templateTransform(
// thrown above. As for "Log" and "Fatal", they are currently unused.
const warnings = result.warnings.filter((_) => _.level === DiagnosticLevel.Warning);

// TODO [#3733]: remove support for legacy scope tokens
const { scopeToken, legacyScopeToken } = generateScopeTokens(filename, namespace, name);

// Rollup only cares about the mappings property on the map. Since producing a source map for
// the template doesn't make sense, the transform returns an empty mappings.
return {
code: serialize(result.code, filename, scopeToken, legacyScopeToken, apiVersion),
code: result.code,
map: { mappings: '' },
warnings,
cssScopeTokens: [
scopeToken,
`${scopeToken}-host`, // implicit scope token created by `makeHostToken()` in `@lwc/engine-core`
// The legacy tokens must be returned as well since we technically don't know what we're going to render
// This is not strictly required since this is only used for Jest serialization (as of this writing),
// and people are unlikely to set runtime flags in Jest, but it is technically correct to include this.
legacyScopeToken,
`${legacyScopeToken}-host`,
],
cssScopeTokens: result.cssScopeTokens,
};
}

// The reason this hash code implementation [1] is chosen is because:
// 1. It has a very low hash collision rate - testing a list of 466,551 English words [2], it generates no collisions
// 2. It is fast - it can hash those 466k words in 70ms (Node 16, 2020 MacBook Pro)
// 3. The output size is reasonable (32-bit - this can be base-32 encoded at 10-11 characters)
//
// Also note that the reason we're hashing rather than generating a random number is because
// we want the output to be predictable given the input, which helps with caching.
//
// [1]: https://stackoverflow.com/a/52171480
// [2]: https://github.com/dwyl/english-words/blob/a77cb15f4f5beb59c15b945f2415328a6b33c3b0/words.txt
function generateHashCode(str: string) {
const seed = 0;
let h1 = 0xdeadbeef ^ seed;
let h2 = 0x41c6ce57 ^ seed;
for (let i = 0, ch; i < str.length; i++) {
ch = str.charCodeAt(i);
h1 = Math.imul(h1 ^ ch, 2654435761);
h2 = Math.imul(h2 ^ ch, 1597334677);
}
h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507);
h1 ^= Math.imul(h2 ^ (h2 >>> 13), 3266489909);
h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507);
h2 ^= Math.imul(h1 ^ (h1 >>> 13), 3266489909);

return 4294967296 * (2097151 & h2) + (h1 >>> 0);
}

function escapeScopeToken(input: string) {
// Minimal escape for strings containing the "@" and "#" characters, which are disallowed
// in certain cases in attribute names
return input.replace(/@/g, '___at___').replace(/#/g, '___hash___');
}

function generateScopeTokens(
filename: string,
namespace: string | undefined,
name: string | undefined
) {
const uniqueToken = `${namespace}-${name}_${path.basename(filename, path.extname(filename))}`;

// This scope token is all lowercase so that it works correctly in case-sensitive namespaces (e.g. SVG).
// It is deliberately designed to discourage people from relying on it by appearing somewhat random.
// (But not totally random, because it's nice to have stable scope tokens for our own tests.)
// Base-32 is chosen because it is not case-sensitive (0-v), and generates short strings with the given hash
// code implementation (10-11 characters).
const hashCode = generateHashCode(uniqueToken);
const scopeToken = `lwc-${hashCode.toString(32)}`;

// This scope token is based on the namespace and name, and contains a mix of uppercase/lowercase chars
const legacyScopeToken = escapeScopeToken(uniqueToken);

return {
scopeToken,
legacyScopeToken,
};
}

function serialize(
code: string,
filename: string,
scopeToken: string,
legacyScopeToken: string,
apiVersion: APIVersion
): string {
const cssRelPath = `./${path.basename(filename, path.extname(filename))}.css`;
const scopedCssRelPath = `./${path.basename(filename, path.extname(filename))}.scoped.css`;

let buffer = '';
buffer += `import { freezeTemplate } from "lwc";\n\n`;
buffer += `import _implicitStylesheets from "${cssRelPath}";\n\n`;
buffer += `import _implicitScopedStylesheets from "${scopedCssRelPath}?scoped=true";\n\n`;
buffer += code;
buffer += '\n\n';
buffer += 'if (_implicitStylesheets) {\n';
buffer += ` tmpl.stylesheets.push.apply(tmpl.stylesheets, _implicitStylesheets);\n`;
buffer += `}\n`;
buffer += 'if (_implicitScopedStylesheets) {\n';
buffer += ` tmpl.stylesheets.push.apply(tmpl.stylesheets, _implicitScopedStylesheets);\n`;
buffer += `}\n`;

if (isAPIFeatureEnabled(APIFeature.LOWERCASE_SCOPE_TOKENS, apiVersion)) {
// Include both the new and legacy tokens, so that the runtime can decide based on a flag whether
// we need to render the legacy one. This is designed for cases where the legacy one is required
// for backwards compat (e.g. global stylesheets that rely on the legacy format for a CSS selector).
buffer += `tmpl.stylesheetToken = "${scopeToken}";\n`;
buffer += `tmpl.legacyStylesheetToken = "${legacyScopeToken}";\n`;
} else {
// In old API versions, we can just keep doing what we always did
buffer += `tmpl.stylesheetToken = "${legacyScopeToken}";\n`;
}

// Note that `renderMode` and `slots` are already rendered in @lwc/template-compiler and appear
// as `code` above. At this point, no more expando props should be added to `tmpl`.
buffer += 'freezeTemplate(tmpl);\n';

return buffer;
}
5 changes: 5 additions & 0 deletions packages/@lwc/template-compiler/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,15 @@ yarn add --dev @lwc/template-compiler
```js
import { compile } from '@lwc/template-compiler';

const filename = 'component.html';
const options = {};
const { code, warnings } = compile(
`
<template>
<h1>Hello World!</h1>
</template>
`,
filename,
options
);

Expand All @@ -44,10 +46,13 @@ const { code, warnings } = compile(`<template><h1>Hello World!</h1></template>`,
**Parameters:**

- `source` (string, required) - the HTML template source to compile.
- `filename` (string, required) - the source filename with extension.
- `options` (object, required) - the options to used to compile the HTML template source.

**Options:**

- `name` (type: `string`, optional, `undefined` by default) - name of the component, e.g. `foo` in `x/foo`.
- `namespace` (type: `string`, optional, `undefined` by default) - namespace of the component, e.g. `x` in `x/foo`.
- `experimentalComputedMemberExpression` (boolean, optional, `false` by default) - set to `true` to enable computed member expression in the template, eg: `{list[0].name}`.
- `experimentalComplexExpressions` (boolean, optional, `false` by default) - set to `true` to enable use of (a subset of) JavaScript expressions in place of template bindings.
- `experimentalDynamicDirective` (boolean, optional, `false` by default) - set to `true` to allow the usage of `lwc:dynamic` directives in the template.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,14 @@ describe('fixtures', () => {
},
({ src, dirname }) => {
const configPath = path.resolve(dirname, 'config.json');
const filename = path.basename(dirname);

let config: Config = {};
let config: Config = { namespace: 'x', name: filename };
if (fs.existsSync(configPath)) {
config = require(configPath);
config = { ...config, ...require(configPath) };
}

const compiled = compiler(src, config);
const compiled = compiler(src, filename, config);
const { warnings, root } = compiled;

// Replace LWC's version with X.X.X so the snapshots don't frequently change
Expand Down
8 changes: 4 additions & 4 deletions packages/@lwc/template-compiler/src/__tests__/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,13 @@ describe('option validation', () => {
it('validated presence of options', () => {
expect(() => {
// @ts-expect-error Explicitly testing JS behavior that violates TS types
compile(`<template></template>`);
compile(`<template></template>`, '');
}).toThrow(/Compiler options must be an object/);
});

it('throws for unknown compiler option', () => {
expect(() => {
compile(`<template></template>`, { foo: true } as any);
compile(`<template></template>`, '', { foo: true } as any);
}).toThrow(/Unknown option property foo/);
});
});
Expand Down Expand Up @@ -48,7 +48,7 @@ describe('parse', () => {
];
configs.forEach(({ name, config }) => {
it(`parse() with double </template> with config=${name}`, () => {
const result = parse('<template></template></template>', config);
const result = parse('<template></template></template>', '', config);
const expectWarning = config.apiVersion === 58; // null/undefined/unspecified is treated as latest
expect(result.warnings.length).toBe(1);
expect(result.warnings[0].level).toBe(
Expand Down Expand Up @@ -76,7 +76,7 @@ describe('parse', () => {
configs.forEach(({ name, config, expected }) => {
it(name, () => {
const template = `<template><img src="http://example.com/img.png" crossorigin="anonymous"></template>`;
const { code, warnings } = compile(template, config);
const { code, warnings } = compile(template, '', config);
expect(warnings.length).toBe(0);
if (expected) {
expect(code).toContain('<img');
Expand Down
49 changes: 42 additions & 7 deletions packages/@lwc/template-compiler/src/codegen/formatters/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,16 @@
* SPDX-License-Identifier: MIT
* For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/MIT
*/
import path from 'path';
import * as t from '../../shared/estree';
import { kebabcaseToCamelcase } from '../../shared/naming';
import {
TEMPLATE_FUNCTION_NAME,
SECURE_REGISTER_TEMPLATE_METHOD_NAME,
LWC_MODULE_NAME,
FREEZE_TEMPLATE,
IMPLICIT_STYLESHEETS,
IMPLICIT_STYLESHEET_IMPORTS,
} from '../../shared/constants';

import CodeGen from '../codegen';
Expand All @@ -28,15 +32,31 @@ function generateComponentImports(codeGen: CodeGen): t.ImportDeclaration[] {
}

function generateLwcApisImport(codeGen: CodeGen): t.ImportDeclaration {
const imports = Array.from(codeGen.usedLwcApis)
.sort()
.map((name) => {
return t.importSpecifier(t.identifier(name), t.identifier(name));
});
// freezeTemplate will always be needed and is called once it has been created.
const imports = [...codeGen.usedLwcApis, FREEZE_TEMPLATE].sort().map((name) => {
return t.importSpecifier(t.identifier(name), t.identifier(name));
});

return t.importDeclaration(imports, t.literal(LWC_MODULE_NAME));
}

function generateStylesheetImports(codeGen: CodeGen): t.ImportDeclaration[] {
const {
state: { filename },
} = codeGen;

const relPath = `./${path.basename(filename, path.extname(filename))}`;
const imports = IMPLICIT_STYLESHEET_IMPORTS.map((stylesheet) => {
const extension = stylesheet === IMPLICIT_STYLESHEETS ? '.css' : '.scoped.css?scoped=true';
return t.importDeclaration(
[t.importDefaultSpecifier(t.identifier(stylesheet))],
t.literal(`${relPath}${extension}`)
);
});

return imports;
}

function generateHoistedNodes(codegen: CodeGen): t.VariableDeclaration[] {
return codegen.hoistedNodes.map(({ identifier, expr }) => {
return t.variableDeclaration('const', [t.variableDeclarator(identifier, expr)]);
Expand Down Expand Up @@ -66,7 +86,11 @@ function generateHoistedNodes(codegen: CodeGen): t.VariableDeclaration[] {
export function format(templateFn: t.FunctionDeclaration, codeGen: CodeGen): t.Program {
codeGen.usedLwcApis.add(SECURE_REGISTER_TEMPLATE_METHOD_NAME);

const imports = [...generateComponentImports(codeGen), generateLwcApisImport(codeGen)];
const imports = [
...generateStylesheetImports(codeGen),
...generateComponentImports(codeGen),
generateLwcApisImport(codeGen),
];
const hoistedNodes = generateHoistedNodes(codeGen);

const metadata = generateTemplateMetadata(codeGen);
Expand All @@ -82,5 +106,16 @@ export function format(templateFn: t.FunctionDeclaration, codeGen: CodeGen): t.P
),
];

return t.program([...imports, ...hoistedNodes, ...templateBody, ...metadata]);
const freezeTemplate = t.expressionStatement(
t.callExpression(t.identifier(FREEZE_TEMPLATE), [t.identifier(TEMPLATE_FUNCTION_NAME)])
);

return t.program([
...imports,
...hoistedNodes,
...templateBody,
...metadata,
// At this point, no more expando props should be added to `tmpl`.
freezeTemplate,
]);
}

0 comments on commit 16d13f3

Please sign in to comment.