pagez

An element file

@name.html, read into its parts: a <template> the build writes, with an optional <script> first in it, a top-level <style> that ships, and a top-level <script> that runs in the browser (elements.md). Where a part sits says when it runs.

The file is read as HTML is, by its tags, and each part keeps its offsets in the file. So each part becomes a module of its own whose lines are the file's lines (templateModule, setupModule), and an error in one names the line the author wrote. Every check that needs only the file runs here, and fails naming the file and the line (elements.md).

import { readFileSync } from "node:fs";
import { basename } from "node:path";
import { parseAst } from "vite-plus";
import { FileError, type ErrorId } from "./errors.ts";
import { attribute, scan, type Tag } from "./markup.ts";
import { checkStyle } from "./style.ts";

/** Where a part's text sits in the file, from just after its start tag to its end tag. */
export interface Part {
  start: number;
  end: number;
  text: string;
}

export interface ElementFile {
  /** The element's tag, from the file's name. */
  name: string;
  /** Absolute path to `@name.html`. */
  file: string;
  source: string;
  /** What the build writes, with the template's own `<script>` apart from its markup. */
  template?: { markup: Part; script?: Part };
  style?: Part;
  /** The `setup` the browser runs. */
  script?: Part;
}

/** The names every template has in scope, besides the host's. */
export const OWN_SCOPE: string[] = ["attrs", "html"];

/** The tag an element file is named for: `@call-out.html` is `call-out`. */
export const nameOf = (file: string): string => basename(file).slice(1, -".html".length);

Reads file and checks it.

scope is what the host adds to the template's scope, such as page. A name the template's script declares or imports by one of those fails, since the template would never see it. name is the file's, unless a host reads an element defined somewhere else, such as in a page. Its source is then that file with all but the definition blanked.

export function readElement(
  file: string,
  scope: string[],
  source: string = readFileSync(file, "utf8"),
  name: string = nameOf(file),
): ElementFile {
  const element: ElementFile = { name, file, source };
  const fail: Fail = (id, at, message) =>
    new FileError(id, file, `line ${lineOf(source, at)}: ${message}`);
  const tags = scan(source);
  const seen = new Set<string>();
  let at = 0;
  for (let i = 0; i < tags.length; i++) {
    const tag = tags[i]!;
    outside(source, at, tag.start, fail);
    if (tag.kind === "end") {
      throw fail(
        "error-element-file-stray-end",
        tag.start,
        `</${tag.name}> closes nothing. Remove it.`,
      );
    }
    const kind = tag.name as "template" | "style" | "script";
    if (kind !== "template" && kind !== "style" && kind !== "script") {
      throw fail(
        "error-element-file-outside",
        tag.start,
        `<${tag.name}> is outside the <template>. An element file holds a <template>, a <style> and a <script>, so markup goes inside <template>…</template>.`,
      );
    }
    if (seen.has(kind)) {
      throw fail(
        "error-element-file-second-part",
        tag.start,
        `this is a second <${kind}>. An element file holds one of each.`,
      );
    }
    seen.add(kind);
    const close = closing(tags, i);
    if (close === -1)
      throw fail(
        "error-element-file-unclosed",
        tag.start,
        `<${kind}> is never closed. Write </${kind}>.`,
      );
    const part = { start: tag.end, end: tags[close]!.start, text: "" };
    part.text = source.slice(part.start, part.end);
    if (kind === "template") {
      const read = template(element, tag, tags.slice(i + 1, close), part, fail);
      // An empty template writes nothing, so its element is written as the page wrote it.
      if (read.script || /\S/.test(read.markup.text)) element.template = read;
    } else {
      if (kind === "script") scriptEnds(source, part, tags[close]!, fail);
      element[kind] = part;
    }
    i = close;
    at = tags[close]!.end;
  }
  outside(source, at, source.length, fail);
  if (element.template?.script) checkBuildScript(element, scope, fail);
  if (element.script) checkSetup(element, fail);
  if (element.style) checkStyle(element.style, name, fail);
  return element;
}

/** The index of the end tag that closes `tags[i]`, counting templates inside a template. */
function closing(tags: Tag[], i: number): number {
  const { name } = tags[i]!;
  let depth = 0;
  for (let j = i + 1; j < tags.length; j++) {
    const tag = tags[j]!;
    if (tag.name !== name) continue;
    if (tag.kind === "start") depth++;
    else if (depth-- === 0) return j;
  }
  return -1;
}

/** Fails on anything but space and comments between the file's parts. */
function outside(source: string, from: number, to: number, fail: Fail): void {
  const text = source.slice(from, to).replace(/<!--[\s\S]*?-->/g, (c) => " ".repeat(c.length));
  const found = /\S/.exec(text);
  if (!found) return;
  throw fail(
    "error-element-file-outside",
    from + found.index,
    `this text is outside the <template>. An element file holds a <template>, a <style> and a <script>, so markup goes inside <template>…</template>.`,
  );
}

