Pages, and the elements in code/
Which files are pages, and at which URL (rule 1), and which files in code/ are elements. Every
.md in text/ is a page, and nothing in code/ is. A page's path, less the extension, is its
URL, and index is its folder's own. A file or folder whose name starts with . or _ is
skipped in every folder, since a site's tools leave the first and the author marks the second.
An element's file is @name.html, found by its name
(sitez.md). Sitez looks it up by name, so a misspelling
would silently do nothing, and readElements fails on one instead.
import { readdirSync, statSync } from "node:fs";
import { basename, extname, join, relative, sep } from "node:path";
import { SiteError } from "./errors.ts";
export const SITE_FILE = "site.md";
/** `text/site.md`, found by page discovery. */
export function reserved(file: string): SiteError {
return new SiteError(
file,
`${SITE_FILE} is reserved for the site's metadata, next to text/, so it can't be a page. Rename it, or move its metadata into ../${SITE_FILE}.`,
);
}
export interface Page {
url: string;
/** Absolute path to the page's file. */
file: string;
}Finds every page, sorted by URL. It fails on text/site.md, which would otherwise be a page. It
fails on two files for one URL, naming both (text/blog.md and text/blog/index.md).
It also fails on any file in text/ that isn't .md. text/ holds what is read as it is, and
an image or a PDF there would otherwise be silently left out of the site. Its place is
public/, or data/ for JSON.
export function discover(root: string): Page[] {
const pages: Page[] = [];
const text = join(root, "text");
for (const file of files(text)) {
if (extname(file) !== ".md") {
const folder = extname(file) === ".json" ? "data" : "public";
throw new SiteError(
file,
`text/ holds only Markz (.md), so this file wouldn't reach the site. Move it to ${folder}/${posix(text, file)}, and ${folder === "data" ? "read it from an element with data" : "link to it from there"}.`,
);
}
if (file === join(text, SITE_FILE)) throw reserved(file);
pages.push({ url: urlOf(text, file), file });
}
const byUrl = new Map<string, Page>();
for (const page of pages) {
const other = byUrl.get(page.url);
if (other) {
throw new SiteError(
page.file,
`${page.url} also comes from ${relative(root, other.file)}. A URL has one file: rename or remove one of them.`,
);
}
byUrl.set(page.url, page);
}
return pages.toSorted((a, b) => (a.url < b.url ? -1 : 1));
}
/** Every element's file in `code/`, by tag. Elements are global, wherever their files sit. */
export type Elements = Map<string, string>;
// An element's tag has a hyphen, as Markz's elements do, and its file is named for it.
const TAG = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)+$/;
// Names HTML keeps for its own elements, which `customElements.define` refuses.
const RESERVED = new Set([
"annotation-xml",
"color-profile",
"font-face",
"font-face-src",
"font-face-uri",
"font-face-format",
"font-face-name",
"missing-glyph",
]);The elements in code/, found by their names (sitez.md),
and every mistake in a name that would leave a file doing nothing. An @ file is an element's,
and an element is one file, @name.html. So an @ file with no hyphen, a reserved name, any
other suffix, or a name some other file has already is a mistake, and the message says what to
do. The suffixes of Sitez 0.3 (.html.js, .browser.js) and 0.2 (.live.js, +layout.js,
+style.css) say where their code goes now. Elements are global, since one script holds every
behavior and one stylesheet styles every tag, so a name has one file wherever it sits.
export function readElements(root: string): Elements {
const elements: Elements = new Map();
const code = join(root, "code");
const shown = (file: string) => relative(root, file);
for (const file of files(code)) {
const name = basename(file);
if (/^\+layout\.[jt]s$/.test(name)) {
throw new SiteError(
file,
`Sitez no longer reads a layout. The page's frame is code/index.html, which is complete HTML with <slot></slot> where the page goes. Move this file's markup there, and its head into <head>.`,
);
}
if (name === "+style.css") {
throw new SiteError(
file,
`Sitez no longer reads +style.css. Rename it style.css, and link it from code/index.html: <link rel="stylesheet" href="./style.css" />`,
);
}
if (!name.startsWith("@")) continue;
const dot = name.indexOf(".");
const tag = dot === -1 ? name.slice(1) : name.slice(1, dot);
const suffix = dot === -1 ? "" : name.slice(dot + 1);
if (!TAG.test(tag)) {
throw new SiteError(
file,
`an @ file is an element's, named for its tag, which has a hyphen and is lowercase, as in @call-out.html. Rename it, or drop the @ if it's a module.`,
);
}
if (RESERVED.has(tag)) {
throw new SiteError(
file,
`<${tag}> is a name HTML keeps for its own elements. Rename the element.`,
);
}
if (suffix !== "html") throw new SiteError(file, unnamed(tag, suffix));
const other = elements.get(tag);
if (other) {
const [first, second] = [other, file].sort() as [string, string];
throw new SiteError(
second,
`<${tag}> is also in ${shown(first)}. Elements are global, so a name has one file wherever it sits. Remove one, or rename the element.`,
);
}
elements.set(tag, file);
}
return elements;
}
/** What an @ file should have been, by what Sitez 0.2 or 0.3 or a guess would have written. */
function unnamed(tag: string, suffix: string): string {
const one = `An element is one file, @${tag}.html`;
if (/^html\.[jt]s$/.test(suffix)) {
return `${one}, so this goes into its <template>. Its markup becomes the template, and its code the template's <script>, which exports what the markup uses.`;
}
if (/^(browser|live)\.[jt]s$/.test(suffix)) {
return `${one}, so this becomes its top-level <script>, which default-exports the setup: export default (el, { signal }) => { … }. Move connectedCallback's code into it.`;
}
if (suffix === "css") return `${one}, so this becomes its top-level <style>.`;
return `${one}. Rename it, or drop the @ if it's a module.`;
}The page at /404/, text/404.md, is what a host serves for a URL that doesn't exist. Hosts
look for it at 404.html (rule 1). Every other page is its folder's index.html, so its URL
needs no extension.
export const NOT_FOUND = "/404/";
export function outputFile(url: string): string {
return url === NOT_FOUND ? "404.html" : join(...url.split("/").filter(Boolean), "index.html");
}
/** A file's URL from its path below `folder` (`text/`). */
export function urlOf(folder: string, file: string): string {
const segments = relative(folder, file).slice(0, -extname(file).length).split(sep);
if (segments.at(-1) === "index") segments.pop();
return segments.length === 0 ? "/" : `/${segments.join("/")}/`;
}
/** A file's path below `folder`, with `/` between its parts on every system. */
export function posix(folder: string, file: string): string {
return relative(folder, file).split(sep).join("/");
}
/** Whether a path, from `folder` down, goes through a name starting with `_`, which Sitez skips. */
export function skipped(folder: string, file: string): boolean {
return relative(folder, file)
.split(sep)
.some((part) => part.startsWith("_"));
}
/** Every file below `folder`, sorted, leaving out what `skipped` does and anything under a `.`. */
function files(folder: string): string[] {
if (!statSync(folder, { throwIfNoEntry: false })?.isDirectory()) return [];
return readdirSync(folder, { recursive: true, withFileTypes: true })
.filter((entry) => entry.isFile())
.map((entry) => join(entry.parentPath, entry.name))
.filter(
(file) =>
!skipped(folder, file) &&
!relative(folder, file)
.split(sep)
.some((part) => part.startsWith(".")),
)
.sort();
}