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.
382 lines
12 KiB
TypeScript
382 lines
12 KiB
TypeScript
async function trackEsbuild(io: Io, metafile: esbuild.Metafile) {
|
|
await Promise.all(
|
|
Object.keys(metafile.inputs)
|
|
.filter((file) => !isIgnoredSource(file))
|
|
.map((file) => io.trackFile(file)),
|
|
);
|
|
}
|
|
|
|
// This file implements client-side bundling, mostly wrapping esbuild.
|
|
export async function bundleClientJavaScript(
|
|
io: Io,
|
|
{
|
|
clientRefs,
|
|
extraPublicScripts,
|
|
dev = false,
|
|
}: {
|
|
clientRefs: string[];
|
|
extraPublicScripts: string[];
|
|
dev: boolean;
|
|
},
|
|
) {
|
|
const entryPoints = [
|
|
...new Set(
|
|
[...clientRefs.map((x) => `src/${x}`), ...extraPublicScripts].map(toAbs),
|
|
),
|
|
];
|
|
if (entryPoints.length === 0) return {};
|
|
const invalidFiles = entryPoints.filter(
|
|
(file) => !file.match(/\.client\.[tj]sx?/),
|
|
);
|
|
if (invalidFiles.length > 0) {
|
|
const cwd = process.cwd();
|
|
throw new Error(
|
|
"All client-side scripts should be named like '.client.ts'. Exceptions: \n" +
|
|
invalidFiles.map((x) => path.join(cwd, x)).join("\n"),
|
|
);
|
|
}
|
|
|
|
const clientPlugins: esbuild.Plugin[] = [
|
|
projectRelativeResolution(),
|
|
markoViaBuildCache(),
|
|
];
|
|
|
|
const bundle = await esbuild
|
|
.build({
|
|
assetNames: "/asset/[hash]",
|
|
bundle: true,
|
|
chunkNames: "/js/c.[hash]",
|
|
entryNames: "/js/[name]",
|
|
entryPoints,
|
|
format: "esm",
|
|
jsx: "automatic",
|
|
jsxDev: dev,
|
|
jsxImportSource: "#engine",
|
|
logLevel: "silent",
|
|
metafile: true,
|
|
minify: !dev,
|
|
outdir: "out!",
|
|
plugins: clientPlugins,
|
|
write: false,
|
|
define: {
|
|
ASSERT: "console.assert",
|
|
MIME_INLINE_DATA: JSON.stringify(mime.rawEntriesText),
|
|
},
|
|
})
|
|
.catch((err: any) => {
|
|
err.message = `Client ${err.message}`;
|
|
throw err;
|
|
});
|
|
if (bundle.errors.length || bundle.warnings.length) {
|
|
throw new AggregateError(
|
|
bundle.errors.concat(bundle.warnings),
|
|
"JS bundle failed",
|
|
);
|
|
}
|
|
const publicScriptRoutes = extraPublicScripts.map(
|
|
(file) =>
|
|
"/js/" +
|
|
path
|
|
.relative(hot.projectSrc, file)
|
|
.replaceAll("\\", "/")
|
|
.replace(/\.client\.[tj]sx?/, ".js"),
|
|
);
|
|
const { metafile, outputFiles } = bundle;
|
|
const p = [];
|
|
p.push(trackEsbuild(io, metafile));
|
|
const scripts: Record<string, string> = {};
|
|
for (const file of outputFiles) {
|
|
const { text } = file;
|
|
let route = file.path.replace(/^.*!/, "").replaceAll("\\", "/");
|
|
const { entryPoint } = UNWRAP(metafile.outputs["out!" + route]);
|
|
|
|
// Register non-chunks as script entries.
|
|
const chunk = route.startsWith("/js/c.");
|
|
if (!chunk) {
|
|
const key = hot.getScriptId(toAbs(UNWRAP(entryPoint)));
|
|
route = "/js/" + key.replace(/\.client\.tsx?/, ".js");
|
|
scripts[key] = text;
|
|
}
|
|
// Register chunks and public scripts as assets.
|
|
if (chunk || publicScriptRoutes.includes(route)) {
|
|
p.push(io.writeAsset({ pathname: route, buffer: text }));
|
|
}
|
|
}
|
|
await Promise.all(p);
|
|
return scripts;
|
|
}
|
|
|
|
export type ServerPlatform = "node" | "passthru";
|
|
export interface ServerSideOptions {
|
|
entries: string[];
|
|
viewItems: sg.FileItem[];
|
|
viewRefs: incr.Ref<PageOrView>[];
|
|
styleMap: Map<string, incr.Ref<string>>;
|
|
scriptMap: incr.Ref<Record<string, string>>;
|
|
platform: ServerPlatform;
|
|
metaTemplate: meta.Template;
|
|
}
|
|
export async function bundleServerJavaScript({
|
|
viewItems,
|
|
viewRefs,
|
|
styleMap,
|
|
scriptMap: wScriptMap,
|
|
entries,
|
|
platform,
|
|
metaTemplate,
|
|
}: ServerSideOptions) {
|
|
const regenKeys: Record<string, string[]> = {};
|
|
const regenTtls: view.Ttl[] = [];
|
|
for (const ref of viewRefs) {
|
|
const value = UNWRAP(ref.value);
|
|
if (value.type === "page" && (value.regenerate?.tags?.length ?? 0) > 0) {
|
|
for (const tag of value.regenerate!.tags!) {
|
|
(regenKeys[tag] ??= []).push(`page:${value.id}`);
|
|
}
|
|
}
|
|
if (value.type === "page" && (value.regenerate?.seconds ?? 0) > 0) {
|
|
regenTtls.push({
|
|
key: `page:${value.id}` as view.Key,
|
|
seconds: value.regenerate!.seconds!,
|
|
});
|
|
}
|
|
}
|
|
|
|
const wViewSource = incr.work(
|
|
async (
|
|
_,
|
|
{ viewItems, regenKeys, regenTtls }: {
|
|
viewItems: sg.FileItem[];
|
|
regenKeys: Record<string, string[]>;
|
|
regenTtls: view.Ttl[];
|
|
},
|
|
) => {
|
|
const magicWord = "C_" + crypto.randomUUID().replaceAll("-", "_");
|
|
return {
|
|
magicWord,
|
|
file: [
|
|
...viewItems.map(
|
|
(view, i) =>
|
|
`import * as view${i} from ${JSON.stringify(view.file)}`,
|
|
),
|
|
`const styles = ${magicWord}[-2]`,
|
|
`export const scripts = ${magicWord}[-1]`,
|
|
"export const views = {",
|
|
...viewItems.map((view, i) =>
|
|
[
|
|
` ${JSON.stringify(view.id)}: {`,
|
|
` component: view${i}.default,`,
|
|
` meta: view${i}.meta,`,
|
|
` layout: view${i}.layout?.default ?? null,`,
|
|
` inlineCss: styles[${magicWord}[${i}]]`,
|
|
` },`,
|
|
].join("\n")
|
|
),
|
|
"}",
|
|
`export const regenTags = ${JSON.stringify(regenKeys)};`,
|
|
`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"),
|
|
};
|
|
},
|
|
{ viewItems, regenKeys, regenTtls },
|
|
);
|
|
|
|
await incr.work(
|
|
async (io, { regenKeys, viewItems }) => {
|
|
io.writeFile(
|
|
"../ts/view.d.ts",
|
|
[
|
|
"export interface RegisteredViews {",
|
|
...viewItems
|
|
.filter((view) => !view.id.startsWith("page:"))
|
|
.map(
|
|
(view) =>
|
|
` ${JSON.stringify(view.id)}: ` +
|
|
`typeof import(${
|
|
JSON.stringify(path.relative(".clover/ts", toAbs(view.file)))
|
|
}),`,
|
|
),
|
|
"}",
|
|
"export type RegenKey = " +
|
|
(regenKeys.map((key) => JSON.stringify(key)).join(" | ") ||
|
|
"never"),
|
|
].join("\n"),
|
|
);
|
|
},
|
|
{ regenKeys: Object.keys(regenKeys), viewItems },
|
|
);
|
|
|
|
const wBundles = entries.map((entry) =>
|
|
incr.work(async (io, entry) => {
|
|
const pkg = await io.readJson<{
|
|
dependencies: Record<string, string>;
|
|
}>("package.json");
|
|
|
|
let magicWord = null as string | null;
|
|
// -- plugins --
|
|
const serverPlugins: esbuild.Plugin[] = [
|
|
virtualFiles({
|
|
// only add dependency when imported.
|
|
$views: async () => {
|
|
const view = await io.readWork(wViewSource);
|
|
({ magicWord } = view);
|
|
return view.file;
|
|
},
|
|
}),
|
|
projectRelativeResolution(),
|
|
markoViaBuildCache(),
|
|
{
|
|
name: "replace client references",
|
|
setup(b) {
|
|
b.onLoad({ filter: /\.tsx?$/ }, async ({ path: file }) => ({
|
|
contents: hot.resolveClientRefs(
|
|
await fs.readFile(file, "utf-8"),
|
|
file,
|
|
).code,
|
|
loader: path.extname(file).slice(1) as esbuild.Loader,
|
|
}));
|
|
},
|
|
},
|
|
{
|
|
name: "mark css external",
|
|
setup(b) {
|
|
b.onResolve({ filter: /\.css$/ }, () => ({
|
|
path: ".",
|
|
namespace: "dropped",
|
|
}));
|
|
b.onLoad({ filter: /./, namespace: "dropped" }, () => ({
|
|
contents: "",
|
|
}));
|
|
},
|
|
},
|
|
];
|
|
|
|
const { metafile, outputFiles } = await esbuild.build({
|
|
bundle: true,
|
|
chunkNames: "c.[hash]",
|
|
entryNames: path.basename(entry, path.extname(entry)),
|
|
entryPoints: [
|
|
path.join(import.meta.dirname, "backend/entry-" + platform + ".ts"),
|
|
],
|
|
platform: "node",
|
|
format: "esm",
|
|
minify: false,
|
|
outdir: "out!",
|
|
plugins: serverPlugins,
|
|
splitting: true,
|
|
logLevel: "silent",
|
|
write: false,
|
|
metafile: true,
|
|
jsx: "automatic",
|
|
jsxImportSource: "#engine",
|
|
jsxDev: false,
|
|
define: {
|
|
MIME_INLINE_DATA: JSON.stringify(mime.rawEntriesText),
|
|
"globalThis.CLOVER_SERVER_ENTRY": JSON.stringify(entry),
|
|
},
|
|
external: Object.keys(pkg.dependencies).filter(
|
|
(x) => !x.startsWith("@paperclover"),
|
|
),
|
|
});
|
|
await trackEsbuild(io, metafile);
|
|
|
|
let fileWithMagicWord: {
|
|
bytes: Buffer;
|
|
basename: string;
|
|
magicWord: string;
|
|
} | null = null;
|
|
for (const output of outputFiles) {
|
|
const basename = output.path.replace(/^.*?!(?:\/|\\)/, "");
|
|
const key = "out!/" + basename.replaceAll("\\", "/");
|
|
// If this contains the generated "$views" file, then
|
|
// mark this file as the one for replacement. Because
|
|
// `splitting` is `true`, esbuild will not emit this
|
|
// file in more than one chunk.
|
|
if (
|
|
magicWord &&
|
|
UNWRAP(metafile.outputs[key]).inputs["framework/lib/view.ts"]
|
|
) {
|
|
ASSERT(!fileWithMagicWord);
|
|
fileWithMagicWord = {
|
|
basename,
|
|
bytes: Buffer.from(output.contents),
|
|
magicWord,
|
|
};
|
|
} else {
|
|
io.writeFile(basename, Buffer.from(output.contents));
|
|
}
|
|
}
|
|
return fileWithMagicWord;
|
|
}, entry)
|
|
);
|
|
|
|
const wProcessed = wBundles.map(async (wBundle) => {
|
|
if (!(await wBundle)) return;
|
|
await incr.work(async (io) => {
|
|
// Only the reachable resources need to be read and inserted into the bundle.
|
|
// This is what Map<string, incr.Ref> is for
|
|
const { basename, bytes, magicWord } = UNWRAP(await io.readWork(wBundle));
|
|
const views = await Promise.all(viewRefs.map((ref) => io.readWork(ref)));
|
|
|
|
// Client JS
|
|
const scriptList = Object.entries(await io.readWork(wScriptMap));
|
|
const viewScriptsList = new Set(views.flatMap((view) => view.clientRefs));
|
|
const neededScripts = scriptList.filter(([k]) => viewScriptsList.has(k));
|
|
|
|
// CSS
|
|
const viewStyleKeys = views.map((view) => view.styleKey);
|
|
const viewCssBundles = await Promise.all(
|
|
viewStyleKeys.map((key) =>
|
|
io.readWork(UNWRAP(styleMap.get(key), "Style key: " + key))
|
|
),
|
|
);
|
|
const styleList = Array.from(new Set(viewCssBundles));
|
|
|
|
// Replace the magic word
|
|
const text = bytes
|
|
.toString("utf-8")
|
|
.replace(new RegExp(magicWord + "\\[(-?\\d+)\\]", "gs"), (_, i) => {
|
|
i = Number(i);
|
|
// Inline the styling data
|
|
if (i === -2) {
|
|
return JSON.stringify(styleList.map((cssText) => cssText));
|
|
}
|
|
// Inline the script data
|
|
if (i === -1) {
|
|
return JSON.stringify(Object.fromEntries(neededScripts));
|
|
}
|
|
// Reference an index into `styleList`
|
|
return `${styleList.indexOf(UNWRAP(viewCssBundles[i]))}`;
|
|
});
|
|
|
|
io.writeFile(basename, text);
|
|
});
|
|
});
|
|
|
|
await Promise.all(wProcessed);
|
|
}
|
|
|
|
import * as esbuild from "esbuild";
|
|
import * as path from "node:path";
|
|
import process from "node:process";
|
|
import * as hot from "./hot.ts";
|
|
import {
|
|
isIgnoredSource,
|
|
markoViaBuildCache,
|
|
projectRelativeResolution,
|
|
virtualFiles,
|
|
} from "./esbuild-support.ts";
|
|
import { Io, toAbs } from "./incremental.ts";
|
|
import * as fs from "#sitegen/fs";
|
|
import * as mime from "#sitegen/mime";
|
|
import * as incr from "./incremental.ts";
|
|
import * as sg from "#sitegen";
|
|
import type { PageOrView } from "./generate.ts";
|
|
import type * as view from "#sitegen/view";
|
|
import * as meta from "#sitegen/meta";
|