diff --git a/apps/cms/config/admin.ts b/apps/cms/config/admin.ts index 161ebce..0808ea3 100644 --- a/apps/cms/config/admin.ts +++ b/apps/cms/config/admin.ts @@ -1,24 +1,26 @@ -import type { Core } from '@strapi/strapi'; +import type { Core } from "@strapi/strapi"; -const config = ({ env }: Core.Config.Shared.ConfigParams): Core.Config.Admin => ({ +const config = ({ + env, +}: Core.Config.Shared.ConfigParams): Core.Config.Admin => ({ auth: { - secret: env('ADMIN_JWT_SECRET')!, + secret: env("ADMIN_JWT_SECRET")!, }, apiToken: { - salt: env('API_TOKEN_SALT')!, + salt: env("API_TOKEN_SALT")!, }, transfer: { token: { - salt: env('TRANSFER_TOKEN_SALT')!, + salt: env("TRANSFER_TOKEN_SALT")!, }, }, secrets: { - encryptionKey: env('ENCRYPTION_KEY')!, + encryptionKey: env("ENCRYPTION_KEY")!, }, flags: { - nps: env.bool('FLAG_NPS', true), - promoteEE: env.bool('FLAG_PROMOTE_EE', true), - docLinks: env.bool('FLAG_DOC_LINKS', true), + nps: env.bool("FLAG_NPS", true), + promoteEE: env.bool("FLAG_PROMOTE_EE", true), + docLinks: env.bool("FLAG_DOC_LINKS", true), }, }); diff --git a/apps/cms/config/api.ts b/apps/cms/config/api.ts index dded5ec..2679388 100644 --- a/apps/cms/config/api.ts +++ b/apps/cms/config/api.ts @@ -1,4 +1,4 @@ -import type { Core } from '@strapi/strapi'; +import type { Core } from "@strapi/strapi"; const config: Core.Config.Api = { rest: { diff --git a/apps/cms/config/database.ts b/apps/cms/config/database.ts index 39ce8fc..48cf699 100644 --- a/apps/cms/config/database.ts +++ b/apps/cms/config/database.ts @@ -1,61 +1,83 @@ -import path from 'path'; -import type { Core } from '@strapi/strapi'; -import { isDatabaseClientKind } from '@strapi/database'; +import { isDatabaseClientKind } from "@strapi/database"; +import type { Core } from "@strapi/strapi"; +import path from "path"; -const config = ({ env }: Core.Config.Shared.ConfigParams): Core.Config.Database => { - const client = env('DATABASE_CLIENT', 'sqlite'); +const config = ({ + env, +}: Core.Config.Shared.ConfigParams): Core.Config.Database => { + const client = env("DATABASE_CLIENT", "sqlite"); if (!isDatabaseClientKind(client)) { throw new Error( - `Unsupported DATABASE_CLIENT: ${client}. Use "postgres", "mysql", or "sqlite".` + `Unsupported DATABASE_CLIENT: ${client}. Use "postgres", "mysql", or "sqlite".`, ); } - const connections: Record = { + const connections: Record< + Core.Config.Database.ClientKind, + Core.Config.Database["connection"] + > = { mysql: { - client: 'mysql', + client: "mysql", connection: { - host: env('DATABASE_HOST', 'localhost'), - port: env.int('DATABASE_PORT', 3306), - database: env('DATABASE_NAME', 'strapi'), - user: env('DATABASE_USERNAME', 'strapi'), - password: env('DATABASE_PASSWORD', 'strapi'), - ssl: env.bool('DATABASE_SSL', false) && { - key: env('DATABASE_SSL_KEY', undefined), - cert: env('DATABASE_SSL_CERT', undefined), - ca: env('DATABASE_SSL_CA', undefined), - capath: env('DATABASE_SSL_CAPATH', undefined), - cipher: env('DATABASE_SSL_CIPHER', undefined), - rejectUnauthorized: env.bool('DATABASE_SSL_REJECT_UNAUTHORIZED', true), + host: env("DATABASE_HOST", "localhost"), + port: env.int("DATABASE_PORT", 3306), + database: env("DATABASE_NAME", "strapi"), + user: env("DATABASE_USERNAME", "strapi"), + password: env("DATABASE_PASSWORD", "strapi"), + ssl: env.bool("DATABASE_SSL", false) && { + key: env("DATABASE_SSL_KEY", undefined), + cert: env("DATABASE_SSL_CERT", undefined), + ca: env("DATABASE_SSL_CA", undefined), + capath: env("DATABASE_SSL_CAPATH", undefined), + cipher: env("DATABASE_SSL_CIPHER", undefined), + rejectUnauthorized: env.bool( + "DATABASE_SSL_REJECT_UNAUTHORIZED", + true, + ), }, }, - pool: { min: env.int('DATABASE_POOL_MIN', 2), max: env.int('DATABASE_POOL_MAX', 10) }, + pool: { + min: env.int("DATABASE_POOL_MIN", 2), + max: env.int("DATABASE_POOL_MAX", 10), + }, }, postgres: { - client: 'postgres', + client: "postgres", connection: { - connectionString: env('DATABASE_URL'), - host: env('DATABASE_HOST', 'localhost'), - port: env.int('DATABASE_PORT', 5432), - database: env('DATABASE_NAME', 'strapi'), - user: env('DATABASE_USERNAME', 'strapi'), - password: env('DATABASE_PASSWORD', 'strapi'), - ssl: env.bool('DATABASE_SSL', false) && { - key: env('DATABASE_SSL_KEY', undefined), - cert: env('DATABASE_SSL_CERT', undefined), - ca: env('DATABASE_SSL_CA', undefined), - capath: env('DATABASE_SSL_CAPATH', undefined), - cipher: env('DATABASE_SSL_CIPHER', undefined), - rejectUnauthorized: env.bool('DATABASE_SSL_REJECT_UNAUTHORIZED', true), + connectionString: env("DATABASE_URL"), + host: env("DATABASE_HOST", "localhost"), + port: env.int("DATABASE_PORT", 5432), + database: env("DATABASE_NAME", "strapi"), + user: env("DATABASE_USERNAME", "strapi"), + password: env("DATABASE_PASSWORD", "strapi"), + ssl: env.bool("DATABASE_SSL", false) && { + key: env("DATABASE_SSL_KEY", undefined), + cert: env("DATABASE_SSL_CERT", undefined), + ca: env("DATABASE_SSL_CA", undefined), + capath: env("DATABASE_SSL_CAPATH", undefined), + cipher: env("DATABASE_SSL_CIPHER", undefined), + rejectUnauthorized: env.bool( + "DATABASE_SSL_REJECT_UNAUTHORIZED", + true, + ), }, - schema: env('DATABASE_SCHEMA', 'public'), + schema: env("DATABASE_SCHEMA", "public"), + }, + pool: { + min: env.int("DATABASE_POOL_MIN", 2), + max: env.int("DATABASE_POOL_MAX", 10), }, - pool: { min: env.int('DATABASE_POOL_MIN', 2), max: env.int('DATABASE_POOL_MAX', 10) }, }, sqlite: { - client: 'sqlite', + client: "sqlite", connection: { - filename: path.join(__dirname, '..', '..', env('DATABASE_FILENAME', '.tmp/data.db')), + filename: path.join( + __dirname, + "..", + "..", + env("DATABASE_FILENAME", ".tmp/data.db"), + ), }, useNullAsDefault: true, }, @@ -64,7 +86,7 @@ const config = ({ env }: Core.Config.Shared.ConfigParams): Core.Config.Database return { connection: { ...connections[client], - acquireConnectionTimeout: env.int('DATABASE_CONNECTION_TIMEOUT', 60000), + acquireConnectionTimeout: env.int("DATABASE_CONNECTION_TIMEOUT", 60000), }, }; }; diff --git a/apps/cms/config/middlewares.ts b/apps/cms/config/middlewares.ts index 669918f..e179108 100644 --- a/apps/cms/config/middlewares.ts +++ b/apps/cms/config/middlewares.ts @@ -1,16 +1,16 @@ -import type { Core } from '@strapi/strapi'; +import type { Core } from "@strapi/strapi"; const config: Core.Config.Middlewares = [ - 'strapi::logger', - 'strapi::errors', - 'strapi::security', - 'strapi::cors', - 'strapi::poweredBy', - 'strapi::query', - 'strapi::body', - 'strapi::session', - 'strapi::favicon', - 'strapi::public', + "strapi::logger", + "strapi::errors", + "strapi::security", + "strapi::cors", + "strapi::poweredBy", + "strapi::query", + "strapi::body", + "strapi::session", + "strapi::favicon", + "strapi::public", ]; export default config; diff --git a/apps/cms/config/plugins.ts b/apps/cms/config/plugins.ts index b7da4db..254cb8a 100644 --- a/apps/cms/config/plugins.ts +++ b/apps/cms/config/plugins.ts @@ -1,32 +1,34 @@ -import type { Core } from '@strapi/strapi'; +import type { Core } from "@strapi/strapi"; const allowedMediaTypes = [ - 'image/*', - 'video/*', - 'audio/*', - 'application/pdf', - 'application/msword', - 'application/vnd.openxmlformats-officedocument.*', - 'text/plain', - 'text/csv', + "image/*", + "video/*", + "audio/*", + "application/pdf", + "application/msword", + "application/vnd.openxmlformats-officedocument.*", + "text/plain", + "text/csv", ]; const deniedTypes = [ - 'image/svg+xml', - 'application/vnd.microsoft.portable-executable', - 'application/x-msdownload', - 'application/x-msdos-program', - 'application/x-executable', - 'application/x-dosexec', - 'application/x-sh', - 'text/x-shellscript', - 'application/x-mach-binary', + "image/svg+xml", + "application/vnd.microsoft.portable-executable", + "application/x-msdownload", + "application/x-msdos-program", + "application/x-executable", + "application/x-dosexec", + "application/x-sh", + "text/x-shellscript", + "application/x-mach-binary", ]; -const config = ({ env }: Core.Config.Shared.ConfigParams): Core.Config.Plugin => ({ - 'users-permissions': { +const config = ({ + env, +}: Core.Config.Shared.ConfigParams): Core.Config.Plugin => ({ + "users-permissions": { config: { - jwtManagement: 'refresh', + jwtManagement: "refresh", sessions: { httpOnly: true, }, diff --git a/apps/cms/config/server.ts b/apps/cms/config/server.ts index e0e4fa5..2edea97 100644 --- a/apps/cms/config/server.ts +++ b/apps/cms/config/server.ts @@ -1,13 +1,15 @@ -import type { Core } from '@strapi/strapi'; +import type { Core } from "@strapi/strapi"; -const config = ({ env }: Core.Config.Shared.ConfigParams): Core.Config.Server => ({ - host: env('HOST', '0.0.0.0'), - port: env.int('PORT', 1337), +const config = ({ + env, +}: Core.Config.Shared.ConfigParams): Core.Config.Server => ({ + host: env("HOST", "0.0.0.0"), + port: env.int("PORT", 1337), app: { - keys: env.array('APP_KEYS')!, + keys: env.array("APP_KEYS")!, }, webhooks: { - populateRelations: env.bool('WEBHOOKS_POPULATE_RELATIONS', false), + populateRelations: env.bool("WEBHOOKS_POPULATE_RELATIONS", false), }, }); diff --git a/apps/cms/src/admin/app.example.tsx b/apps/cms/src/admin/app.example.tsx index ae50db5..865a889 100644 --- a/apps/cms/src/admin/app.example.tsx +++ b/apps/cms/src/admin/app.example.tsx @@ -1,4 +1,4 @@ -import type { StrapiApp } from '@strapi/strapi/admin'; +import type { StrapiApp } from "@strapi/strapi/admin"; export default { config: { diff --git a/apps/cms/src/admin/vite.config.example.ts b/apps/cms/src/admin/vite.config.example.ts index 85f6982..eabb96f 100644 --- a/apps/cms/src/admin/vite.config.example.ts +++ b/apps/cms/src/admin/vite.config.example.ts @@ -1,11 +1,11 @@ -import { mergeConfig, type UserConfig } from 'vite'; +import { mergeConfig, type UserConfig } from "vite"; export default (config: UserConfig) => { // Important: always return the modified config return mergeConfig(config, { resolve: { alias: { - '@': '/src', + "@": "/src", }, }, }); diff --git a/apps/site/astro.config.mjs b/apps/site/astro.config.mjs index e762ba5..17f6a62 100644 --- a/apps/site/astro.config.mjs +++ b/apps/site/astro.config.mjs @@ -1,5 +1,5 @@ // @ts-check -import { defineConfig } from 'astro/config'; +import { defineConfig } from "astro/config"; // https://astro.build/config export default defineConfig({}); diff --git a/apps/site/package.json b/apps/site/package.json index 16eb74c..81c7ceb 100644 --- a/apps/site/package.json +++ b/apps/site/package.json @@ -17,4 +17,4 @@ "allowScripts": { "esbuild": true } -} \ No newline at end of file +} diff --git a/biome.json b/biome.json index 03c42b2..f9a9ee2 100644 --- a/biome.json +++ b/biome.json @@ -7,7 +7,13 @@ }, "files": { "ignoreUnknown": true, - "includes": ["apps/**", "scripts/**", "!apps/**/dist/**", "!apps/**/.strapi/**", "!apps/**/public/**"] + "includes": [ + "apps/**", + "scripts/**", + "!apps/**/dist", + "!apps/**/.strapi", + "!apps/**/public" + ] }, "formatter": { "enabled": true, diff --git a/scripts/generate-llms.mjs b/scripts/generate-llms.mjs index 5bcf96f..bdc0052 100644 --- a/scripts/generate-llms.mjs +++ b/scripts/generate-llms.mjs @@ -1,13 +1,15 @@ -import fs from 'node:fs'; -import path from 'node:path'; +import fs from "node:fs"; +import path from "node:path"; const root = process.cwd(); -const config = fs.readFileSync(path.join(root, 'zola.toml'), 'utf8'); -const postsDir = path.join(root, 'content', 'posts'); -const output = path.join(root, 'static', 'llms.txt'); +const config = fs.readFileSync(path.join(root, "zola.toml"), "utf8"); +const postsDir = path.join(root, "content", "posts"); +const output = path.join(root, "static", "llms.txt"); function tomlString(source, key) { - const match = source.match(new RegExp(`^${key}\\s*=\\s*("(?:[^"\\\\]|\\\\.)*")`, 'm')); + const match = source.match( + new RegExp(`^${key}\\s*=\\s*("(?:[^"\\\\]|\\\\.)*")`, "m"), + ); return match ? JSON.parse(match[1]) : undefined; } @@ -19,31 +21,32 @@ function markdownFiles(directory) { return fs.readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { const location = path.join(directory, entry.name); if (entry.isDirectory()) return markdownFiles(location); - return entry.isFile() && entry.name.endsWith('.md') ? [location] : []; + return entry.isFile() && entry.name.endsWith(".md") ? [location] : []; }); } function postUrl(file, metadata, baseUrl) { - const relative = path.relative(postsDir, file).replaceAll(path.sep, '/'); - const defaultSlug = relative.endsWith('/index.md') - ? relative.slice(0, -'/index.md'.length) - : relative.slice(0, -'.md'.length); - const slug = tomlString(metadata, 'slug') ?? defaultSlug; + const relative = path.relative(postsDir, file).replaceAll(path.sep, "/"); + const defaultSlug = relative.endsWith("/index.md") + ? relative.slice(0, -"/index.md".length) + : relative.slice(0, -".md".length); + const slug = tomlString(metadata, "slug") ?? defaultSlug; return `${baseUrl}/posts/${slug}/`; } -const baseUrl = tomlString(config, 'base_url')?.replace(/\/$/, ''); +const baseUrl = tomlString(config, "base_url")?.replace(/\/$/, ""); const siteTitle = new URL(baseUrl).hostname; -const summary = tomlString(config, 'llms_description') ?? tomlString(config, 'description'); +const summary = + tomlString(config, "llms_description") ?? tomlString(config, "description"); const posts = markdownFiles(postsDir) - .filter((file) => path.basename(file) !== '_index.md') + .filter((file) => path.basename(file) !== "_index.md") .map((file) => { - const metadata = frontMatter(fs.readFileSync(file, 'utf8')); + const metadata = frontMatter(fs.readFileSync(file, "utf8")); if (!metadata || /^draft\s*=\s*true$/m.test(metadata)) return null; - const title = tomlString(metadata, 'title'); - const description = tomlString(metadata, 'description'); + const title = tomlString(metadata, "title"); + const description = tomlString(metadata, "description"); const date = metadata.match(/^date\s*=\s*(\d{4}-\d{2}-\d{2})/m)?.[1]; if (!title || !date) return null; @@ -54,32 +57,35 @@ const posts = markdownFiles(postsDir) const lines = [ `# ${siteTitle}`, - '', + "", `> ${summary}`, - '', - 'This site contains articles in English, often with commands, code samples, worked explanations, and material aimed at readers learning by doing.', - '', - '## Main pages', - '', + "", + "This site contains articles in English, often with commands, code samples, worked explanations, and material aimed at readers learning by doing.", + "", + "## Main pages", + "", `- [Home](${baseUrl}/): Introduction and latest articles.`, `- [About](${baseUrl}/about/): About the author and contact information.`, `- [Posts](${baseUrl}/posts/): All published articles.`, `- [Tags](${baseUrl}/tags/): Articles grouped by topic.`, `- [Archive](${baseUrl}/archive/): Chronological article archive.`, - '', - '## Published articles', - '', - ...posts.map(({ title, description, url }) => - `- [${title}](${url})${description ? `: ${description.replace(/\s+/g, ' ').trim()}` : ''}`, + "", + "## Published articles", + "", + ...posts.map( + ({ title, description, url }) => + `- [${title}](${url})${description ? `: ${description.replace(/\s+/g, " ").trim()}` : ""}`, ), - '', - '## Feeds and discovery', - '', + "", + "## Feeds and discovery", + "", `- [RSS feed](${baseUrl}/rss.xml): Full site feed in RSS format.`, `- [JSON feed](${baseUrl}/feed.json): Full site feed in JSON format.`, `- [Sitemap](${baseUrl}/sitemap.xml): Machine-readable index of public pages.`, - '', + "", ]; -fs.writeFileSync(output, lines.join('\n')); -console.log(`Generated ${path.relative(root, output)} with ${posts.length} published articles.`); +fs.writeFileSync(output, lines.join("\n")); +console.log( + `Generated ${path.relative(root, output)} with ${posts.length} published articles.`, +); diff --git a/scripts/generate-og-images.mjs b/scripts/generate-og-images.mjs index 238fe5e..05f4020 100644 --- a/scripts/generate-og-images.mjs +++ b/scripts/generate-og-images.mjs @@ -1,30 +1,36 @@ -import fs from 'node:fs/promises'; -import path from 'node:path'; -import satori from 'satori'; -import { Resvg } from '@resvg/resvg-js'; -import { decompress } from 'wawoff2'; +import fs from "node:fs/promises"; +import path from "node:path"; +import { Resvg } from "@resvg/resvg-js"; +import satori from "satori"; +import { decompress } from "wawoff2"; const root = process.cwd(); -const postsDir = path.join(root, 'content', 'posts'); -const outDir = path.join(root, 'static', 'images', 'og'); +const postsDir = path.join(root, "content", "posts"); +const outDir = path.join(root, "static", "images", "og"); const COLORS = { - bg: '#131311', - text: '#e9e7de', - muted: '#8b8a80', - accent: '#ff5a46', - border: 'rgba(233, 231, 222, 0.16)', + bg: "#131311", + text: "#e9e7de", + muted: "#8b8a80", + accent: "#ff5a46", + border: "rgba(233, 231, 222, 0.16)", }; function tomlString(source, key) { - const match = source.match(new RegExp(`^${key}\\s*=\\s*("(?:[^"\\\\]|\\\\.)*")`, 'm')); + const match = source.match( + new RegExp(`^${key}\\s*=\\s*("(?:[^"\\\\]|\\\\.)*")`, "m"), + ); return match ? JSON.parse(match[1]) : undefined; } function tomlStringArray(source, key) { - const match = source.match(new RegExp(`^${key}\\s*=\\s*\\[([^\\]]*)\\]`, 'm')); + const match = source.match( + new RegExp(`^${key}\\s*=\\s*\\[([^\\]]*)\\]`, "m"), + ); if (!match) return []; - return [...match[1].matchAll(/"((?:[^"\\]|\\.)*)"/g)].map((m) => JSON.parse(`"${m[1]}"`)); + return [...match[1].matchAll(/"((?:[^"\\]|\\.)*)"/g)].map((m) => + JSON.parse(`"${m[1]}"`), + ); } function frontMatter(source) { @@ -32,7 +38,9 @@ function frontMatter(source) { } function taxonomies(source) { - return source.match(/^\[taxonomies\]\s*\n([\s\S]*?)(?:\n\[|\n?$)/m)?.[1] ?? ''; + return ( + source.match(/^\[taxonomies\]\s*\n([\s\S]*?)(?:\n\[|\n?$)/m)?.[1] ?? "" + ); } function markdownFiles(directory) { @@ -41,103 +49,117 @@ function markdownFiles(directory) { entries.map((entry) => { const location = path.join(directory, entry.name); if (entry.isDirectory()) return markdownFiles(location); - return entry.isFile() && entry.name.endsWith('.md') ? [location] : []; + return entry.isFile() && entry.name.endsWith(".md") ? [location] : []; }), ).then((lists) => lists.flat()), ); } function slugFor(file, metadata) { - const relative = path.relative(postsDir, file).replaceAll(path.sep, '/'); - const defaultSlug = relative.endsWith('/index.md') - ? relative.slice(0, -'/index.md'.length) - : relative.slice(0, -'.md'.length); - return tomlString(metadata, 'slug') ?? defaultSlug; + const relative = path.relative(postsDir, file).replaceAll(path.sep, "/"); + const defaultSlug = relative.endsWith("/index.md") + ? relative.slice(0, -"/index.md".length) + : relative.slice(0, -".md".length); + return tomlString(metadata, "slug") ?? defaultSlug; } async function loadFont(relPath) { - const woff2 = await fs.readFile(path.join(root, 'static', relPath)); + const woff2 = await fs.readFile(path.join(root, "static", relPath)); const ttf = await decompress(woff2); return Buffer.from(ttf); } function h(type, props, ...children) { - return { type, props: { ...props, children: children.flat().filter((c) => c !== null && c !== undefined) } }; + return { + type, + props: { + ...props, + children: children.flat().filter((c) => c !== null && c !== undefined), + }, + }; } function formatDate(date) { - return new Date(`${date}T00:00:00Z`).toLocaleDateString('en-US', { - year: 'numeric', - month: 'long', - day: 'numeric', - timeZone: 'UTC', + return new Date(`${date}T00:00:00Z`).toLocaleDateString("en-US", { + year: "numeric", + month: "long", + day: "numeric", + timeZone: "UTC", }); } function card({ title, date, tags }) { const stampTag = tags[0]; - const footerTags = tags.length > 4 ? `${tags.slice(0, 4).join(' · ')} ⋯` : tags.join(' · '); + const footerTags = + tags.length > 4 ? `${tags.slice(0, 4).join(" · ")} ⋯` : tags.join(" · "); const titleSize = title.length > 70 ? 46 : title.length > 45 ? 54 : 64; return h( - 'div', + "div", { style: { - width: '1200px', - height: '630px', - display: 'flex', + width: "1200px", + height: "630px", + display: "flex", backgroundColor: COLORS.bg, color: COLORS.text, - fontFamily: 'IBM Plex Serif', + fontFamily: "IBM Plex Serif", }, }, h( - 'div', + "div", { style: { flex: 1, - margin: '40px', + margin: "40px", border: `1px solid ${COLORS.border}`, - borderRadius: '6px', - padding: '48px 56px', - display: 'flex', - flexDirection: 'column', - justifyContent: 'space-between', + borderRadius: "6px", + padding: "48px 56px", + display: "flex", + flexDirection: "column", + justifyContent: "space-between", }, }, h( - 'div', - { style: { display: 'flex', flexDirection: 'row', justifyContent: 'space-between', alignItems: 'flex-start' } }, + "div", + { + style: { + display: "flex", + flexDirection: "row", + justifyContent: "space-between", + alignItems: "flex-start", + }, + }, h( - 'div', + "div", { style: { - display: 'flex', - fontFamily: 'JetBrains Mono', + display: "flex", + fontFamily: "JetBrains Mono", fontWeight: 400, - fontSize: '22px', + fontSize: "22px", color: COLORS.muted, - letterSpacing: '2px', + letterSpacing: "2px", }, }, - 'ANDWATI.COM · CASE FILE', + "ANDWATI.COM · CASE FILE", ), stampTag ? h( - 'div', + "div", { style: { - display: 'flex', + display: "flex", border: `2px solid ${COLORS.accent}`, - borderRadius: '4px', - padding: '8px 16px', + borderRadius: "4px", + padding: "8px 16px", color: COLORS.accent, - fontFamily: 'JetBrains Mono', + fontFamily: "JetBrains Mono", fontWeight: 700, - fontSize: '20px', - letterSpacing: '2px', - textTransform: 'uppercase', - transform: 'rotate(3deg)', + fontSize: "20px", + letterSpacing: "2px", + textTransform: "uppercase", + transform: "rotate(3deg)", }, }, stampTag, @@ -145,57 +167,57 @@ function card({ title, date, tags }) { : null, ), h( - 'div', + "div", { style: { - display: 'flex', - fontFamily: 'IBM Plex Serif', + display: "flex", + fontFamily: "IBM Plex Serif", fontWeight: 700, fontSize: `${titleSize}px`, lineHeight: 1.15, - letterSpacing: '-1px', + letterSpacing: "-1px", color: COLORS.text, - overflow: 'hidden', + overflow: "hidden", }, }, title, ), h( - 'div', + "div", { style: { - display: 'flex', - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', + display: "flex", + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", borderTop: `1px solid ${COLORS.border}`, - paddingTop: '24px', + paddingTop: "24px", }, }, h( - 'div', + "div", { style: { - display: 'flex', - fontFamily: 'JetBrains Mono', + display: "flex", + fontFamily: "JetBrains Mono", fontWeight: 400, - fontSize: '20px', + fontSize: "20px", color: COLORS.muted, - letterSpacing: '1px', + letterSpacing: "1px", }, }, formatDate(date), ), h( - 'div', + "div", { style: { - display: 'flex', - fontFamily: 'JetBrains Mono', + display: "flex", + fontFamily: "JetBrains Mono", fontWeight: 400, - fontSize: '20px', + fontSize: "20px", color: COLORS.muted, - letterSpacing: '1px', + letterSpacing: "1px", }, }, footerTags, @@ -206,32 +228,57 @@ function card({ title, date, tags }) { } const fonts = [ - { name: 'IBM Plex Serif', data: await loadFont('fonts/ibm-plex-serif/ibm-plex-serif-v20-latin-700.woff2'), weight: 700, style: 'normal' }, - { name: 'JetBrains Mono', data: await loadFont('fonts/jetbrains-mono/JetBrainsMono-Regular.woff2'), weight: 400, style: 'normal' }, - { name: 'JetBrains Mono', data: await loadFont('fonts/jetbrains-mono/JetBrainsMono-Bold.woff2'), weight: 700, style: 'normal' }, + { + name: "IBM Plex Serif", + data: await loadFont( + "fonts/ibm-plex-serif/ibm-plex-serif-v20-latin-700.woff2", + ), + weight: 700, + style: "normal", + }, + { + name: "JetBrains Mono", + data: await loadFont("fonts/jetbrains-mono/JetBrainsMono-Regular.woff2"), + weight: 400, + style: "normal", + }, + { + name: "JetBrains Mono", + data: await loadFont("fonts/jetbrains-mono/JetBrainsMono-Bold.woff2"), + weight: 700, + style: "normal", + }, ]; -const files = (await markdownFiles(postsDir)).filter((file) => path.basename(file) !== '_index.md'); +const files = (await markdownFiles(postsDir)).filter( + (file) => path.basename(file) !== "_index.md", +); await fs.mkdir(outDir, { recursive: true }); let count = 0; for (const file of files) { - const source = await fs.readFile(file, 'utf8'); + const source = await fs.readFile(file, "utf8"); const metadata = frontMatter(source); if (!metadata || /^draft\s*=\s*true$/m.test(metadata)) continue; - const title = tomlString(metadata, 'title'); + const title = tomlString(metadata, "title"); const date = metadata.match(/^date\s*=\s*(\d{4}-\d{2}-\d{2})/m)?.[1]; if (!title || !date) continue; - const tags = tomlStringArray(taxonomies(metadata), 'tags'); + const tags = tomlStringArray(taxonomies(metadata), "tags"); const slug = slugFor(file, metadata); - const svg = await satori(card({ title, date, tags }), { width: 1200, height: 630, fonts }); + const svg = await satori(card({ title, date, tags }), { + width: 1200, + height: 630, + fonts, + }); const png = new Resvg(svg).render().asPng(); await fs.writeFile(path.join(outDir, `${slug}.png`), png); count += 1; } -console.log(`Generated ${count} OG image${count === 1 ? '' : 's'} in ${path.relative(root, outDir)}/`); +console.log( + `Generated ${count} OG image${count === 1 ? "" : "s"} in ${path.relative(root, outDir)}/`, +); diff --git a/scripts/validate-post.mjs b/scripts/validate-post.mjs index 4b776e2..2332969 100755 --- a/scripts/validate-post.mjs +++ b/scripts/validate-post.mjs @@ -1,16 +1,19 @@ -import fs from 'node:fs'; -import path from 'node:path'; -import { execSync } from 'node:child_process'; +import { execSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; // Get staged files from git -const stagedFiles = execSync('git diff --cached --name-only --diff-filter=ACMR') +const stagedFiles = execSync("git diff --cached --name-only --diff-filter=ACMR") .toString() - .split('\n') + .split("\n") .filter(Boolean); // Filter to only new or modified posts const postFiles = stagedFiles.filter( - (file) => file.startsWith('content/posts/') && file.endsWith('.md') && !file.endsWith('_index.md'), + (file) => + file.startsWith("content/posts/") && + file.endsWith(".md") && + !file.endsWith("_index.md"), ); if (postFiles.length === 0) { @@ -20,21 +23,21 @@ if (postFiles.length === 0) { let hasError = false; for (const file of postFiles) { - const basename = path.basename(file, '.md'); - + const basename = path.basename(file, ".md"); + // 1. Slug validation (kebab-case: lowercase letters, numbers, and hyphens) - if (!/^[a-z0-9\-]+$/.test(basename)) { + if (!/^[a-z0-9-]+$/.test(basename)) { console.error(`[ERROR] in ${file}:`); console.error(` Filename must be kebab-case (e.g. 'my-cool-post.md').`); hasError = true; } // 2. Front-matter validation - const content = fs.readFileSync(file, 'utf8'); - + const content = fs.readFileSync(file, "utf8"); + // Zola uses +++ for TOML front-matter const frontMatterMatch = content.match(/^\+\+\+([\s\S]*?)\+\+\+/); - + if (!frontMatterMatch) { console.error(`[ERROR] in ${file}:`); console.error(` Missing TOML front-matter (must be enclosed in +++).`); @@ -43,26 +46,32 @@ for (const file of postFiles) { } const frontMatter = frontMatterMatch[1]; - + const hasTitle = /^title\s*=\s*".+"/m.test(frontMatter); const hasDate = /^date\s*=\s*\d{4}-\d{2}-\d{2}/m.test(frontMatter); if (!hasTitle) { console.error(`[ERROR] in ${file}:`); - console.error(` Missing 'title' field in front-matter (e.g. title = "My Post").`); + console.error( + ` Missing 'title' field in front-matter (e.g. title = "My Post").`, + ); hasError = true; } - + if (!hasDate) { console.error(`[ERROR] in ${file}:`); - console.error(` Missing 'date' field in front-matter (e.g. date = 2024-01-01).`); + console.error( + ` Missing 'date' field in front-matter (e.g. date = 2024-01-01).`, + ); hasError = true; } } if (hasError) { - console.error('\n[FATAL] Commit rejected due to validation errors. Please fix the issues above and try again.\n'); + console.error( + "\n[FATAL] Commit rejected due to validation errors. Please fix the issues above and try again.\n", + ); process.exit(1); } else { - console.log('[SUCCESS] Post validation passed!'); + console.log("[SUCCESS] Post validation passed!"); }