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:
2026-08-30 18:45:44 +03:00
parent 27efeb8b26
commit 85efb824ae
10 changed files with 807 additions and 328 deletions
+1
View File
@@ -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
View File
@@ -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);
},
};
+127
View File
@@ -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 });
}
+320
View File
@@ -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;
});
}
File diff suppressed because it is too large Load Diff
+3
View File
@@ -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])