Reading an element file
An element file read into its parts, each part as a module on the file's own lines, and every check that needs only the file, each failing with the line.
import { describe, expect, test } from "vite-plus/test";
import { elementStyle, readElement, setupModule, templateModule } from "./file.ts";
const file = "/site/code/@tag-filter.html";
const read = (source: string) => readElement(file, ["page", "pages", "site"], source);
const fails = (source: string) => {
try {
read(source);
} catch (error) {
return (error as Error).message;
}
throw new Error("expected it to fail");
};
const full = `<!-- Filters posts by tag. -->
<template element="tag-filter">
<script>
export const tags = (pages) => pages.flatMap((p) => p.tags ?? []);
</script>
<p>\${tags(pages).map((t) => html\`<button>\${t}</button>\`)}</p>
</template>
<style>
tag-filter {
display: block;
}
</style>
<script>
export default (el, { signal }) => {
el.addEventListener("click", () => {}, { signal });
};
</script>
`;
describe("readElement", () => {
test("reads each part, and the template's script apart from its markup", () => {
const element = read(full);
expect(element.name).toBe("tag-filter");
expect(element.template!.script!.text).toContain("export const tags");
expect(element.template!.markup.text.trim()).toMatch(/^<p>/);
expect(element.style!.text).toContain("display: block");
expect(element.script!.text).toContain("export default");
});
test("gives each part as a module whose lines are the file's", () => {
const element = read(full);
const line = (code: string, text: string) =>
code.split("\n").findIndex((l) => l.includes(text)) + 1;
const template = templateModule(element, ["page", "pages", "site"]);
expect(line(template, "export const tags")).toBe(4);
expect(line(template, "export default ({ attrs, html, page, pages, site }) => html`")).toBe(5);
expect(line(template, "<p>")).toBe(6);
expect(template).not.toContain("display: block");
expect(line(setupModule(element), "addEventListener")).toBe(17);
const style = elementStyle(element);
expect(line(style, "@layer elements {")).toBe(9);
expect(line(style, "display: block")).toBe(11);
});
test("takes every part as optional", () => {
expect(read("").template).toBeUndefined();
expect(read("<template><b>x</b></template>").template!.script).toBeUndefined();
expect(read("<style>tag-filter { color: red }</style>").style).toBeDefined();
});
test.each([
["markup outside the template", "<p>x</p>", "line 1: <p> is outside the <template>."],
[
"text outside the template",
"<template></template>\nhi",
"line 2: this text is outside the <template>.",
],
["a second style", "<style></style><style></style>", "line 1: this is a second <style>."],
["an unclosed template", "\n<template><b>x</b>", "line 2: <template> is never closed."],
["a stray end tag", "</div>", "line 1: </div> closes nothing."],
[
"a template's script after its markup",
"<template><p>x</p><script>export const a = 1;</script></template>",
"the template's <script> comes after its markup",
],
[
"two scripts in a template",
"<template><script></script><p>x</p><script></script></template>",
"this is a second <script> in the <template>",
],
[
"an element name that isn't the file's",
'<template element="tag-list"></template>',
'write element="tag-filter" or leave it out',
],
[
"a named slot",
'<template>\n<slot name="x"></slot></template>',
"line 2: an element has one slot, with no name",
],
[
"a shadow root",
'<template shadowrootmode="open"></template>',
"shadowrootmode makes a shadow root",
],
[
"an export named like the scope",
"<template><script>\nexport const page = 1;\n</script></template>",
"line 2: this exports page, which the template already has (attrs, html, page, pages, site).",
],
[
"a default export in the template's script",
"<template><script>export default 1;</script></template>",
"the template's script has a default export",
],
[
"</script> in a string",
'<script>\nconst a = "</script>";\nexport default () => a;\n</script>',
"line 2: this </script> is inside the script's code",
],
[
"a browser script with no default",
"<script>\nconst a = 1;\n</script>",
"the <script> has no default export",
],
[
"a browser script that exports a class",
"<script>\nexport default class extends HTMLElement {}\n</script>",
"line 2: the <script> exports a class",
],
])("fails on %s", (_, source, message) => {
expect(fails(source)).toContain(message);
});
test("reads </script> written <\\/script> in a string, and <template> in one, as code", () => {
expect(() =>
read('<script>\nconst a = "<\\/script><template>";\nexport default () => a;\n</script>'),
).not.toThrow();
});
});
describe("an element's CSS", () => {
test.each([
["the tag", "tag-filter { color: red }"],
[
"the tag with a descendant, a class and a list",
"tag-filter li, tag-filter.wide, tag-filter:not(:defined) p { color: red }",
],
["nesting", "tag-filter { & .frame { color: red } .x, .y { color: blue } }"],
[
"rules in @media and @layer",
"@media (width > 40rem) { tag-filter { color: red } } @layer x { tag-filter p { color: red } }",
],
[
"at-rules with no selectors",
"@keyframes spin { from { rotate: 0 } to { rotate: 1turn } } @import url(x.css);",
],
["a brace in a string and a comment", 'tag-filter::before { content: "}" } /* li { } */'],
])("passes %s", (_, css) => {
expect(() => read(`<style>${css}</style>`)).not.toThrow();
});
test.each([
["another selector", "\np { color: red }", "line 2: the rule p doesn't start with tag-filter."],
[
"one in a list",
"tag-filter, .x { color: red }",
"the rule .x doesn't start with tag-filter.",
],
["a longer name", "tag-filter-x { color: red }", "the rule tag-filter-x doesn't start"],
["one inside @media", "@media print { li { color: red } }", "the rule li doesn't start"],
])("fails on %s", (_, css, message) => {
expect(fails(`<style>${css}</style>`)).toContain(message);
});
});