mirror of
https://github.com/andwati/andwati.com.git
synced 2026-10-18 01:12:53 +00:00
Wire Strapi to content/ via a two-way file sync provider
Adds slug (uid) fields to all four content types so Strapi documents can be matched to files deterministically, plus: - apps/cms/src/utils/content-file.ts: read/write/delete the +++ TOML frontmatter files, using smol-toml's TomlDate so dates round-trip as bare local dates (matching the legacy Zola format), not quoted datetime strings. - apps/cms/src/utils/content-sync.ts: per-type frontmatter <-> Strapi field mappings, a boot-time importer that upserts every content/ file into Strapi by slug, and a Document Service middleware that writes any create/update/delete made through Strapi straight back to the same files. Strapi's SQLite DB is now genuinely a disposable cache: content/ stays buildable and readable with Strapi entirely absent, and restarting Strapi rebuilds its DB from content/ with no duplicates (matched by slug). Verified end-to-end: fresh boot imports all seed files once; a second boot re-imports idempotently (0 created, all matched by slug); create/ update/delete through the content-manager API all correctly write to and remove the corresponding file on disk.
This commit is contained in:
@@ -23,6 +23,7 @@
|
||||
"react": "^18.0.0",
|
||||
"react-dom": "^18.0.0",
|
||||
"react-router-dom": "^6.30.3",
|
||||
"smol-toml": "^1.8.0",
|
||||
"styled-components": "^6.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -15,6 +15,12 @@
|
||||
"type": "string",
|
||||
"required": true
|
||||
},
|
||||
"slug": {
|
||||
"type": "uid",
|
||||
"targetField": "title",
|
||||
"required": true,
|
||||
"description": "Matches the file name under content/bookshelf/"
|
||||
},
|
||||
"kind": {
|
||||
"type": "enumeration",
|
||||
"enum": ["book", "paper"],
|
||||
|
||||
@@ -15,6 +15,12 @@
|
||||
"type": "string",
|
||||
"required": true
|
||||
},
|
||||
"slug": {
|
||||
"type": "uid",
|
||||
"targetField": "title",
|
||||
"required": true,
|
||||
"description": "Matches the file name under content/blogs/"
|
||||
},
|
||||
"url": {
|
||||
"type": "string",
|
||||
"required": true
|
||||
|
||||
@@ -15,6 +15,12 @@
|
||||
"type": "string",
|
||||
"required": true
|
||||
},
|
||||
"slug": {
|
||||
"type": "uid",
|
||||
"targetField": "title",
|
||||
"required": true,
|
||||
"description": "Matches the file name under content/portfolio/"
|
||||
},
|
||||
"description": {
|
||||
"type": "text"
|
||||
},
|
||||
|
||||
@@ -15,6 +15,12 @@
|
||||
"type": "string",
|
||||
"required": true
|
||||
},
|
||||
"slug": {
|
||||
"type": "uid",
|
||||
"targetField": "title",
|
||||
"required": true,
|
||||
"description": "Matches the file/directory name under content/writings/"
|
||||
},
|
||||
"description": {
|
||||
"type": "text"
|
||||
},
|
||||
|
||||
+14
-14
@@ -1,20 +1,20 @@
|
||||
// import type { Core } from '@strapi/strapi';
|
||||
import type { Core } from "@strapi/strapi";
|
||||
import {
|
||||
importContentFromDisk,
|
||||
registerContentSync,
|
||||
} from "./utils/content-sync";
|
||||
|
||||
export default {
|
||||
/**
|
||||
* An asynchronous register function that runs before
|
||||
* your application is initialized.
|
||||
*
|
||||
* This gives you an opportunity to extend code.
|
||||
*/
|
||||
register(/* { strapi }: { strapi: Core.Strapi } */) {},
|
||||
register() {},
|
||||
|
||||
/**
|
||||
* An asynchronous bootstrap function that runs before
|
||||
* your application gets started.
|
||||
*
|
||||
* This gives you an opportunity to set up your data model,
|
||||
* run jobs, or perform some special logic.
|
||||
* content/ is the source of truth (see content/README.md). On boot we
|
||||
* import every file into Strapi's DB (a disposable cache), then register
|
||||
* a Document Service middleware that writes any create/update/delete made
|
||||
* through Strapi straight back to the same files.
|
||||
*/
|
||||
bootstrap(/* { strapi }: { strapi: Core.Strapi } */) {},
|
||||
async bootstrap({ strapi }: { strapi: Core.Strapi }) {
|
||||
registerContentSync(strapi as never);
|
||||
await importContentFromDisk(strapi as never);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import {
|
||||
mkdirSync,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { dirname, join, relative } from "node:path";
|
||||
import {
|
||||
parse as parseToml,
|
||||
stringify as stringifyToml,
|
||||
TomlDate,
|
||||
} from "smol-toml";
|
||||
|
||||
const FRONTMATTER_RE = /^\+\+\+\r?\n([\s\S]*?)\r?\n\+\+\+\r?\n?([\s\S]*)$/;
|
||||
|
||||
export interface ParsedEntryFile {
|
||||
slug: string;
|
||||
frontmatter: Record<string, unknown>;
|
||||
body: string;
|
||||
}
|
||||
|
||||
/** Strips undefined/null/empty-string/empty-array values so smol-toml doesn't choke on them. */
|
||||
export function compact<T extends Record<string, unknown>>(
|
||||
obj: T,
|
||||
): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
if (value === undefined || value === null || value === "") continue;
|
||||
if (Array.isArray(value) && value.length === 0) continue;
|
||||
if (
|
||||
typeof value === "object" &&
|
||||
!Array.isArray(value) &&
|
||||
!(value instanceof Date)
|
||||
) {
|
||||
const nested = compact(value as Record<string, unknown>);
|
||||
if (Object.keys(nested).length === 0) continue;
|
||||
out[key] = nested;
|
||||
continue;
|
||||
}
|
||||
out[key] = value;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Converts a Strapi "date" field (an ISO `YYYY-MM-DD` string) to a bare TOML local-date. */
|
||||
export function toTomlDate(
|
||||
value: string | null | undefined,
|
||||
): TomlDate | undefined {
|
||||
return value ? new TomlDate(value) : undefined;
|
||||
}
|
||||
|
||||
function walk(dir: string): string[] {
|
||||
const out: string[] = [];
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = join(dir, entry.name);
|
||||
if (entry.isDirectory()) out.push(...walk(full));
|
||||
else if (entry.isFile() && full.endsWith(".md")) out.push(full);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function slugFromPath(dir: string, file: string): string {
|
||||
const rel = relative(dir, file).split("\\").join("/");
|
||||
return rel.endsWith("/index.md")
|
||||
? rel.slice(0, -"/index.md".length)
|
||||
: rel.slice(0, -".md".length);
|
||||
}
|
||||
|
||||
/** Reads every `+++`-frontmatter Markdown file under `dir` (flat or `<slug>/index.md`). */
|
||||
export function readAllEntries(dir: string): ParsedEntryFile[] {
|
||||
let files: string[];
|
||||
try {
|
||||
files = walk(dir);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
return files.flatMap((file) => {
|
||||
const raw = readFileSync(file, "utf8");
|
||||
const match = raw.match(FRONTMATTER_RE);
|
||||
if (!match) return [];
|
||||
const [, fm, body] = match;
|
||||
return [
|
||||
{
|
||||
slug: slugFromPath(dir, file),
|
||||
frontmatter: parseToml(fm),
|
||||
body: body.trim(),
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
function entryPath(
|
||||
dir: string,
|
||||
slug: string,
|
||||
layout: "nested" | "flat",
|
||||
): string {
|
||||
return layout === "nested"
|
||||
? join(dir, slug, "index.md")
|
||||
: join(dir, `${slug}.md`);
|
||||
}
|
||||
|
||||
/** Writes (or overwrites) the frontmatter+body file for `slug` under `dir`. */
|
||||
export function writeEntry(
|
||||
dir: string,
|
||||
slug: string,
|
||||
layout: "nested" | "flat",
|
||||
frontmatter: Record<string, unknown>,
|
||||
body: string,
|
||||
): void {
|
||||
const file = entryPath(dir, slug, layout);
|
||||
mkdirSync(dirname(file), { recursive: true });
|
||||
const toml = stringifyToml(compact(frontmatter)).trimEnd();
|
||||
writeFileSync(file, `+++\n${toml}\n+++\n\n${(body ?? "").trim()}\n`);
|
||||
}
|
||||
|
||||
/** Deletes the file (and, for nested layout, its now-empty directory) for `slug`. */
|
||||
export function deleteEntry(
|
||||
dir: string,
|
||||
slug: string,
|
||||
layout: "nested" | "flat",
|
||||
): void {
|
||||
const file = entryPath(dir, slug, layout);
|
||||
rmSync(file, { force: true });
|
||||
if (layout === "nested")
|
||||
rmSync(join(dir, slug), { recursive: true, force: true });
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
deleteEntry,
|
||||
readAllEntries,
|
||||
toTomlDate,
|
||||
writeEntry,
|
||||
} from "./content-file";
|
||||
|
||||
type Layout = "nested" | "flat";
|
||||
|
||||
interface SyncConfig {
|
||||
uid: string;
|
||||
dir: string;
|
||||
layout: Layout;
|
||||
/** Strapi entry -> { frontmatter, body } written to disk. */
|
||||
toFile: (entry: Record<string, unknown>) => {
|
||||
frontmatter: Record<string, unknown>;
|
||||
body: string;
|
||||
};
|
||||
/** Parsed file -> Strapi entry fields, for importing content/ into Strapi on boot. */
|
||||
toEntry: (
|
||||
slug: string,
|
||||
frontmatter: Record<string, unknown>,
|
||||
body: string,
|
||||
) => Record<string, unknown>;
|
||||
}
|
||||
|
||||
const isoDate = (value: unknown): string | undefined =>
|
||||
value instanceof Date
|
||||
? value.toISOString().slice(0, 10)
|
||||
: typeof value === "string"
|
||||
? value
|
||||
: undefined;
|
||||
|
||||
function contentRoot(): string {
|
||||
// apps/cms -> repo root/content (also the mount point in the dev container)
|
||||
return join(process.cwd(), "..", "..", "content");
|
||||
}
|
||||
|
||||
function buildConfigs(): Record<string, SyncConfig> {
|
||||
const root = contentRoot();
|
||||
|
||||
const writing: SyncConfig = {
|
||||
uid: "api::writing.writing",
|
||||
dir: join(root, "writings"),
|
||||
layout: "nested",
|
||||
toFile: (e) => ({
|
||||
frontmatter: {
|
||||
title: e.title,
|
||||
description: e.description,
|
||||
author: e.author,
|
||||
date: toTomlDate(e.date as string),
|
||||
updated: toTomlDate(e.updated as string),
|
||||
draft: e.draft ?? false,
|
||||
canonical_url: e.canonical_url,
|
||||
taxonomies: { tags: e.tags ?? [] },
|
||||
extra: {
|
||||
series: e.series,
|
||||
series_index: e.series_index,
|
||||
llms_description: e.llms_description,
|
||||
},
|
||||
},
|
||||
body: (e.body as string) ?? "",
|
||||
}),
|
||||
toEntry: (slug, fm, body) => {
|
||||
const taxonomies = fm.taxonomies as { tags?: string[] } | undefined;
|
||||
const extra = fm.extra as
|
||||
| { series?: string; series_index?: number; llms_description?: string }
|
||||
| undefined;
|
||||
return {
|
||||
slug,
|
||||
title: fm.title,
|
||||
description: fm.description,
|
||||
author: fm.author,
|
||||
date: isoDate(fm.date),
|
||||
updated: isoDate(fm.updated),
|
||||
draft: fm.draft ?? false,
|
||||
canonical_url: fm.canonical_url,
|
||||
tags: taxonomies?.tags ?? [],
|
||||
series: extra?.series,
|
||||
series_index: extra?.series_index,
|
||||
llms_description: extra?.llms_description,
|
||||
body,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const portfolioEntry: SyncConfig = {
|
||||
uid: "api::portfolio-entry.portfolio-entry",
|
||||
dir: join(root, "portfolio"),
|
||||
layout: "flat",
|
||||
toFile: (e) => ({
|
||||
frontmatter: {
|
||||
title: e.title,
|
||||
description: e.description,
|
||||
role: e.role,
|
||||
date_start: toTomlDate(e.date_start as string),
|
||||
date_end: toTomlDate(e.date_end as string),
|
||||
outcome: e.outcome,
|
||||
draft: e.draft ?? false,
|
||||
links: e.links ?? [],
|
||||
taxonomies: { tags: e.tags ?? [] },
|
||||
},
|
||||
body: (e.body as string) ?? "",
|
||||
}),
|
||||
toEntry: (slug, fm, body) => {
|
||||
const taxonomies = fm.taxonomies as { tags?: string[] } | undefined;
|
||||
return {
|
||||
slug,
|
||||
title: fm.title,
|
||||
description: fm.description,
|
||||
role: fm.role,
|
||||
date_start: isoDate(fm.date_start),
|
||||
date_end: isoDate(fm.date_end),
|
||||
outcome: fm.outcome,
|
||||
draft: fm.draft ?? false,
|
||||
links: fm.links ?? [],
|
||||
tags: taxonomies?.tags ?? [],
|
||||
body,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const bookshelfEntry: SyncConfig = {
|
||||
uid: "api::bookshelf-entry.bookshelf-entry",
|
||||
dir: join(root, "bookshelf"),
|
||||
layout: "flat",
|
||||
toFile: (e) => ({
|
||||
frontmatter: {
|
||||
title: e.title,
|
||||
kind: e.kind,
|
||||
authors: e.authors ?? [],
|
||||
isbn: e.isbn,
|
||||
doi: e.doi,
|
||||
arxiv_id: e.arxiv_id,
|
||||
url: e.url,
|
||||
cover_image: e.cover_image,
|
||||
rating: e.rating,
|
||||
status: e.status,
|
||||
date_started: toTomlDate(e.date_started as string),
|
||||
date_finished: toTomlDate(e.date_finished as string),
|
||||
taxonomies: { tags: e.tags ?? [] },
|
||||
},
|
||||
body: (e.body as string) ?? "",
|
||||
}),
|
||||
toEntry: (slug, fm, body) => {
|
||||
const taxonomies = fm.taxonomies as { tags?: string[] } | undefined;
|
||||
return {
|
||||
slug,
|
||||
title: fm.title,
|
||||
kind: fm.kind,
|
||||
authors: fm.authors ?? [],
|
||||
isbn: fm.isbn,
|
||||
doi: fm.doi,
|
||||
arxiv_id: fm.arxiv_id,
|
||||
url: fm.url,
|
||||
cover_image: fm.cover_image,
|
||||
rating: fm.rating,
|
||||
status: fm.status,
|
||||
date_started: isoDate(fm.date_started),
|
||||
date_finished: isoDate(fm.date_finished),
|
||||
tags: taxonomies?.tags ?? [],
|
||||
body,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const curatedBlog: SyncConfig = {
|
||||
uid: "api::curated-blog.curated-blog",
|
||||
dir: join(root, "blogs"),
|
||||
layout: "flat",
|
||||
toFile: (e) => ({
|
||||
frontmatter: {
|
||||
title: e.title,
|
||||
url: e.url,
|
||||
feed_url: e.feed_url,
|
||||
},
|
||||
body: (e.body as string) ?? "",
|
||||
}),
|
||||
toEntry: (slug, fm, body) => ({
|
||||
slug,
|
||||
title: fm.title,
|
||||
url: fm.url,
|
||||
feed_url: fm.feed_url,
|
||||
body,
|
||||
}),
|
||||
};
|
||||
|
||||
return {
|
||||
[writing.uid]: writing,
|
||||
[portfolioEntry.uid]: portfolioEntry,
|
||||
[bookshelfEntry.uid]: bookshelfEntry,
|
||||
[curatedBlog.uid]: curatedBlog,
|
||||
};
|
||||
}
|
||||
|
||||
export const SYNC_CONFIGS = buildConfigs();
|
||||
|
||||
/** Guards the outbound (DB -> file) middleware while the inbound (file -> DB) import runs. */
|
||||
let importing = false;
|
||||
|
||||
function writeEntryForConfig(
|
||||
config: SyncConfig,
|
||||
entry: Record<string, unknown>,
|
||||
): void {
|
||||
const slug = entry.slug as string | undefined;
|
||||
if (!slug) return;
|
||||
const { frontmatter, body } = config.toFile(entry);
|
||||
writeEntry(config.dir, slug, config.layout, frontmatter, body);
|
||||
}
|
||||
|
||||
/**
|
||||
* On boot, upserts every file under content/<type>/ into Strapi so the admin
|
||||
* UI reflects on-disk edits made outside Strapi. Strapi's DB stays a
|
||||
* disposable cache: this can always rebuild it from content/ alone.
|
||||
*/
|
||||
export async function importContentFromDisk(strapi: {
|
||||
documents: (uid: string) => {
|
||||
findFirst: (params: {
|
||||
filters: Record<string, unknown>;
|
||||
}) => Promise<Record<string, unknown> | null>;
|
||||
create: (params: { data: Record<string, unknown> }) => Promise<unknown>;
|
||||
update: (params: {
|
||||
documentId: string;
|
||||
data: Record<string, unknown>;
|
||||
}) => Promise<unknown>;
|
||||
};
|
||||
log: { info: (msg: string) => void; error: (msg: string) => void };
|
||||
}): Promise<void> {
|
||||
importing = true;
|
||||
try {
|
||||
for (const config of Object.values(SYNC_CONFIGS)) {
|
||||
const files = readAllEntries(config.dir);
|
||||
let created = 0;
|
||||
let updated = 0;
|
||||
for (const { slug, frontmatter, body } of files) {
|
||||
const data = config.toEntry(slug, frontmatter, body);
|
||||
try {
|
||||
const existing = await strapi
|
||||
.documents(config.uid)
|
||||
.findFirst({ filters: { slug } });
|
||||
if (existing) {
|
||||
await strapi
|
||||
.documents(config.uid)
|
||||
.update({ documentId: existing.documentId as string, data });
|
||||
updated += 1;
|
||||
} else {
|
||||
await strapi.documents(config.uid).create({ data });
|
||||
created += 1;
|
||||
}
|
||||
} catch (err) {
|
||||
strapi.log.error(
|
||||
`content-sync: failed to import ${config.dir}/${slug}: ${(err as Error).message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
strapi.log.info(
|
||||
`content-sync: imported ${config.uid} (${created} created, ${updated} updated, ${files.length} total)`,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
importing = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Registers the outbound (DB -> file) sync as a Document Service middleware. */
|
||||
export function registerContentSync(strapi: {
|
||||
documents: {
|
||||
use: (
|
||||
middleware: (
|
||||
ctx: { uid: string; action: string; params: Record<string, unknown> },
|
||||
next: () => Promise<unknown>,
|
||||
) => Promise<unknown>,
|
||||
) => void;
|
||||
(
|
||||
uid: string,
|
||||
): {
|
||||
findOne: (params: {
|
||||
documentId: string;
|
||||
}) => Promise<Record<string, unknown> | null>;
|
||||
};
|
||||
};
|
||||
log: { info: (msg: string) => void; error: (msg: string) => void };
|
||||
}): void {
|
||||
strapi.documents.use(async (ctx, next) => {
|
||||
const config = SYNC_CONFIGS[ctx.uid];
|
||||
if (!config || importing) return next();
|
||||
|
||||
let preDeleteSlug: string | undefined;
|
||||
if (ctx.action === "delete") {
|
||||
const documentId = (ctx.params as { documentId?: string }).documentId;
|
||||
if (documentId) {
|
||||
const existing = await strapi
|
||||
.documents(ctx.uid)
|
||||
.findOne({ documentId });
|
||||
preDeleteSlug = existing?.slug as string | undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const result = await next();
|
||||
|
||||
try {
|
||||
if (
|
||||
(ctx.action === "create" || ctx.action === "update") &&
|
||||
result &&
|
||||
typeof result === "object"
|
||||
) {
|
||||
writeEntryForConfig(config, result as Record<string, unknown>);
|
||||
} else if (ctx.action === "delete" && preDeleteSlug) {
|
||||
deleteEntry(config.dir, preDeleteSlug, config.layout);
|
||||
}
|
||||
} catch (err) {
|
||||
strapi.log.error(
|
||||
`content-sync: failed to write ${ctx.uid} to disk: ${(err as Error).message}`,
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
});
|
||||
}
|
||||
+318
-314
File diff suppressed because it is too large
Load Diff
Generated
+3
@@ -47,6 +47,9 @@ importers:
|
||||
react-router-dom:
|
||||
specifier: ^6.30.3
|
||||
version: 6.30.6([email protected]([email protected]))([email protected])
|
||||
smol-toml:
|
||||
specifier: ^1.8.0
|
||||
version: 1.8.0
|
||||
styled-components:
|
||||
specifier: ^6.0.0
|
||||
version: 6.5.3([email protected])([email protected]([email protected]))([email protected])
|
||||
|
||||
Reference in New Issue
Block a user