feat: metadata generation library
this takes what i love about 'next/meta' (originally imported via my 'next-metadata' port) and consolidates it into a simple 250 line library. instead of supporting all meta tags under the sun, only the most essential ones are exposed. less common meta tags can be added with JSX under the 'extra' field. a common problem i had with next-metadata was that open graph embeds copied a lot of data from the main meta tags. to solve this, a highly opiniated 'embed' option exists, which simply passing '{}' will trigger the default behavior of copying the meta title, description, and canonical url into the open graph meta tags.
This commit is contained in:
parent
3a36e53635
commit
b304e2ac9f
11 changed files with 323 additions and 64 deletions
|
@ -114,6 +114,7 @@ export interface ServerSideOptions {
|
||||||
styleMap: Map<string, incr.Ref<string>>;
|
styleMap: Map<string, incr.Ref<string>>;
|
||||||
scriptMap: incr.Ref<Record<string, string>>;
|
scriptMap: incr.Ref<Record<string, string>>;
|
||||||
platform: ServerPlatform;
|
platform: ServerPlatform;
|
||||||
|
metaTemplate: meta.Template;
|
||||||
}
|
}
|
||||||
export async function bundleServerJavaScript({
|
export async function bundleServerJavaScript({
|
||||||
viewItems,
|
viewItems,
|
||||||
|
@ -122,6 +123,7 @@ export async function bundleServerJavaScript({
|
||||||
scriptMap: wScriptMap,
|
scriptMap: wScriptMap,
|
||||||
entries,
|
entries,
|
||||||
platform,
|
platform,
|
||||||
|
metaTemplate,
|
||||||
}: ServerSideOptions) {
|
}: ServerSideOptions) {
|
||||||
const regenKeys: Record<string, string[]> = {};
|
const regenKeys: Record<string, string[]> = {};
|
||||||
const regenTtls: view.Ttl[] = [];
|
const regenTtls: view.Ttl[] = [];
|
||||||
|
@ -173,6 +175,12 @@ export async function bundleServerJavaScript({
|
||||||
"}",
|
"}",
|
||||||
`export const regenTags = ${JSON.stringify(regenKeys)};`,
|
`export const regenTags = ${JSON.stringify(regenKeys)};`,
|
||||||
`export const regenTtls = ${JSON.stringify(regenTtls)};`,
|
`export const regenTtls = ${JSON.stringify(regenTtls)};`,
|
||||||
|
`export const metaTemplate = {`,
|
||||||
|
` base: new URL(${JSON.stringify(metaTemplate.base)}),`,
|
||||||
|
...Object.entries(metaTemplate)
|
||||||
|
.filter(([k]) => k !== 'base')
|
||||||
|
.map(([k, v]) => ` ${JSON.stringify(k)}: ${JSON.stringify(v)},`),
|
||||||
|
`};`,
|
||||||
].join("\n"),
|
].join("\n"),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
@ -371,3 +379,4 @@ import * as incr from "./incremental.ts";
|
||||||
import * as sg from "#sitegen";
|
import * as sg from "#sitegen";
|
||||||
import type { PageOrView } from "./generate.ts";
|
import type { PageOrView } from "./generate.ts";
|
||||||
import type * as view from "#sitegen/view";
|
import type * as view from "#sitegen/view";
|
||||||
|
import * as meta from "#sitegen/meta";
|
||||||
|
|
|
@ -268,7 +268,7 @@ export function stringifyStyleAttribute(style: Record<string, string>) {
|
||||||
return "style=" + quoteIfNeeded(out);
|
return "style=" + quoteIfNeeded(out);
|
||||||
}
|
}
|
||||||
export function quoteIfNeeded(text: string) {
|
export function quoteIfNeeded(text: string) {
|
||||||
if (text.match(/["/>]/)) return '"' + text + '"';
|
if (text.match(/["/> ]/)) return '"' + text + '"';
|
||||||
return text;
|
return text;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -17,7 +17,6 @@ export async function buildFonts(fonts: sg.Font[]) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const instances = new async.OnceMap<void>();
|
const instances = new async.OnceMap<void>();
|
||||||
const subsets = new async.OnceMap<void>();
|
|
||||||
async function makeInstance(input: string, vars: sg.FontVars) {
|
async function makeInstance(input: string, vars: sg.FontVars) {
|
||||||
const args = Object.entries(vars).map((x) => {
|
const args = Object.entries(vars).map((x) => {
|
||||||
const lower = x[0].toLowerCase();
|
const lower = x[0].toLowerCase();
|
||||||
|
@ -89,18 +88,23 @@ export async function fetchFont(name: string, sources: string[]) {
|
||||||
if (fs.existsSync(cacheName)) return cacheName;
|
if (fs.existsSync(cacheName)) return cacheName;
|
||||||
|
|
||||||
if (source.startsWith("https://")) {
|
if (source.startsWith("https://")) {
|
||||||
const response = await fetch(source);
|
try {
|
||||||
if (response.ok) {
|
const response = await fetch(source);
|
||||||
await fs.writeMkdir(
|
if (response.ok) {
|
||||||
cacheName,
|
await fs.writeMkdir(
|
||||||
Buffer.from(await response.arrayBuffer()),
|
cacheName,
|
||||||
);
|
Buffer.from(await response.arrayBuffer()),
|
||||||
} else {
|
);
|
||||||
errs.push(
|
} else {
|
||||||
new Error(
|
errs.push(
|
||||||
`Fetching from ${source} failed: ${response.status} ${response.statusText}`,
|
new Error(
|
||||||
),
|
`Fetching from ${source} failed: ${response.status} ${response.statusText}`,
|
||||||
);
|
),
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
errs.push(`Fetching from ${source} failed: ${error.message(err)}`);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -190,3 +194,4 @@ import * as subprocess from "#sitegen/subprocess";
|
||||||
import * as incr from "./incremental.ts";
|
import * as incr from "./incremental.ts";
|
||||||
import * as crypto from "node:crypto";
|
import * as crypto from "node:crypto";
|
||||||
import * as async from "#sitegen/async";
|
import * as async from "#sitegen/async";
|
||||||
|
import * as error from "#sitegen/error";
|
||||||
|
|
|
@ -14,9 +14,16 @@ export async function main() {
|
||||||
export async function generate() {
|
export async function generate() {
|
||||||
// -- read config and discover files --
|
// -- read config and discover files --
|
||||||
const siteConfig = await incr.work(readManifest);
|
const siteConfig = await incr.work(readManifest);
|
||||||
const { staticFiles, scripts, views, pages } = await discoverAllFiles(
|
const metaTemplate: meta.Template = {
|
||||||
siteConfig,
|
...siteConfig.meta,
|
||||||
);
|
base: new URL(siteConfig.meta.base),
|
||||||
|
};
|
||||||
|
const {
|
||||||
|
staticFiles,
|
||||||
|
scripts,
|
||||||
|
views,
|
||||||
|
pages,
|
||||||
|
} = await discoverAllFiles(siteConfig);
|
||||||
|
|
||||||
// TODO: make sure that `static` and `pages` does not overlap
|
// TODO: make sure that `static` and `pages` does not overlap
|
||||||
|
|
||||||
|
@ -26,7 +33,9 @@ export async function generate() {
|
||||||
const builtFonts = fonts.buildFonts(siteConfig.fonts);
|
const builtFonts = fonts.buildFonts(siteConfig.fonts);
|
||||||
|
|
||||||
// -- perform build-time rendering --
|
// -- perform build-time rendering --
|
||||||
const builtPages = pages.map((item) => incr.work(preparePage, item));
|
const builtPages = pages.map((item) =>
|
||||||
|
incr.work(preparePage, { item, metaTemplate })
|
||||||
|
);
|
||||||
const builtViews = views.map((item) => incr.work(prepareView, item));
|
const builtViews = views.map((item) => incr.work(prepareView, item));
|
||||||
const builtStaticFiles = Promise.all(
|
const builtStaticFiles = Promise.all(
|
||||||
staticFiles.map((item) =>
|
staticFiles.map((item) =>
|
||||||
|
@ -66,6 +75,7 @@ export async function generate() {
|
||||||
return { id: type === "page" ? `page:${id}` : id, file };
|
return { id: type === "page" ? `page:${id}` : id, file };
|
||||||
}),
|
}),
|
||||||
viewRefs: viewsAndDynPages,
|
viewRefs: viewsAndDynPages,
|
||||||
|
metaTemplate,
|
||||||
});
|
});
|
||||||
|
|
||||||
// -- assemble page assets --
|
// -- assemble page assets --
|
||||||
|
@ -89,6 +99,10 @@ export async function readManifest(io: Io) {
|
||||||
})),
|
})),
|
||||||
backends: cfg.backends.map(toRel),
|
backends: cfg.backends.map(toRel),
|
||||||
fonts: cfg.fonts,
|
fonts: cfg.fonts,
|
||||||
|
meta: {
|
||||||
|
...cfg.meta,
|
||||||
|
base: cfg.meta.base.toString(),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -194,7 +208,10 @@ export async function scanSiteSection(io: Io, sectionRoot: string) {
|
||||||
return { staticFiles, pages, views, scripts };
|
return { staticFiles, pages, views, scripts };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function preparePage(io: Io, item: sg.FileItem) {
|
export async function preparePage(
|
||||||
|
io: Io,
|
||||||
|
{ item, metaTemplate }: { item: sg.FileItem; metaTemplate: meta.Template },
|
||||||
|
) {
|
||||||
// -- load and validate module --
|
// -- load and validate module --
|
||||||
let {
|
let {
|
||||||
default: Page,
|
default: Page,
|
||||||
|
@ -220,7 +237,10 @@ export async function preparePage(io: Io, item: sg.FileItem) {
|
||||||
// -- metadata --
|
// -- metadata --
|
||||||
const renderedMetaPromise = Promise.resolve(
|
const renderedMetaPromise = Promise.resolve(
|
||||||
typeof metadata === "function" ? metadata({ ssr: true }) : metadata,
|
typeof metadata === "function" ? metadata({ ssr: true }) : metadata,
|
||||||
).then((m) => meta.renderMeta(m));
|
).then((m) => meta.render({
|
||||||
|
canonical: item.id,
|
||||||
|
...m,
|
||||||
|
}, metaTemplate));
|
||||||
|
|
||||||
// -- html --
|
// -- html --
|
||||||
let page = render.element(Page);
|
let page = render.element(Page);
|
||||||
|
@ -322,7 +342,7 @@ export async function assembleAndWritePage(
|
||||||
pathname: id,
|
pathname: id,
|
||||||
buffer,
|
buffer,
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "text/html",
|
"Content-Type": "text/html;charset=utf8",
|
||||||
},
|
},
|
||||||
regenerative: !!regenerate,
|
regenerative: !!regenerate,
|
||||||
});
|
});
|
||||||
|
|
|
@ -277,8 +277,8 @@ type SerializedState = ReturnType<typeof serialize>;
|
||||||
/* No-op on failure */
|
/* No-op on failure */
|
||||||
async function deserialize(buffer: Buffer) {
|
async function deserialize(buffer: Buffer) {
|
||||||
const decoded = msgpackr.decode(buffer) as SerializedState;
|
const decoded = msgpackr.decode(buffer) as SerializedState;
|
||||||
if (!Array.isArray(decoded)) return false;
|
if (!Array.isArray(decoded)) return;
|
||||||
if (decoded[0] !== 1) return false;
|
if (decoded[0] !== 1) return;
|
||||||
const [, fileEntries, workEntries, expectedFilesOnDisk, assetEntries] =
|
const [, fileEntries, workEntries, expectedFilesOnDisk, assetEntries] =
|
||||||
decoded;
|
decoded;
|
||||||
for (const [k, type, content, ...affects] of fileEntries) {
|
for (const [k, type, content, ...affects] of fileEntries) {
|
||||||
|
|
|
@ -1,17 +1,119 @@
|
||||||
|
export { renderMetadata as render };
|
||||||
export interface Meta {
|
export interface Meta {
|
||||||
|
/** Required for all pages. `<title>{content}</title>` */
|
||||||
title: string;
|
title: string;
|
||||||
description?: string | undefined;
|
/** Recommended for all pages. `<meta name="description" content="{...}" />` */
|
||||||
openGraph?: OpenGraph;
|
description?: string | null;
|
||||||
alternates?: Alternates;
|
/** Automatically added for static renders from the 'pages' folders. */
|
||||||
|
canonical?: string | null;
|
||||||
|
/** Add `<link rel="alternate" ... />`. Object keys are interpretted as
|
||||||
|
* mime types if they contain a slash, otherwise seen as an alternative language. */
|
||||||
|
alternates?: Alternate[] | Record<string, string>;
|
||||||
|
|
||||||
|
/** Automatically generate both OpenGraph and Twitter meta tags */
|
||||||
|
embed?: AutoEmbed | null;
|
||||||
|
/** Add a robots tag for `noindex` and `nofollow` */
|
||||||
|
denyRobots?: boolean | null;
|
||||||
|
/** Add 'og:*' meta tags */
|
||||||
|
openGraph?: OpenGraph | null;
|
||||||
|
/** Add 'twitter:*' meta tags */
|
||||||
|
twitter?: Twitter | null;
|
||||||
|
/** * Refer to an oEmbed file. See https://oembed.com
|
||||||
|
* TODO: support passing the oEmbed file directly here. */
|
||||||
|
oEmbed?: string;
|
||||||
|
/**
|
||||||
|
* '#sitegen/meta' intentionally excludes a lot of exotic tags.
|
||||||
|
* Add these manually using JSX syntax:
|
||||||
|
*
|
||||||
|
* extra: [
|
||||||
|
* <meta name="site-verification" content="waffles" />,
|
||||||
|
* ],
|
||||||
|
*/
|
||||||
|
extra?: render.Node;
|
||||||
|
|
||||||
|
/** Adds `<meta name="author" content="{...}" />` */
|
||||||
|
authors?: string[];
|
||||||
|
/** Credit sitegen by setting this to true aka "clover sitegen 3; paperclover.net/sitegen" */
|
||||||
|
generator?: string | true | null;
|
||||||
|
/** Adds `<meta name="keywords" content="{keywords.join(', ')}" />` */
|
||||||
|
keywords?: string[];
|
||||||
|
/** URL to a manifest; https://developer.mozilla.org/en-US/docs/Web/Manifest */
|
||||||
|
manifest?: string | null;
|
||||||
|
/** Adds `<meta name="publisher" content="{...}" />` */
|
||||||
|
publisher?: string | null;
|
||||||
|
/** https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/meta/name/referrer */
|
||||||
|
referrer?: Referrer | null;
|
||||||
|
/** Adds `<meta name="theme-color" content="{...}" />` */
|
||||||
|
themeColor?: string | { dark: string; light: string } | null;
|
||||||
|
/** Defaults to `width=device-width, initial-scale=1.0` for mobile compatibility. */
|
||||||
|
viewport?: string;
|
||||||
}
|
}
|
||||||
export interface Template extends Omit<Meta, "title"> {
|
export type Alternate = { type: string; url: string } | {
|
||||||
titleTemplate?: (title: string) => string,
|
lang: string;
|
||||||
}
|
|
||||||
export interface OpenGraph {
|
|
||||||
title?: string;
|
|
||||||
description?: string | undefined;
|
|
||||||
type: string;
|
|
||||||
url: string;
|
url: string;
|
||||||
|
};
|
||||||
|
export interface AutoEmbed {
|
||||||
|
/* Defaults to the page title. */
|
||||||
|
title?: string | null;
|
||||||
|
/* Defaults to the page description. */
|
||||||
|
description?: string | null;
|
||||||
|
/* Provide to add an embed image. */
|
||||||
|
thumbnail?: string | null;
|
||||||
|
/** @default "banner", which applies twitter:card = "summary_large_image" */
|
||||||
|
thumbnailSize?: "banner" | "icon";
|
||||||
|
/* Ignored if not passed */
|
||||||
|
siteTitle?: string | null;
|
||||||
|
}
|
||||||
|
/** See https://ogp.me for extra rules. */
|
||||||
|
export interface OpenGraph {
|
||||||
|
/** The title of your object as it should appear within the graph */
|
||||||
|
title?: string;
|
||||||
|
/** A one to two sentence description of your object. */
|
||||||
|
description?: string | null;
|
||||||
|
/** The type of your object, e.g., "video.movie". Depending on the type you specify, other properties may also be required */
|
||||||
|
type?: string;
|
||||||
|
/** An image URL which should represent your object within the graph */
|
||||||
|
image?: OpenGraphField;
|
||||||
|
/** The canonical URL of your object that will be used as its permanent ID in the graph, e.g., "https://www.imdb.com/title/tt0117500/" */
|
||||||
|
url?: string;
|
||||||
|
/** A URL to an audio file to accompany this object */
|
||||||
|
audio?: OpenGraphField;
|
||||||
|
/** The word that appears before this object's title in a sentence. An enum of (a, an, the, "", auto). If auto is chosen, the consumer of your data should choose between "a" or "an". Default is "" (blank) */
|
||||||
|
determiner?: string;
|
||||||
|
/** The locale these tags are marked up in. Of the format language_TERRITORY. Default is en_US */
|
||||||
|
locale?: string;
|
||||||
|
/** An array of other locales this page is available in */
|
||||||
|
"locale:alternate"?: string[];
|
||||||
|
/** If your object is part of a larger web site, the name which should be displayed for the overall site. e.g., "IMDb" */
|
||||||
|
site_name?: string;
|
||||||
|
/** A URL to a video file that complements this object */
|
||||||
|
video?: OpenGraphField;
|
||||||
|
[field: string]: OpenGraphField;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* When passing an array, the property is duplicated.
|
||||||
|
* When passing an object, the fields are emitted as namespaced with ':'.
|
||||||
|
*/
|
||||||
|
type OpenGraphField =
|
||||||
|
| string
|
||||||
|
| { [field: string]: OpenGraphField }
|
||||||
|
| Array<OpenGraphField>
|
||||||
|
| (null | undefined);
|
||||||
|
/** Twitter uses various OpenGraph fields if these are not specified. */
|
||||||
|
export interface Twitter {
|
||||||
|
card: string;
|
||||||
|
title?: string;
|
||||||
|
description?: string | null;
|
||||||
|
url?: string;
|
||||||
|
image?: string;
|
||||||
|
player?: string;
|
||||||
|
/** Same logic as Open Graph */
|
||||||
|
[field: string]: OpenGraphField;
|
||||||
|
}
|
||||||
|
export interface Template
|
||||||
|
extends Omit<Meta, "title" | "description" | "canonical"> {
|
||||||
|
base: URL;
|
||||||
|
titleTemplate?: (title: string) => string;
|
||||||
}
|
}
|
||||||
export interface Alternates {
|
export interface Alternates {
|
||||||
canonical: string;
|
canonical: string;
|
||||||
|
@ -21,7 +123,134 @@ export interface AlternateType {
|
||||||
url: string;
|
url: string;
|
||||||
title: string;
|
title: string;
|
||||||
}
|
}
|
||||||
export function renderMeta({ title }: Meta): string {
|
export type Referrer =
|
||||||
return `<title>${esc(title)}</title><link rel="icon" type="image/x-icon" href="/favicon.ico">`;
|
| "no-referrer"
|
||||||
|
| "origin"
|
||||||
|
| "no-referrer-when-downgrade"
|
||||||
|
| "origin-when-cross-origin"
|
||||||
|
| "same-origin"
|
||||||
|
| "strict-origin"
|
||||||
|
| "strict-origin-when-cross-origin";
|
||||||
|
|
||||||
|
/* Convert a metadata definition into text. */
|
||||||
|
function renderMetadata(meta: Meta, template: Template): string {
|
||||||
|
const { titleTemplate, base } = template;
|
||||||
|
const resolve = (str: string) => new URL(str, base).href;
|
||||||
|
|
||||||
|
const title = titleTemplate ? titleTemplate(meta.title) : meta.title;
|
||||||
|
const description = meta.description ?? null;
|
||||||
|
const canonical = meta.canonical ? resolve(meta.canonical) : null;
|
||||||
|
const denyRobots = Boolean(meta.denyRobots || template.denyRobots);
|
||||||
|
const authors = meta.authors ?? template.authors ?? null;
|
||||||
|
let generator = meta.generator ?? template.generator ?? null;
|
||||||
|
const keywords = meta.keywords ?? template.keywords ?? null;
|
||||||
|
const manifest = meta.manifest ?? template.manifest ?? null;
|
||||||
|
const publisher = meta.publisher ?? template.publisher ?? null;
|
||||||
|
const referrer = meta.referrer ?? template.referrer ?? null;
|
||||||
|
const themeColor = meta.themeColor ?? template.themeColor ?? null;
|
||||||
|
const viewport = meta.viewport ?? template.viewport ??
|
||||||
|
"width=device-width, initial-scale=1.0";
|
||||||
|
|
||||||
|
const embed = meta.embed ?? template.embed ?? null;
|
||||||
|
let openGraph = meta.openGraph ?? template.openGraph ?? null;
|
||||||
|
let twitter = meta.twitter ?? template.twitter ?? null;
|
||||||
|
if (embed) {
|
||||||
|
const { thumbnail, thumbnailSize, siteTitle } = embed;
|
||||||
|
openGraph = {
|
||||||
|
type: "website",
|
||||||
|
title: embed.title ?? title,
|
||||||
|
description: embed.description ?? description,
|
||||||
|
...openGraph,
|
||||||
|
};
|
||||||
|
twitter = {
|
||||||
|
card: (thumbnailSize ?? (thumbnail ? "banner" : "icon")) === "banner"
|
||||||
|
? "summary_large_image"
|
||||||
|
: "summary",
|
||||||
|
...twitter,
|
||||||
|
};
|
||||||
|
if (thumbnail) {
|
||||||
|
openGraph.image = embed.thumbnail;
|
||||||
|
}
|
||||||
|
if (siteTitle) {
|
||||||
|
openGraph.site_name = siteTitle;
|
||||||
|
}
|
||||||
|
if (canonical) {
|
||||||
|
openGraph.url = canonical;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let out = `<title>${esc(title)}</title>`;
|
||||||
|
if (description) {
|
||||||
|
out += `<meta name=description content=${attr(description)}>`;
|
||||||
|
}
|
||||||
|
for (const author of authors ?? []) {
|
||||||
|
out += `<meta name=author content=${attr(author)}>`;
|
||||||
|
}
|
||||||
|
if (keywords) {
|
||||||
|
out += `<meta name=keywords content=${attr(keywords.join(", "))}>`;
|
||||||
|
}
|
||||||
|
if (generator) {
|
||||||
|
if (generator === true) {
|
||||||
|
generator = "clover sitegen 3; paperclover.net/sitegen";
|
||||||
|
}
|
||||||
|
out += `<meta name=generator content=${attr(generator)}>`;
|
||||||
|
}
|
||||||
|
if (publisher) {
|
||||||
|
out += `<meta name=publisher content=${attr(publisher)}>`;
|
||||||
|
}
|
||||||
|
if (referrer) {
|
||||||
|
out += `<meta name=referrer content=${attr(referrer)}>`;
|
||||||
|
}
|
||||||
|
if (themeColor) {
|
||||||
|
if (typeof themeColor === "string") {
|
||||||
|
out += `<meta name=theme-color content=${attr(themeColor)}>`;
|
||||||
|
} else {
|
||||||
|
out += '<meta name=theme-color media="(prefers-color-scheme:light)" ' +
|
||||||
|
`content=${attr(themeColor.light)}>`;
|
||||||
|
out += '<meta name=theme-color media="(prefers-color-scheme:dark)" ' +
|
||||||
|
`content=${attr(themeColor.dark)}>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (denyRobots) {
|
||||||
|
out += `<meta name=robots content=noindex,nofollow>`;
|
||||||
|
}
|
||||||
|
if (description) {
|
||||||
|
out += `<meta name=viewport content=${attr(viewport)}>`;
|
||||||
|
}
|
||||||
|
if (canonical) {
|
||||||
|
out += `<link rel=canonical href=${attr(canonical)}>`;
|
||||||
|
}
|
||||||
|
if (manifest) {
|
||||||
|
out += `<link rel=manifest href=${attr(manifest)}>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (openGraph) out += renderOpenGraph("og:", openGraph);
|
||||||
|
if (twitter) out += renderOpenGraph("twitter:", twitter);
|
||||||
|
|
||||||
|
if (meta.extra) out += render.sync(meta.extra);
|
||||||
|
if (template.extra) out += render.sync(template.extra);
|
||||||
|
|
||||||
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderOpenGraph(prefix: string, value: OpenGraphField): string {
|
||||||
|
if (!value) return "";
|
||||||
|
if (typeof value === "string") {
|
||||||
|
return `<meta name=${attr(prefix)} content=${attr(value)}>`;
|
||||||
|
}
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value
|
||||||
|
.map((item) => renderOpenGraph(prefix, item))
|
||||||
|
.join("");
|
||||||
|
}
|
||||||
|
return Object.entries(value)
|
||||||
|
.map(([key, item]) => renderOpenGraph(`${prefix}:${key}`, item))
|
||||||
|
.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function attr(value: string) {
|
||||||
|
return render.quoteIfNeeded(esc(value));
|
||||||
|
}
|
||||||
|
|
||||||
import { escapeHtmlContent as esc } from "#engine/render";
|
import { escapeHtmlContent as esc } from "#engine/render";
|
||||||
|
import * as render from "#engine/render";
|
||||||
|
|
|
@ -14,6 +14,7 @@ try {
|
||||||
export interface Codegen {
|
export interface Codegen {
|
||||||
views: { [K in Key]: View<PropsFromModule<ViewMap[K]>> };
|
views: { [K in Key]: View<PropsFromModule<ViewMap[K]>> };
|
||||||
scripts: Record<string, string>;
|
scripts: Record<string, string>;
|
||||||
|
metaTemplate: meta.Template;
|
||||||
regenTtls: Ttl[];
|
regenTtls: Ttl[];
|
||||||
regenTags: Record<RegenKey, Key[]>;
|
regenTags: Record<RegenKey, Key[]>;
|
||||||
}
|
}
|
||||||
|
@ -38,7 +39,11 @@ export async function serve<K extends Key>(
|
||||||
id: K,
|
id: K,
|
||||||
props: PropsFromModule<ViewMap[K]>,
|
props: PropsFromModule<ViewMap[K]>,
|
||||||
) {
|
) {
|
||||||
return context.html(await renderToString(id, { context, ...props }));
|
return context.html(await renderToString(id, { context, ...props }), {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "text/html;charset=utf8",
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
type PropsFromModule<M extends any> = M extends {
|
type PropsFromModule<M extends any> = M extends {
|
||||||
|
@ -63,7 +68,7 @@ export async function renderToString<K extends Key>(
|
||||||
// -- metadata --
|
// -- metadata --
|
||||||
const renderedMetaPromise = Promise.resolve(
|
const renderedMetaPromise = Promise.resolve(
|
||||||
typeof metadata === "function" ? metadata(props) : metadata,
|
typeof metadata === "function" ? metadata(props) : metadata,
|
||||||
).then((m) => meta.renderMeta(m));
|
).then((m) => meta.render(m, codegen.metaTemplate));
|
||||||
|
|
||||||
// -- html --
|
// -- html --
|
||||||
let page: render.Element = render.element(component, props);
|
let page: render.Element = render.element(component, props);
|
||||||
|
|
|
@ -18,10 +18,10 @@ that assist building websites. these tools power <https://paperclover.net>.
|
||||||
checks when the files change. For example, changing a component re-tests
|
checks when the files change. For example, changing a component re-tests
|
||||||
only pages that use that component and re-lints only the changed file.
|
only pages that use that component and re-lints only the changed file.
|
||||||
- **Integrated libraries for building complex, content heavy web sites.**
|
- **Integrated libraries for building complex, content heavy web sites.**
|
||||||
- Static asset serving with ETag and build-time compression.
|
- Static asset serving with automatic ETag and build-time compression.
|
||||||
- Dynamicly rendered pages with static client. (`import "#sitegen/view"`)
|
- Dynamic pages. Static pages can be regenerated (and cached) at runtime.
|
||||||
- Databases with a typed SQLite wrapper. (`import "#sitegen/sqlite"`)
|
- Databases with a minimal SQLite wrapper.
|
||||||
- TODO: Meta and Open Graph generation. (`export const meta`)
|
- Meta Tags and Open Graph generation.
|
||||||
- Font subsetting to reduce page bandwidth.
|
- Font subsetting to reduce page bandwidth.
|
||||||
- **Built on the stable and battle-tested Node.js runtime.**
|
- **Built on the stable and battle-tested Node.js runtime.**
|
||||||
|
|
||||||
|
|
|
@ -1,28 +1,13 @@
|
||||||
import type { Meta } from "#meta";
|
import type { Meta } from "#meta";
|
||||||
import "./index.css";
|
import "./index.css";
|
||||||
|
|
||||||
export const theme = {
|
export const theme = { bg: "#fff", fg: "#666" };
|
||||||
bg: "#fff",
|
export const meta = {
|
||||||
fg: "#666",
|
title: "paper clover",
|
||||||
};
|
description: "and then we knew, just like paper airplanes: that we could fly...",
|
||||||
static const title = "paper clover";
|
embed: { /* Default embed */ },
|
||||||
static const description = "and then we knew, just like paper airplanes: that we could fly...";
|
|
||||||
export const meta: Meta = {
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
openGraph: {
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
type: "website",
|
|
||||||
url: "https://paperclover.net",
|
|
||||||
},
|
|
||||||
alternates: {
|
alternates: {
|
||||||
canonical: "https://paperclover.net",
|
"application/rss+xml": "rss.xml",
|
||||||
types: {
|
|
||||||
"application/rss+xml": [
|
|
||||||
{ url: "rss.xml", title: "rss" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import "./resume.css";
|
import "./resume.css";
|
||||||
|
|
||||||
export const meta = { title: 'clover\'s resume' };
|
export const meta = { title: "clover's resume" };
|
||||||
|
|
||||||
<main>
|
<main>
|
||||||
<h1>clover's resume</h1>
|
<h1>clover's resume</h1>
|
||||||
|
|
|
@ -20,8 +20,13 @@ export const backends: string[] = [
|
||||||
join("source-of-truth.ts"),
|
join("source-of-truth.ts"),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
export const meta: MetaTemplate = {
|
||||||
|
base: new URL("https://paperclover.net"),
|
||||||
|
generator: true,
|
||||||
|
};
|
||||||
|
|
||||||
// Font subsets reduce bandwidth and protect against proprietary font theft.
|
// Font subsets reduce bandwidth and protect against proprietary font theft.
|
||||||
const fontRoot = path.join(nasRoot, 'Documents/Font');
|
const fontRoot = path.join(nasRoot, "Documents/Font");
|
||||||
const ascii = { start: 0x20, end: 0x7E };
|
const ascii = { start: 0x20, end: 0x7E };
|
||||||
const nonAscii: sg.FontRange[] = [
|
const nonAscii: sg.FontRange[] = [
|
||||||
{ start: 0xC0, end: 0xFF },
|
{ start: 0xC0, end: 0xFF },
|
||||||
|
@ -105,5 +110,6 @@ export async function main() {
|
||||||
|
|
||||||
import * as path from "node:path";
|
import * as path from "node:path";
|
||||||
import * as font from "../framework/font.ts";
|
import * as font from "../framework/font.ts";
|
||||||
|
import type { Template as MetaTemplate } from "#sitegen/meta";
|
||||||
import type * as sg from "#sitegen";
|
import type * as sg from "#sitegen";
|
||||||
import { nasRoot } from "./file-viewer/paths.ts";
|
import { nasRoot } from "./file-viewer/paths.ts";
|
||||||
|
|
Loading…
Reference in a new issue