authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2025-08-15 22:30:58-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2025-10-14 02:40:46-07:00
log418861b947737336087371ca7d7abb297188adcd
tree52f638e42b282b5debb9ee9d07d22bca64e620da
parent89a4f02a8f324f23275dd26bb4a23988ca07bc59
signature Commit is signed but in an unrecognized format.

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.

11 files changed, 321 insertions(+), 62 deletions(-)

framework/bundle.ts+9
...@@ -114,6 +114,7 @@ export interface ServerSideOptions {...@@ -114,6 +114,7 @@ export interface ServerSideOptions {
114 styleMap: Map<string, incr.Ref<string>>;114 styleMap: Map<string, incr.Ref<string>>;
115 scriptMap: incr.Ref<Record<string, string>>;115 scriptMap: incr.Ref<Record<string, string>>;
116 platform: ServerPlatform;116 platform: ServerPlatform;
117 metaTemplate: meta.Template;
117}118}
118export async function bundleServerJavaScript({119export async function bundleServerJavaScript({
119 viewItems,120 viewItems,
...@@ -122,6 +123,7 @@ export async function bundleServerJavaScript({...@@ -122,6 +123,7 @@ export async function bundleServerJavaScript({
122 scriptMap: wScriptMap,123 scriptMap: wScriptMap,
123 entries,124 entries,
124 platform,125 platform,
126 metaTemplate,
125}: ServerSideOptions) {127}: ServerSideOptions) {
126 const regenKeys: Record<string, string[]> = {};128 const regenKeys: Record<string, string[]> = {};
127 const regenTtls: view.Ttl[] = [];129 const regenTtls: view.Ttl[] = [];
...@@ -173,6 +175,12 @@ export async function bundleServerJavaScript({...@@ -173,6 +175,12 @@ export async function bundleServerJavaScript({
173 "}",175 "}",
174 `export const regenTags = ${JSON.stringify(regenKeys)};`,176 `export const regenTags = ${JSON.stringify(regenKeys)};`,
175 `export const regenTtls = ${JSON.stringify(regenTtls)};`,177 `export const regenTtls = ${JSON.stringify(regenTtls)};`,
178 `export const metaTemplate = {`,
179 ` base: new URL(${JSON.stringify(metaTemplate.base)}),`,
180 ...Object.entries(metaTemplate)
181 .filter(([k]) => k !== 'base')
182 .map(([k, v]) => ` ${JSON.stringify(k)}: ${JSON.stringify(v)},`),
183 `};`,
176 ].join("\n"),184 ].join("\n"),
177 };185 };
178 },186 },
...@@ -371,3 +379,4 @@ import * as incr from "./incremental.ts";...@@ -371,3 +379,4 @@ import * as incr from "./incremental.ts";
371import * as sg from "#sitegen";379import * as sg from "#sitegen";
372import type { PageOrView } from "./generate.ts";380import type { PageOrView } from "./generate.ts";
373import type * as view from "#sitegen/view";381import type * as view from "#sitegen/view";
382import * as meta from "#sitegen/meta";
framework/engine/render.ts+1-1
...@@ -268,7 +268,7 @@ export function stringifyStyleAttribute(style: Record<string, string>) {...@@ -268,7 +268,7 @@ export function stringifyStyleAttribute(style: Record<string, string>) {
268 return "style=" + quoteIfNeeded(out);268 return "style=" + quoteIfNeeded(out);
269}269}
270export function quoteIfNeeded(text: string) {270export function quoteIfNeeded(text: string) {
271 if (text.match(/["/>]/)) return '"' + text + '"';271 if (text.match(/["/> ]/)) return '"' + text + '"';
272 return text;272 return text;
273}273}
274274
framework/font.ts+18-13
...@@ -17,7 +17,6 @@ export async function buildFonts(fonts: sg.Font[]) {...@@ -17,7 +17,6 @@ export async function buildFonts(fonts: sg.Font[]) {
17 }17 }
1818
19 const instances = new async.OnceMap<void>();19 const instances = new async.OnceMap<void>();
20 const subsets = new async.OnceMap<void>();
21 async function makeInstance(input: string, vars: sg.FontVars) {20 async function makeInstance(input: string, vars: sg.FontVars) {
22 const args = Object.entries(vars).map((x) => {21 const args = Object.entries(vars).map((x) => {
23 const lower = x[0].toLowerCase();22 const lower = x[0].toLowerCase();
...@@ -89,18 +88,23 @@ export async function fetchFont(name: string, sources: string[]) {...@@ -89,18 +88,23 @@ export async function fetchFont(name: string, sources: string[]) {
89 if (fs.existsSync(cacheName)) return cacheName;88 if (fs.existsSync(cacheName)) return cacheName;
9089
91 if (source.startsWith("https://")) {90 if (source.startsWith("https://")) {
92 const response = await fetch(source);91 try {
93 if (response.ok) {92 const response = await fetch(source);
94 await fs.writeMkdir(93 if (response.ok) {
95 cacheName,94 await fs.writeMkdir(
96 Buffer.from(await response.arrayBuffer()),95 cacheName,
97 );96 Buffer.from(await response.arrayBuffer()),
98 } else {97 );
99 errs.push(98 } else {
100 new Error(99 errs.push(
101 `Fetching from ${source} failed: ${response.status} ${response.statusText}`,100 new Error(
102 ),101 `Fetching from ${source} failed: ${response.status} ${response.statusText}`,
103 );102 ),
103 );
104 continue;
105 }
106 } catch (err) {
107 errs.push(`Fetching from ${source} failed: ${error.message(err)}`);
104 continue;108 continue;
105 }109 }
106 }110 }
...@@ -190,3 +194,4 @@ import * as subprocess from "#sitegen/subprocess";...@@ -190,3 +194,4 @@ import * as subprocess from "#sitegen/subprocess";
190import * as incr from "./incremental.ts";194import * as incr from "./incremental.ts";
191import * as crypto from "node:crypto";195import * as crypto from "node:crypto";
192import * as async from "#sitegen/async";196import * as async from "#sitegen/async";
197import * as error from "#sitegen/error";
framework/generate.ts+27-7
...@@ -14,9 +14,16 @@ export async function main() {...@@ -14,9 +14,16 @@ export async function main() {
14export async function generate() {14export async function generate() {
15 // -- read config and discover files --15 // -- read config and discover files --
16 const siteConfig = await incr.work(readManifest);16 const siteConfig = await incr.work(readManifest);
17 const { staticFiles, scripts, views, pages } = await discoverAllFiles(17 const metaTemplate: meta.Template = {
18 siteConfig,18 ...siteConfig.meta,
19 );19 base: new URL(siteConfig.meta.base),
20 };
21 const {
22 staticFiles,
23 scripts,
24 views,
25 pages,
26 } = await discoverAllFiles(siteConfig);
2027
21 // TODO: make sure that `static` and `pages` does not overlap28 // TODO: make sure that `static` and `pages` does not overlap
2229
...@@ -26,7 +33,9 @@ export async function generate() {...@@ -26,7 +33,9 @@ export async function generate() {
26 const builtFonts = fonts.buildFonts(siteConfig.fonts);33 const builtFonts = fonts.buildFonts(siteConfig.fonts);
2734
28 // -- perform build-time rendering --35 // -- perform build-time rendering --
29 const builtPages = pages.map((item) => incr.work(preparePage, item));36 const builtPages = pages.map((item) =>
37 incr.work(preparePage, { item, metaTemplate })
38 );
30 const builtViews = views.map((item) => incr.work(prepareView, item));39 const builtViews = views.map((item) => incr.work(prepareView, item));
31 const builtStaticFiles = Promise.all(40 const builtStaticFiles = Promise.all(
32 staticFiles.map((item) =>41 staticFiles.map((item) =>
...@@ -66,6 +75,7 @@ export async function generate() {...@@ -66,6 +75,7 @@ export async function generate() {
66 return { id: type === "page" ? `page:${id}` : id, file };75 return { id: type === "page" ? `page:${id}` : id, file };
67 }),76 }),
68 viewRefs: viewsAndDynPages,77 viewRefs: viewsAndDynPages,
78 metaTemplate,
69 });79 });
7080
71 // -- assemble page assets --81 // -- assemble page assets --
...@@ -89,6 +99,10 @@ export async function readManifest(io: Io) {...@@ -89,6 +99,10 @@ export async function readManifest(io: Io) {
89 })),99 })),
90 backends: cfg.backends.map(toRel),100 backends: cfg.backends.map(toRel),
91 fonts: cfg.fonts,101 fonts: cfg.fonts,
102 meta: {
103 ...cfg.meta,
104 base: cfg.meta.base.toString(),
105 },
92 };106 };
93}107}
94108
...@@ -194,7 +208,10 @@ export async function scanSiteSection(io: Io, sectionRoot: string) {...@@ -194,7 +208,10 @@ export async function scanSiteSection(io: Io, sectionRoot: string) {
194 return { staticFiles, pages, views, scripts };208 return { staticFiles, pages, views, scripts };
195}209}
196210
197export async function preparePage(io: Io, item: sg.FileItem) {211export async function preparePage(
212 io: Io,
213 { item, metaTemplate }: { item: sg.FileItem; metaTemplate: meta.Template },
214) {
198 // -- load and validate module --215 // -- load and validate module --
199 let {216 let {
200 default: Page,217 default: Page,
...@@ -220,7 +237,10 @@ export async function preparePage(io: Io, item: sg.FileItem) {...@@ -220,7 +237,10 @@ export async function preparePage(io: Io, item: sg.FileItem) {
220 // -- metadata --237 // -- metadata --
221 const renderedMetaPromise = Promise.resolve(238 const renderedMetaPromise = Promise.resolve(
222 typeof metadata === "function" ? metadata({ ssr: true }) : metadata,239 typeof metadata === "function" ? metadata({ ssr: true }) : metadata,
223 ).then((m) => meta.renderMeta(m));240 ).then((m) => meta.render({
241 canonical: item.id,
242 ...m,
243 }, metaTemplate));
224244
225 // -- html --245 // -- html --
226 let page = render.element(Page);246 let page = render.element(Page);
...@@ -322,7 +342,7 @@ export async function assembleAndWritePage(...@@ -322,7 +342,7 @@ export async function assembleAndWritePage(
322 pathname: id,342 pathname: id,
323 buffer,343 buffer,
324 headers: {344 headers: {
325 "Content-Type": "text/html",345 "Content-Type": "text/html;charset=utf8",
326 },346 },
327 regenerative: !!regenerate,347 regenerative: !!regenerate,
328 });348 });
framework/incremental.ts+2-2
...@@ -277,8 +277,8 @@ type SerializedState = ReturnType<typeof serialize>;...@@ -277,8 +277,8 @@ type SerializedState = ReturnType<typeof serialize>;
277/* No-op on failure */277/* No-op on failure */
278async function deserialize(buffer: Buffer) {278async function deserialize(buffer: Buffer) {
279 const decoded = msgpackr.decode(buffer) as SerializedState;279 const decoded = msgpackr.decode(buffer) as SerializedState;
280 if (!Array.isArray(decoded)) return false;280 if (!Array.isArray(decoded)) return;
281 if (decoded[0] !== 1) return false;281 if (decoded[0] !== 1) return;
282 const [, fileEntries, workEntries, expectedFilesOnDisk, assetEntries] =282 const [, fileEntries, workEntries, expectedFilesOnDisk, assetEntries] =
283 decoded;283 decoded;
284 for (const [k, type, content, ...affects] of fileEntries) {284 for (const [k, type, content, ...affects] of fileEntries) {
framework/lib/meta.ts+239-10
...@@ -1,17 +1,119 @@...@@ -1,17 +1,119 @@
1export { renderMetadata as render };
1export interface Meta {2export interface Meta {
3 /** Required for all pages. `<title>{content}</title>` */
2 title: string;4 title: string;
3 description?: string | undefined;5 /** Recommended for all pages. `<meta name="description" content="{...}" />` */
4 openGraph?: OpenGraph;6 description?: string | null;
5 alternates?: Alternates;7 /** Automatically added for static renders from the 'pages' folders. */
8 canonical?: string | null;
9 /** Add `<link rel="alternate" ... />`. Object keys are interpretted as
10 * mime types if they contain a slash, otherwise seen as an alternative language. */
11 alternates?: Alternate[] | Record<string, string>;
12
13 /** Automatically generate both OpenGraph and Twitter meta tags */
14 embed?: AutoEmbed | null;
15 /** Add a robots tag for `noindex` and `nofollow` */
16 denyRobots?: boolean | null;
17 /** Add 'og:*' meta tags */
18 openGraph?: OpenGraph | null;
19 /** Add 'twitter:*' meta tags */
20 twitter?: Twitter | null;
21 /** * Refer to an oEmbed file. See https://oembed.com
22 * TODO: support passing the oEmbed file directly here. */
23 oEmbed?: string;
24 /**
25 * '#sitegen/meta' intentionally excludes a lot of exotic tags.
26 * Add these manually using JSX syntax:
27 *
28 * extra: [
29 * <meta name="site-verification" content="waffles" />,
30 * ],
31 */
32 extra?: render.Node;
33
34 /** Adds `<meta name="author" content="{...}" />` */
35 authors?: string[];
36 /** Credit sitegen by setting this to true aka "clover sitegen 3; paperclover.net/sitegen" */
37 generator?: string | true | null;
38 /** Adds `<meta name="keywords" content="{keywords.join(', ')}" />` */
39 keywords?: string[];
40 /** URL to a manifest; https://developer.mozilla.org/en-US/docs/Web/Manifest */
41 manifest?: string | null;
42 /** Adds `<meta name="publisher" content="{...}" />` */
43 publisher?: string | null;
44 /** https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/meta/name/referrer */
45 referrer?: Referrer | null;
46 /** Adds `<meta name="theme-color" content="{...}" />` */
47 themeColor?: string | { dark: string; light: string } | null;
48 /** Defaults to `width=device-width, initial-scale=1.0` for mobile compatibility. */
49 viewport?: string;
6}50}
7export interface Template extends Omit<Meta, "title"> {51export type Alternate = { type: string; url: string } | {
8 titleTemplate?: (title: string) => string,52 lang: string;
53 url: string;
54};
55export interface AutoEmbed {
56 /* Defaults to the page title. */
57 title?: string | null;
58 /* Defaults to the page description. */
59 description?: string | null;
60 /* Provide to add an embed image. */
61 thumbnail?: string | null;
62 /** @default "banner", which applies twitter:card = "summary_large_image" */
63 thumbnailSize?: "banner" | "icon";
64 /* Ignored if not passed */
65 siteTitle?: string | null;
9}66}
67/** See https://ogp.me for extra rules. */
10export interface OpenGraph {68export interface OpenGraph {
69 /** The title of your object as it should appear within the graph */
11 title?: string;70 title?: string;
12 description?: string | undefined;71 /** A one to two sentence description of your object. */
13 type: string;72 description?: string | null;
14 url: string;73 /** The type of your object, e.g., "video.movie". Depending on the type you specify, other properties may also be required */
74 type?: string;
75 /** An image URL which should represent your object within the graph */
76 image?: OpenGraphField;
77 /** 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/" */
78 url?: string;
79 /** A URL to an audio file to accompany this object */
80 audio?: OpenGraphField;
81 /** 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) */
82 determiner?: string;
83 /** The locale these tags are marked up in. Of the format language_TERRITORY. Default is en_US */
84 locale?: string;
85 /** An array of other locales this page is available in */
86 "locale:alternate"?: string[];
87 /** If your object is part of a larger web site, the name which should be displayed for the overall site. e.g., "IMDb" */
88 site_name?: string;
89 /** A URL to a video file that complements this object */
90 video?: OpenGraphField;
91 [field: string]: OpenGraphField;
92}
93/**
94 * When passing an array, the property is duplicated.
95 * When passing an object, the fields are emitted as namespaced with ':'.
96 */
97type OpenGraphField =
98 | string
99 | { [field: string]: OpenGraphField }
100 | Array<OpenGraphField>
101 | (null | undefined);
102/** Twitter uses various OpenGraph fields if these are not specified. */
103export interface Twitter {
104 card: string;
105 title?: string;
106 description?: string | null;
107 url?: string;
108 image?: string;
109 player?: string;
110 /** Same logic as Open Graph */
111 [field: string]: OpenGraphField;
112}
113export interface Template
114 extends Omit<Meta, "title" | "description" | "canonical"> {
115 base: URL;
116 titleTemplate?: (title: string) => string;
15}117}
16export interface Alternates {118export interface Alternates {
17 canonical: string;119 canonical: string;
...@@ -21,7 +123,134 @@ export interface AlternateType {...@@ -21,7 +123,134 @@ export interface AlternateType {
21 url: string;123 url: string;
22 title: string;124 title: string;
23}125}
24export function renderMeta({ title }: Meta): string {126export type Referrer =
25 return `<title>${esc(title)}</title><link rel="icon" type="image/x-icon" href="/favicon.ico">`;127 | "no-referrer"
128 | "origin"
129 | "no-referrer-when-downgrade"
130 | "origin-when-cross-origin"
131 | "same-origin"
132 | "strict-origin"
133 | "strict-origin-when-cross-origin";
134
135/* Convert a metadata definition into text. */
136function renderMetadata(meta: Meta, template: Template): string {
137 const { titleTemplate, base } = template;
138 const resolve = (str: string) => new URL(str, base).href;
139
140 const title = titleTemplate ? titleTemplate(meta.title) : meta.title;
141 const description = meta.description ?? null;
142 const canonical = meta.canonical ? resolve(meta.canonical) : null;
143 const denyRobots = Boolean(meta.denyRobots || template.denyRobots);
144 const authors = meta.authors ?? template.authors ?? null;
145 let generator = meta.generator ?? template.generator ?? null;
146 const keywords = meta.keywords ?? template.keywords ?? null;
147 const manifest = meta.manifest ?? template.manifest ?? null;
148 const publisher = meta.publisher ?? template.publisher ?? null;
149 const referrer = meta.referrer ?? template.referrer ?? null;
150 const themeColor = meta.themeColor ?? template.themeColor ?? null;
151 const viewport = meta.viewport ?? template.viewport ??
152 "width=device-width, initial-scale=1.0";
153
154 const embed = meta.embed ?? template.embed ?? null;
155 let openGraph = meta.openGraph ?? template.openGraph ?? null;
156 let twitter = meta.twitter ?? template.twitter ?? null;
157 if (embed) {
158 const { thumbnail, thumbnailSize, siteTitle } = embed;
159 openGraph = {
160 type: "website",
161 title: embed.title ?? title,
162 description: embed.description ?? description,
163 ...openGraph,
164 };
165 twitter = {
166 card: (thumbnailSize ?? (thumbnail ? "banner" : "icon")) === "banner"
167 ? "summary_large_image"
168 : "summary",
169 ...twitter,
170 };
171 if (thumbnail) {
172 openGraph.image = embed.thumbnail;
173 }
174 if (siteTitle) {
175 openGraph.site_name = siteTitle;
176 }
177 if (canonical) {
178 openGraph.url = canonical;
179 }
180 }
181
182 let out = `<title>${esc(title)}</title>`;
183 if (description) {
184 out += `<meta name=description content=${attr(description)}>`;
185 }
186 for (const author of authors ?? []) {
187 out += `<meta name=author content=${attr(author)}>`;
188 }
189 if (keywords) {
190 out += `<meta name=keywords content=${attr(keywords.join(", "))}>`;
191 }
192 if (generator) {
193 if (generator === true) {
194 generator = "clover sitegen 3; paperclover.net/sitegen";
195 }
196 out += `<meta name=generator content=${attr(generator)}>`;
197 }
198 if (publisher) {
199 out += `<meta name=publisher content=${attr(publisher)}>`;
200 }
201 if (referrer) {
202 out += `<meta name=referrer content=${attr(referrer)}>`;
203 }
204 if (themeColor) {
205 if (typeof themeColor === "string") {
206 out += `<meta name=theme-color content=${attr(themeColor)}>`;
207 } else {
208 out += '<meta name=theme-color media="(prefers-color-scheme:light)" ' +
209 `content=${attr(themeColor.light)}>`;
210 out += '<meta name=theme-color media="(prefers-color-scheme:dark)" ' +
211 `content=${attr(themeColor.dark)}>`;
212 }
213 }
214 if (denyRobots) {
215 out += `<meta name=robots content=noindex,nofollow>`;
216 }
217 if (description) {
218 out += `<meta name=viewport content=${attr(viewport)}>`;
219 }
220 if (canonical) {
221 out += `<link rel=canonical href=${attr(canonical)}>`;
222 }
223 if (manifest) {
224 out += `<link rel=manifest href=${attr(manifest)}>`;
225 }
226
227 if (openGraph) out += renderOpenGraph("og:", openGraph);
228 if (twitter) out += renderOpenGraph("twitter:", twitter);
229
230 if (meta.extra) out += render.sync(meta.extra);
231 if (template.extra) out += render.sync(template.extra);
232
233 return out;
234}
235
236function renderOpenGraph(prefix: string, value: OpenGraphField): string {
237 if (!value) return "";
238 if (typeof value === "string") {
239 return `<meta name=${attr(prefix)} content=${attr(value)}>`;
240 }
241 if (Array.isArray(value)) {
242 return value
243 .map((item) => renderOpenGraph(prefix, item))
244 .join("");
245 }
246 return Object.entries(value)
247 .map(([key, item]) => renderOpenGraph(`${prefix}:${key}`, item))
248 .join("");
249}
250
251function attr(value: string) {
252 return render.quoteIfNeeded(esc(value));
26}253}
254
27import { escapeHtmlContent as esc } from "#engine/render";255import { escapeHtmlContent as esc } from "#engine/render";
256import * as render from "#engine/render";
framework/lib/view.ts+7-2
...@@ -14,6 +14,7 @@ try {...@@ -14,6 +14,7 @@ try {
14export interface Codegen {14export interface Codegen {
15 views: { [K in Key]: View<PropsFromModule<ViewMap[K]>> };15 views: { [K in Key]: View<PropsFromModule<ViewMap[K]>> };
16 scripts: Record<string, string>;16 scripts: Record<string, string>;
17 metaTemplate: meta.Template;
17 regenTtls: Ttl[];18 regenTtls: Ttl[];
18 regenTags: Record<RegenKey, Key[]>;19 regenTags: Record<RegenKey, Key[]>;
19}20}
...@@ -38,7 +39,11 @@ export async function serve<K extends Key>(...@@ -38,7 +39,11 @@ export async function serve<K extends Key>(
38 id: K,39 id: K,
39 props: PropsFromModule<ViewMap[K]>,40 props: PropsFromModule<ViewMap[K]>,
40) {41) {
41 return context.html(await renderToString(id, { context, ...props }));42 return context.html(await renderToString(id, { context, ...props }), {
43 headers: {
44 "Content-Type": "text/html;charset=utf8",
45 },
46 });
42}47}
4348
44type PropsFromModule<M extends any> = M extends {49type PropsFromModule<M extends any> = M extends {
...@@ -63,7 +68,7 @@ export async function renderToString<K extends Key>(...@@ -63,7 +68,7 @@ export async function renderToString<K extends Key>(
63 // -- metadata --68 // -- metadata --
64 const renderedMetaPromise = Promise.resolve(69 const renderedMetaPromise = Promise.resolve(
65 typeof metadata === "function" ? metadata(props) : metadata,70 typeof metadata === "function" ? metadata(props) : metadata,
66 ).then((m) => meta.renderMeta(m));71 ).then((m) => meta.render(m, codegen.metaTemplate));
6772
68 // -- html --73 // -- html --
69 let page: render.Element = render.element(component, props);74 let page: render.Element = render.element(component, props);
readme.md+4-4
...@@ -18,10 +18,10 @@ that assist building websites. these tools power <https://paperclover.net>....@@ -18,10 +18,10 @@ that assist building websites. these tools power <https://paperclover.net>.
18 checks when the files change. For example, changing a component re-tests18 checks when the files change. For example, changing a component re-tests
19 only pages that use that component and re-lints only the changed file.19 only pages that use that component and re-lints only the changed file.
20- **Integrated libraries for building complex, content heavy web sites.**20- **Integrated libraries for building complex, content heavy web sites.**
21 - Static asset serving with ETag and build-time compression.21 - Static asset serving with automatic ETag and build-time compression.
22 - Dynamicly rendered pages with static client. (`import "#sitegen/view"`)22 - Dynamic pages. Static pages can be regenerated (and cached) at runtime.
23 - Databases with a typed SQLite wrapper. (`import "#sitegen/sqlite"`)23 - Databases with a minimal SQLite wrapper.
24 - TODO: Meta and Open Graph generation. (`export const meta`)24 - Meta Tags and Open Graph generation.
25 - Font subsetting to reduce page bandwidth.25 - Font subsetting to reduce page bandwidth.
26- **Built on the stable and battle-tested Node.js runtime.**26- **Built on the stable and battle-tested Node.js runtime.**
2727
src/pages/index.marko+6-21
...@@ -1,28 +1,13 @@...@@ -1,28 +1,13 @@
1import type { Meta } from "#meta";1import type { Meta } from "#meta";
2import "./index.css";2import "./index.css";
33
4export const theme = {4export const theme = { bg: "#fff", fg: "#666" };
5 bg: "#fff",5export const meta = {
6 fg: "#666",6 title: "paper clover",
7};7 description: "and then we knew, just like paper airplanes: that we could fly...",
8static const title = "paper clover";8 embed: { /* Default embed */ },
9static const description = "and then we knew, just like paper airplanes: that we could fly...";
10export const meta: Meta = {
11 title,
12 description,
13 openGraph: {
14 title,
15 description,
16 type: "website",
17 url: "https://paperclover.net",
18 },
19 alternates: {9 alternates: {
20 canonical: "https://paperclover.net",10 "application/rss+xml": "rss.xml",
21 types: {
22 "application/rss+xml": [
23 { url: "rss.xml", title: "rss" },
24 ],
25 },
26 },11 },
27};12};
2813
src/pages/resume.marko+1-1
...@@ -1,6 +1,6 @@...@@ -1,6 +1,6 @@
1import "./resume.css";1import "./resume.css";
22
3export const meta = { title: 'clover\'s resume' };3export const meta = { title: "clover's resume" };
44
5<main>5<main>
6<h1>clover's resume</h1>6<h1>clover's resume</h1>
src/site.ts+7-1
...@@ -20,8 +20,13 @@ export const backends: string[] = [...@@ -20,8 +20,13 @@ export const backends: string[] = [
20 join("source-of-truth.ts"),20 join("source-of-truth.ts"),
21];21];
2222
23export const meta: MetaTemplate = {
24 base: new URL("https://paperclover.net"),
25 generator: true,
26};
27
23// Font subsets reduce bandwidth and protect against proprietary font theft.28// Font subsets reduce bandwidth and protect against proprietary font theft.
24const fontRoot = path.join(nasRoot, 'Documents/Font');29const fontRoot = path.join(nasRoot, "Documents/Font");
25const ascii = { start: 0x20, end: 0x7E };30const ascii = { start: 0x20, end: 0x7E };
26const nonAscii: sg.FontRange[] = [31const nonAscii: sg.FontRange[] = [
27 { start: 0xC0, end: 0xFF },32 { start: 0xC0, end: 0xFF },
...@@ -105,5 +110,6 @@ export async function main() {...@@ -105,5 +110,6 @@ export async function main() {
105110
106import * as path from "node:path";111import * as path from "node:path";
107import * as font from "../framework/font.ts";112import * as font from "../framework/font.ts";
113import type { Template as MetaTemplate } from "#sitegen/meta";
108import type * as sg from "#sitegen";114import type * as sg from "#sitegen";
109import { nasRoot } from "./file-viewer/paths.ts";115import { nasRoot } from "./file-viewer/paths.ts";