pagez

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 { FileError } from "../../src/elements/errors.ts";
import { readElement, setupModule, templateModule } from "../../src/elements/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) {
    if (error instanceof FileError) return `${error.message} [${error.id}]`;
    throw error;
  }
  throw new Error("expected it to fail");
};

const full = `<!-- Filters posts by tag. -->
<template element="tag-filter">
  <script>
    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("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, 'import { html } from "@amitkaps/pagez";')).toBe(1);
    expect(line(template, "const tags")).toBe(4);
    expect(line(template, "export default ({ attrs, 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);
  });

  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>.",
      "error-element-file-outside",
    ],
    [
      "text outside the template",
      "<template></template>\nhi",
      "line 2: this text is outside the <template>.",
      "error-element-file-outside",
    ],
    [
      "a second style",
      "<style></style><style></style>",
      "line 1: this is a second <style>.",
      "error-element-file-second-part",
    ],
    [
      "an unclosed template",
      "\n<template><b>x</b>",
      "line 2: <template> is never closed.",
      "error-element-file-unclosed",
    ],
    ["a stray end tag", "</div>", "line 1: </div> closes nothing.", "error-element-file-stray-end"],
    [
      "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",
      "error-template-script-after",
    ],
    [
      "two scripts in a template",
      "<template><script></script><p>x</p><script></script></template>",
      "this is a second <script> in the <template>",
      "error-template-two-scripts",
    ],
    [
      "an element name that isn't the file's",
      '<template element="tag-list"></template>',
      'write element="tag-filter" or leave it out',
      "error-name-mismatch",
    ],
    [
      "a named slot",
      '<template>\n<slot name="x"></slot></template>',
      "line 2: an element has one slot, with no name",
      "error-slot-named",
    ],
    [
      "a shadow root",
      '<template shadowrootmode="open"></template>',
      "shadowrootmode makes a shadow root",
      "error-template-shadow-root",
    ],
    [
      "a declaration named like the scope",
      "<template><script>\nconst page = 1;\n</script></template>",
      "line 2: this declares page, which the template already has (attrs, html, page, pages, site).",
      "error-template-own-name",
    ],
    [
      "an export named like the scope",
      "<template><script>\nexport function attrs() {}\n</script></template>",
      "line 2: this declares attrs, which the template already has",
      "error-template-own-name",
    ],
    [
      "a name in a pattern named like the scope",
      "<template><script>\nconst { a, b: [site] } = x;\n</script></template>",
      "line 2: this declares site, which the template already has",
      "error-template-own-name",
    ],
    [
      "an import of html, which the script has",
      '<template><script>\nimport { html } from "@amitkaps/pagez";\n</script></template>',
      "line 2: this imports html, which the template's script already has. Remove it.",
      "error-template-own-name",
    ],
    [
      "a default export in the template's script",
      "<template><script>export default 1;</script></template>",
      "the template's script has a default export",
      "error-template-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",
      "error-script-end-in-code",
    ],
    [
      "a browser script with no default",
      "<script>\nconst a = 1;\n</script>",
      "the <script> has no default export",
      "error-setup-no-default",
    ],
    [
      "a browser script that exports a class",
      "<script>\nexport default class extends HTMLElement {}\n</script>",
      "line 2: the <script> exports a class",
      "error-setup-class",
    ],
  ])("fails on %s", (_, source, message, id) => {
    expect(fails(source)).toContain(message);
    expect(fails(source)).toMatch(new RegExp(`\\[${id}\\]$`));
  });

  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.",
      "error-style-selector",
    ],
    [
      "one in a list",
      "tag-filter, .x { color: red }",
      "the rule .x doesn't start with tag-filter.",
      "error-style-selector",
    ],
    [
      "a longer name",
      "tag-filter-x { color: red }",
      "the rule tag-filter-x doesn't start",
      "error-style-selector",
    ],
    [
      "one inside @media",
      "@media print { li { color: red } }",
      "the rule li doesn't start",
      "error-style-selector",
    ],
  ])("fails on %s", (_, css, message, id) => {
    expect(fails(`<style>${css}</style>`)).toContain(message);
    expect(fails(`<style>${css}</style>`)).toMatch(new RegExp(`\\[${id}\\]$`));
  });
});