The frame and the head
What fails a frame, each with the line to add or remove, and the metadata the head reads. Then what Pagez writes in a page's head, which is the page's lang, title and description in place of the frame's, and the tags that follow.
import { mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, test } from "vite-plus/test";
import { FileError } from "../src/elements/errors.ts";
import {
addToHead,
headFields,
MINIMAL,
pageHead,
readFrame,
SLOT,
type Metadata,
} from "../src/head.ts";
const frame = `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>My Site</title>
<meta name="description" content="The site's own." />
</head>
<body>
<main><slot></slot></main>
</body>
</html>
`;
function read(html?: string) {
const dir = mkdtempSync(join(tmpdir(), "pagez-"));
if (html !== undefined) writeFileSync(join(dir, "frame.html"), html);
try {
return readFrame(html === undefined ? undefined : join(dir, "frame.html"));
} catch (error) {
if (error instanceof FileError) return error;
throw error;
}
}
const html = (frame?: string) => (read(frame) as { html: string }).html;
describe("readFrame", () => {
test("is the frame beside the page, with a marker where the page goes", () => {
const result = read(frame);
expect(result).not.toBeInstanceOf(Error);
expect((result as { file: string }).file).toMatch(/frame\.html$/);
expect(html(frame)).toContain(`<main>${SLOT}</main>`);
expect(html(frame)).not.toContain("<slot>");
});
test("is the minimal document when there's no frame", () => {
const result = read();
expect(result).toEqual({ html: MINIMAL.replace("<slot></slot>", SLOT) });
});
test("takes a slot written with its end tag, or with a slash", () => {
for (const slot of ["<slot></slot>", "<slot> </slot>", "<slot />"]) {
expect(html(frame.replace("<slot></slot>", slot))).toContain(`<main>${SLOT}</main>`);
}
});
test("needs no title or description of its own", () => {
const bare = frame.replace(/ *<title>.*\n/, "").replace(/ *<meta name="description".*\n/, "");
expect(read(bare)).not.toBeInstanceOf(Error);
});
test.each([
[
"no lang",
frame.replace(' lang="en"', ""),
/^<html> has no lang\. Add it, such as <html lang="en">/,
"error-frame-lang",
],
[
"an empty lang",
frame.replace('lang="en"', 'lang=""'),
/^<html> has no lang/,
"error-frame-lang",
],
[
"no charset",
frame.replace(' <meta charset="utf-8" />\n', ""),
/^there's no charset\. Add <meta charset="utf-8" \/>/,
"error-frame-charset",
],
[
"no viewport",
frame.replace(/ *<meta name="viewport".*\n/, ""),
/^there's no viewport\. Add <meta name="viewport" content="width=device-width, initial-scale=1" \/>/,
"error-frame-viewport",
],
[
"no slot",
frame.replace("<slot></slot>", ""),
/^there's no <slot><\/slot>\./,
"error-frame-no-slot",
],
[
"two slots",
frame.replace("</main>", "</main><slot></slot>"),
/^line 10: there are 2 slots/,
"error-frame-two-slots",
],
[
"a canonical link",
frame.replace("</head>", '<link rel="canonical" href="/" /></head>'),
/^line 8: this writes a canonical link, but Pagez writes it, from the page's url\. Remove <link rel="canonical" href="\/" \/>$/,
"error-frame-head-tag",
],
[
"an Open Graph tag",
frame.replace("</head>", '<meta property="og:title" content="x" /></head>'),
/^line 8: this writes an Open Graph tag.*Remove <meta property="og:title" content="x" \/>$/,
"error-frame-head-tag",
],
[
"a Twitter tag",
frame.replace("</head>", '<meta name="twitter:card" content="summary" /></head>'),
/^line 8: this writes a Twitter tag/,
"error-frame-head-tag",
],
])("fails on %s, naming frame.html", (_, source, message, id) => {
const error = read(source);
expect(error).toBeInstanceOf(FileError);
expect((error as FileError).file).toMatch(/frame\.html$/);
expect((error as FileError).message).toMatch(message);
expect((error as FileError).id).toBe(id);
});
test("keeps a social tag Pagez doesn't write, such as the site's Twitter handle", () => {
const source = frame.replace(
"</head>",
'<meta name="twitter:site" content="@x" /><meta property="og:locale" content="en_IN" /></head>',
);
expect(read(source)).not.toBeInstanceOf(Error);
});
test("sees a tag in a comment or a script as text", () => {
const source = frame.replace(
"</body>",
'<!-- <link rel="canonical" href="/"> --><script>"<meta property=og:x>"</script></body>',
);
expect(read(source)).not.toBeInstanceOf(Error);
});
});
describe("headFields", () => {
const fields = (page: Metadata) => {
try {
return headFields("page.md", page);
} catch (error) {
if (error instanceof FileError) return error;
throw error;
}
};
test("reads the keys the head needs, and passes every other key by", () => {
expect(fields({ title: "A", description: "B", lang: "fr", date: 2026, tags: ["x"] })).toEqual({
title: "A",
description: "B",
lang: "fr",
});
});
test("resolves a relative image against url, and keeps a full one", () => {
const url = "https://example.com/essays/fish/";
expect(fields({ url, image: "cover.svg" })).toMatchObject({
image: "https://example.com/essays/fish/cover.svg",
});
expect(fields({ url, image: "../art/c.png" })).toMatchObject({
image: "https://example.com/essays/art/c.png",
});
expect(fields({ image: "https://cdn.example.com/c.png" })).toMatchObject({
image: "https://cdn.example.com/c.png",
});
});
test.each([
[{ title: 3 }, /^title is 3, and it must be text/, "error-head-text"],
[{ description: " " }, /^description is " "/, "error-head-text"],
[{ lang: null }, /^lang is null/, "error-head-text"],
[
{ url: "/essays/" },
/^url is \/essays\/, and it must be the page's full address/,
"error-head-url",
],
[
{ image: "cover.svg" },
/^image is cover\.svg, and Open Graph needs a full address/,
"error-image-no-url",
],
])("fails on %j", (page, message, id) => {
expect(fields(page)).toMatchObject({ message: expect.stringMatching(message), id });
});
});
describe("pageHead", () => {
const framed = html(frame);
const minimal = html();
const lines = (out: string) =>
out
.slice(out.indexOf("<head>") + 6, out.indexOf("</head>"))
.trim()
.split("\n")
.map((line) => line.trim());
test("puts the page's lang, title and description in place of the frame's", () => {
const out = pageHead(
framed,
{ lang: "fr", title: "About & me", description: 'Who "I" am' },
"x",
);
expect(out).toContain('<html lang="fr">');
expect(out).toContain("<title>About & me</title>");
expect(out).toContain('<meta name="description" content="Who "I" am" />');
expect(out).not.toContain("My Site");
});
test("keeps the frame's own for a page that sets neither, and says the same in the social tags", () => {
const out = pageHead(framed, {}, "First heading");
expect(out).toContain("<title>My Site</title>");
expect(out).toContain('<meta property="og:title" content="My Site" />');
expect(out).toContain('<meta property="og:description" content="The site\'s own." />');
});
test("writes every field into the minimal document, in order", () => {
const out = pageHead(
minimal,
{
title: "Fish & Chips <tonight>",
description: "Every field.",
url: "https://example.com/essays/fish/",
image: "https://example.com/essays/fish/cover.svg",
},
"x",
);
expect(lines(out)).toEqual([
'<meta charset="utf-8" />',
'<meta name="viewport" content="width=device-width, initial-scale=1" />',
'<meta name="color-scheme" content="light dark" />',
"<title>Fish & Chips <tonight></title>",
'<meta name="description" content="Every field." />',
'<link rel="canonical" href="https://example.com/essays/fish/" />',
'<meta property="og:title" content="Fish & Chips <tonight>" />',
'<meta property="og:description" content="Every field." />',
'<meta property="og:url" content="https://example.com/essays/fish/" />',
'<meta property="og:image" content="https://example.com/essays/fish/cover.svg" />',
'<meta property="og:type" content="website" />',
'<meta name="twitter:card" content="summary_large_image" />',
]);
});
test("titles a page with no title by its fallback, and leaves out what needs a url", () => {
expect(lines(pageHead(minimal, {}, "Untitled")).slice(3)).toEqual([
"<title>Untitled</title>",
'<meta property="og:title" content="Untitled" />',
'<meta property="og:type" content="website" />',
'<meta name="twitter:card" content="summary" />',
]);
});
test("fills a frame's empty title with the fallback", () => {
const out = pageHead(html(frame.replace("My Site", "")), {}, "Untitled");
expect(out).toContain("<title>Untitled</title>");
});
});
describe("addToHead", () => {
test("puts the lines before </head>, indented one step more", () => {
expect(addToHead("<html>\n <head>\n </head>\n</html>", "<a>\n<b>")).toBe(
"<html>\n <head>\n <a>\n <b>\n </head>\n</html>",
);
});
test("puts them after the last head tag when a frame has no </head>", () => {
const bare = '<html lang="en"><meta charset="utf-8"><title>T</title><p>Hi</p>';
expect(addToHead(bare, "<b>")).toBe(
'<html lang="en"><meta charset="utf-8"><title>T</title>\n<b><p>Hi</p>',
);
});
});