muddle/views/integrations.ts
2026-10-23 09:12:49 -04:00

156 lines
4.3 KiB
TypeScript

import { readdir, readFile } from "node:fs/promises";
import { existsSync } from "node:fs";
import { join, resolve } from "node:path";
import type {
ContentSchema,
Integration,
IntegrationContext,
} from "../contracts/index.ts";
import { compileCss, type CompileOptions } from "./css/index.ts";
import { markdownLoader } from "./markdown.ts";
import { sassLoader, type SassLoaderOptions } from "./css/sass.ts";
export {
shadcnIntegration,
type ShadcnIntegrationOptions,
} from "../integrations/shadcn.ts";
export interface MarkdownIntegrationOptions {
/** Name of the collection, defaults to `notes`. */
name?: string;
/** Directory of the collection, relative to the project root. */
dir?: string;
/** Schema of the frontmatter. */
schema?: ContentSchema;
}
/**
* Markdown integration.
*
* The integration registers the markdown loader and adds the collection of
* the content directory to the project.
*/
export function markdownIntegration(
options: MarkdownIntegrationOptions = {},
): Integration {
const name = options.name ?? "notes";
const dir = options.dir ?? "content/notes";
return {
name: "markdown",
setup(context: IntegrationContext) {
context.loader("markdown", markdownLoader());
const collections = context.config.collections ?? [];
if (collections.some((collection) => collection.name === name)) return;
collections.push({
name,
dir,
loader: "markdown",
schema: options.schema,
});
context.config.collections = collections;
},
};
}
export interface StcssIntegrationOptions extends CompileOptions {
/** Directory of the stylesheets, relative to the project root. */
dir?: string;
/** Route the compiled css is served at. */
route?: string;
}
/**
* Css integration.
*
* The integration compiles the structured stylesheets of the project and
* serves them at a single route, the browser caches the result.
*/
export function stcssIntegration(
options: StcssIntegrationOptions = {},
): Integration {
const route = options.route ?? "/_muddle/styles.css";
return {
name: "stcss",
async setup(context: IntegrationContext) {
context.loader("stcss", {
name: "stcss",
extensions: [".stcss"],
load(file) {
return { data: {}, content: compileCss(file.source, options) };
},
});
const dir = resolve(context.config.root, options.dir ?? "styles");
if (!existsSync(dir)) return;
const files = (await readdir(dir)).filter((file) =>
file.endsWith(".stcss"),
);
const parts: string[] = [];
for (const file of files.sort()) {
const source = await readFile(join(dir, file), "utf8");
parts.push(compileCss(source, options));
}
const css = parts.join("\n");
context.route(
"GET",
route,
() =>
new Response(css, {
headers: {
"content-type": "text/css; charset=utf-8",
"cache-control": "public, max-age=3600",
},
}),
);
},
};
}
/** Registers Sass files as loaders and serves their compiled CSS alongside STCSS. */
export function sassIntegration(options: SassLoaderOptions = {}): Integration {
const route = "/_muddle/sass.css";
return {
name: "sass",
async setup(context) {
const loader = sassLoader(options);
context.loader("sass", loader);
const dir = resolve(context.config.root, options.dir ?? "styles");
if (!existsSync(dir)) return;
const files = await sassFiles(dir);
context.route("GET", route, async () => {
const css = await Promise.all(
files
.filter((file) => !file.split("/").at(-1)?.startsWith("_"))
.map(
async (file) =>
(
await loader.load({
path: join(dir, file),
relativePath: `styles/${file}`,
source: await readFile(join(dir, file), "utf8"),
})
).content ?? "",
),
);
return new Response(css.join("\n"), {
headers: { "content-type": "text/css; charset=utf-8" },
});
});
},
};
}
async function sassFiles(dir: string, prefix = ""): Promise<string[]> {
const files: string[] = [];
for (const entry of await readdir(dir, { withFileTypes: true })) {
const relative = `${prefix}${entry.name}`;
if (entry.isDirectory())
files.push(...(await sassFiles(join(dir, entry.name), `${relative}/`)));
else if (/\.s[ac]ss$/i.test(entry.name)) files.push(relative);
}
return files.sort();
}