The template's markup, and its <script> if it has one. The script comes first, since it gives the markup its names, and a template has at most one. A <script> later in the markup would be read as the page's own, which is easy to mistake for the build's.

function template(
  element: ElementFile,
  open: Tag,
  inside: Tag[],
  part: Part,
  fail: Fail,
): { markup: Part; script?: Part } {
  const { source, name } = element;
  const named = attribute(open, "element")?.value;
  if (named !== undefined && named !== name) {
    throw fail(
      "error-name-mismatch",
      open.start,
      `<template element="${named}"> is in @${name}.html. The file's name is the element's, so write element="${name}" or leave it out.`,
    );
  }
  const shadow = [open, ...inside].find(
    (tag) => tag.kind === "start" && attribute(tag, "shadowrootmode"),
  );
  if (shadow) {
    throw fail(
      "error-template-shadow-root",
      shadow.start,
      `shadowrootmode makes a shadow root, and elements have none. Write the markup in the light DOM, with <slot></slot> where the content goes.`,
    );
  }
  for (const slot of inside.filter((tag) => tag.kind === "start" && tag.name === "slot")) {
    if (attribute(slot, "name")) throw fail("error-slot-named", slot.start, namedSlot);
  }
  const scripts = inside.filter((tag) => tag.kind === "start" && tag.name === "script");
  const [first, second] = scripts;
  if (second) {
    throw fail(
      "error-template-two-scripts",
      second.start,
      `this is a second <script> in the <template>. A template has one, first, whose names the markup uses.`,
    );
  }
  if (!first) return { markup: part };
  if (/\S/.test(source.slice(part.start, first.start).replace(/<!--[\s\S]*?-->/g, ""))) {
    throw fail(
      "error-template-script-after",
      first.start,
      `the template's <script> comes after its markup. Move it to the top of the <template>, since it gives the markup its names.`,
    );
  }
  const close = inside[inside.indexOf(first) + 1]!;
  const script = { start: first.end, end: close.start, text: source.slice(first.end, close.start) };
  scriptEnds(source, script, close, fail);
  return {
    script,
    markup: { start: close.end, end: part.end, text: source.slice(close.end, part.end) },
  };
}

/** A mistake at an offset in the file, with its id. */
export type Fail = (id: ErrorId, at: number, message: string) => FileError;

export const namedSlot =
  "an element has one slot, with no name, where its content goes. Remove the name.";

</script> in a script's string or comment ends the script, as it does in any HTML page, and the rest of the code would be read as markup. Only a script that doesn't parse is checked, so a regular expression with a quote in it is never taken for a string.

function scriptEnds(source: string, script: Part, close: Tag, fail: Fail): void {
  try {
    parseAst(script.text, { lang: "js" });
    return;
  } catch {}
  const after = source.slice(close.end, source.indexOf("\n", close.end) + 1 || source.length);
  if (!inside(script.text, /\S/.test(after))) return;
  throw fail(
    "error-script-end-in-code",
    close.start,
    "this </script> is inside the script's code, and ends the script there, as in any HTML page. Write it <\\/script>.",
  );
}

/** Whether `code` ends inside a string, a template literal or a comment. */
function inside(code: string, lineGoesOn: boolean): boolean {
  const braces: string[] = [];
  let mode = "code";
  for (let i = 0; i < code.length; i++) {
    const c = code[i]!;
    const next = code[i + 1];
    if (mode === "code") {
      if (c === "'" || c === '"' || c === "`") mode = c;
      else if (c === "/" && next === "/") [mode, i] = ["//", i + 1];
      else if (c === "/" && next === "*") [mode, i] = ["/*", i + 1];
      else if (c === "{") braces.push("{");
      else if (c === "}" && braces.pop() === "${") mode = "`";
    } else if (mode === "'" || mode === '"') {
      if (c === "\\") i++;
      else if (c === mode || c === "\n") mode = "code";
    } else if (mode === "`") {
      if (c === "\\") i++;
      else if (c === "`") mode = "code";
      else if (c === "$" && next === "{") {
        braces.push("${");
        [mode, i] = ["code", i + 1];
      }
    } else if (mode === "//") {
      if (c === "\n") mode = "code";
    } else if (c === "*" && next === "/") [mode, i] = ["code", i + 1];
  }
  if (mode === "//") return lineGoesOn;
  return mode !== "code" || braces.includes("${");
}

/** The parsed module, or nothing when it doesn't parse: Vite shows that error with its lines. */
function parsed(code: string) {
  try {
    return parseAst(code, { lang: "js" }).body;
  } catch {
    return undefined;
  }
}

The template's script is the top of the template's module, so whatever it declares or imports is in the markup's scope, with no export.

A name the template already has would hide it, or be hidden, so it fails, and html most of all, since the script has it with no import. A default export fails too, since the template is the module's default.

