What a page's elements ship
The elements a page uses that send something to the browser: CSS from a top-level <style>,
or behavior from a top-level <script>. The page's finished HTML is scanned for their tags, so
an element in the frame or written by another element counts, and an element the page doesn't
use ships nothing. A page with no behavior loads no JavaScript.
- Their CSS is each element's once, unlayered, in a
<style>after Pagez's theme and before the page'sstyle.css(Element CSS). A path in it is the element's, from its own folder, so it's written from the page's. - Their behavior is one module, which Vite bundles: the elements' runtime, then each
element's
setup, defined under its tag. The tag comes from the file's name or the page's definition, so the script never names its element.
What a script imports is checked before Vite sees it, as an image is, so a missing file or package fails at the import's line.
import { isBuiltin, findPackageJSON } from "node:module";
import { dirname, relative } from "node:path";
import { pathToFileURL } from "node:url";
import { parseAst } from "vite-plus";
import { FileError } from "./elements/errors.ts";
import { lineOf, setupModule, type ElementFile } from "./elements/file.ts";
import { scan } from "./elements/markup.ts";
import { browserImport, PACKAGE, partId, RUNTIME } from "./parts.ts";
import { checkCss, exists, isRelative, rebased } from "./references.ts";
/** The elements `html` uses that ship CSS or behavior, sorted by tag. */
export function usedElements(elements: Map<string, ElementFile>, html: string): ElementFile[] {
const tags = new Set(scan(html).flatMap((tag) => (tag.kind === "start" ? [tag.name] : [])));
return [...tags].sort().flatMap((tag) => {
const element = elements.get(tag);
return element && (element.style || element.script) ? [element] : [];
});
}
/** The CSS of the elements used, each once, for a page in `dir`. */
export function elementCss(used: ElementFile[], dir: string): string | undefined {
const styled = used.filter((element) => element.style);
if (styled.length === 0) return undefined;
const rules = styled.map((element) => {
const from = dirname(element.file);
const css = dedent(element.style!.text);
checkCss(element.file, from, css);
return rebased(css, from, dir);
});
return rules.join("\n\n");
}
/** The page's one module, which defines each element with behavior, for a page in `dir`. */
export function pageScript(used: ElementFile[], dir: string): string | undefined {
const setups = used.filter((element) => element.script);
if (setups.length === 0) return undefined;
for (const element of used) checkImports(element, "setup");
const names = setups.map(({ name }) => name.replace(/-(\w)/g, (_, c: string) => c.toUpperCase()));
return [
`import { define } from "${RUNTIME}";`,
...setups.map(
(element, i) => `import ${names[i]} from "./${relative(dir, partId(element, "setup"))}";`,
),
...setups.map(({ name }, i) => `define("${name}", ${names[i]});`),
].join("\n");
}Each import of one of an element's scripts, checked. A full URL is left as written, for the
browser to fetch. A relative path must be there, and a package must be in a node_modules the
element's folder reaches. The browser's script can't import Pagez, whose html renders at build
time, or Node's own modules, which the browser doesn't have.
export function checkImports(element: ElementFile, part: "template" | "setup"): void {
const script = part === "setup" ? element.script : element.template?.script;
if (!script) return;
let body;
try {
body = parseAst(setupModule(element, script), { lang: "js" }).body;
} catch {
return;
}
const { file, source } = element;
for (const statement of body) {
if (!("source" in statement) || !statement.source) continue;
const specifier = String(statement.source.value);
const at = `line ${lineOf(source, statement.start)}: `;
if (part === "setup" && (specifier === PACKAGE || specifier.startsWith(`${PACKAGE}/`))) {
const error = browserImport(file);
throw new FileError(error.id, file, at + error.message);
}
if (isBuiltin(specifier)) {
if (part === "template") continue;
throw new FileError(
"error-browser-node",
file,
`${at}this imports ${specifier}, which is Node's, and the browser doesn't have it. Read files in the template's script, which runs at build time.`,
);
}
if (URL.canParse(specifier) || specifier === PACKAGE) continue;
if (/^\.{0,2}\//.test(specifier)) {
if (isRelative(specifier) && !exists(dirname(file), specifier)) {
throw new FileError(
"error-relative-missing",
file,
`${at}${specifier} isn't there. Add the file, or fix the path.`,
);
}
continue;
}
try {
findPackageJSON(specifier, pathToFileURL(file));
} catch {
throw new FileError(
"error-package-missing",
file,
`${at}this imports ${specifier}, which isn't in a node_modules beside the page. Install it there, with pnpm add ${packageName(specifier)}.`,
);
}
}
}
/** The package a bare specifier names: `d3` for `d3/scale`, and `@scope/name` for its parts. */
const packageName = (specifier: string) =>
specifier
.split("/")
.slice(0, specifier.startsWith("@") ? 2 : 1)
.join("/");
/** CSS with its common indent and its blank first and last lines taken out. */
function dedent(css: string): string {
const lines = css
.replace(/^\s*\n/, "")
.trimEnd()
.split("\n");
const margin = Math.min(
...lines.filter((line) => line.trim()).map((line) => /^\s*/.exec(line)![0].length),
);
return lines.map((line) => line.slice(margin)).join("\n");
}