function checkBuildScript(element: ElementFile, scope: string[], fail: Fail): void {
  const names = [...OWN_SCOPE, ...scope];
  for (const statement of parsed(setupModule(element, element.template!.script!)) ?? []) {
    if (statement.type === "ExportDefaultDeclaration") {
      throw fail(
        "error-template-default-export",
        statement.start,
        "the template's script has a default export, and the template is the module's default. Name it, as in const chart = …, and the markup has it.",
      );
    }
    const imported = statement.type === "ImportDeclaration";
    const clash = topLevel(statement).find((name) => names.includes(name));
    if (clash === "html") {
      throw fail(
        "error-template-own-name",
        statement.start,
        `this ${imported ? "imports" : "declares"} html, which the template's script already has. Remove it.`,
      );
    }
    if (clash) {
      throw fail(
        "error-template-own-name",
        statement.start,
        `this ${imported ? "imports" : "declares"} ${clash}, which the template already has (${names.join(", ")}). Rename it.`,
      );
    }
  }
}

type Statement = NonNullable<ReturnType<typeof parsed>>[number];

/** The names a top-level statement binds in its module. */
function topLevel(statement: Statement): string[] {
  if (statement.type === "ImportDeclaration") {
    return statement.specifiers.map(({ local }) => local.name);
  }
  const declaration =
    statement.type === "ExportNamedDeclaration" ? statement.declaration : statement;
  return declaration ? declared(declaration as { id?: unknown; declarations?: unknown }) : [];
}

The browser's script default-exports setup(el, { signal }). A custom element is usually a class, so an exported class fails, saying where its code goes.

function checkSetup(element: ElementFile, fail: Fail): void {
  const body = parsed(setupModule(element));
  if (!body) return;
  const setup = "export default (el, { signal }) => { … }";
  const found = body.find((statement) => statement.type === "ExportDefaultDeclaration");
  if (!found) {
    throw fail(
      "error-setup-no-default",
      element.script!.start,
      `the <script> has no default export. It exports the element's setup, which the browser runs on each element: ${setup}.`,
    );
  }
  const { type } = found.declaration;
  if (type === "ClassDeclaration" || type === "ClassExpression") {
    throw fail(
      "error-setup-class",
      found.start,
      `the <script> exports a class. It exports the element's setup instead, which the browser runs on each element: ${setup}. Move connectedCallback's code into it, and pass signal to each listener.`,
    );
  }
}

/** The names a declaration binds: a function's or a class's, or each variable's. */
function declared(declaration: { id?: unknown; declarations?: unknown }): string[] {
  const ids = Array.isArray(declaration.declarations)
    ? declaration.declarations.map((d: { id: unknown }) => d.id)
    : [declaration.id];
  return ids.flatMap(bound);
}

/** The names a binding pattern holds, such as `{ a, b: [c] }`. */
function bound(pattern: unknown): string[] {
  const node = pattern as { type?: string; [key: string]: unknown } | null;
  switch (node?.type) {
    case "Identifier":
      return [node.name as string];
    case "ObjectPattern":
      return (node.properties as { type: string; value?: unknown; argument?: unknown }[]).flatMap(
        (property) => bound(property.type === "RestElement" ? property.argument : property.value),
      );
    case "ArrayPattern":
      return (node.elements as unknown[]).flatMap(bound);
    case "AssignmentPattern":
      return bound(node.left);
    case "RestElement":
      return bound(node.argument);
    default:
      return [];
  }
}

#The parts as modules

Each part becomes the whole file with everything but that part blanked, so its lines and columns are the file's.

The template is the script's module with one more export, the default, which is the markup as an html literal. Its parameters are the template's scope, and the script's names are its module's, so the markup has both. html is imported first, on the file's first line, so the script has it too and no line moves. from is where it's imported from, which the host resolves to its own.

export function templateModule(
  element: ElementFile,
  scope: string[],
  from: string = "@amitkaps/pagez",
): string {
  const { source } = element;
  const { markup, script } = element.template!;
  const names = ["attrs", ...scope].join(", ");
  const head = script
    ? setupModule(element, script).slice(0, markup.start)
    : blank(source.slice(0, markup.start));
  const html = `import { html } from ${JSON.stringify(from)};`;
  return `${html}${head}export default ({ ${names} }) => html\`${markup.text}\`;${blank(source.slice(markup.end))}`;
}

/** The browser's script as a module, or the template's script when it's given. */
export function setupModule(element: ElementFile, script: Part = element.script!): string {
  const { source } = element;
  return blank(source.slice(0, script.start)) + script.text + blank(source.slice(script.end));
}

/** Text with everything but its line breaks blanked, so what follows keeps its line and column. */
const blank = (text: string) => text.replace(/[^\n]/g, " ");

/** The line `at` is on, from 1. */
export function lineOf(source: string, at: number): number {
  let line = 1;
  for (let i = source.indexOf("\n"); i !== -1 && i < at; i = source.indexOf("\n", i + 1)) line++;
  return line;
}