wiki_ghostguild/scripts/migrate-content.js
Jennie Robinson Faber 289e673cbc Replace Nuxt wiki with Outline deployment config
Strip the Nuxt 4 static site and replace with Docker Compose config
for self-hosted Outline wiki (Outline + PostgreSQL 16 + Redis 7).
Adds nginx reverse proxy with WebSocket support and CSS injection,
migration script for existing markdown articles, backup script,
and starter theme CSS.
2026-03-01 15:45:44 +00:00

130 lines
3.6 KiB
JavaScript

#!/usr/bin/env node
/**
* Migrate markdown articles from content/articles/ into Outline wiki.
*
* Usage:
* OUTLINE_URL=http://localhost:3100 OUTLINE_API_TOKEN=your-token node migrate-content.js
*
* Requires: npm install (in this directory) to get gray-matter.
*/
import fs from "fs/promises";
import path from "path";
import { fileURLToPath } from "url";
import matter from "gray-matter";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const OUTLINE_URL = process.env.OUTLINE_URL;
const OUTLINE_API_TOKEN = process.env.OUTLINE_API_TOKEN;
const CONTENT_DIR = path.resolve(__dirname, "../content/articles");
const RATE_LIMIT_MS = 200;
if (!OUTLINE_URL || !OUTLINE_API_TOKEN) {
console.error("Error: OUTLINE_URL and OUTLINE_API_TOKEN env vars are required.");
process.exit(1);
}
async function outlineApi(endpoint, body) {
const res = await fetch(`${OUTLINE_URL}/api/${endpoint}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${OUTLINE_API_TOKEN}`,
},
body: JSON.stringify(body),
});
if (!res.ok) {
const text = await res.text();
throw new Error(`API ${endpoint} failed (${res.status}): ${text}`);
}
return res.json();
}
function delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function getOrCreateCollection(name) {
// Search existing collections
const { data } = await outlineApi("collections.list", { limit: 100 });
const existing = data.find(
(c) => c.name.toLowerCase() === name.toLowerCase()
);
if (existing) return existing.id;
// Create new collection
await delay(RATE_LIMIT_MS);
const { data: created } = await outlineApi("collections.create", { name });
console.log(` Created collection: ${name}`);
return created.id;
}
async function main() {
const files = (await fs.readdir(CONTENT_DIR)).filter((f) => f.endsWith(".md"));
console.log(`Found ${files.length} markdown files to migrate.\n`);
// Build a map of category → collection ID
const collectionIds = new Map();
let success = 0;
let failed = 0;
for (const file of files) {
const filePath = path.join(CONTENT_DIR, file);
const raw = await fs.readFile(filePath, "utf-8");
const { data: frontmatter, content } = matter(raw);
// Determine title: frontmatter title, or first H1, or filename
let title =
frontmatter.title ||
content.match(/^#\s+(.+)$/m)?.[1] ||
path.basename(file, ".md").replace(/-/g, " ");
// Determine collection from category
const category = frontmatter.category || "Uncategorized";
const categoryName = category
.replace(/-/g, " ")
.replace(/\b\w/g, (c) => c.toUpperCase());
if (!collectionIds.has(categoryName)) {
try {
const id = await getOrCreateCollection(categoryName);
collectionIds.set(categoryName, id);
await delay(RATE_LIMIT_MS);
} catch (err) {
console.error(` Failed to get/create collection "${categoryName}": ${err.message}`);
failed++;
continue;
}
}
const collectionId = collectionIds.get(categoryName);
try {
const { data: doc } = await outlineApi("documents.create", {
title,
text: content.trim(),
collectionId,
publish: true,
});
console.log(`${title}${doc.url}`);
success++;
} catch (err) {
console.error(`${title}: ${err.message}`);
failed++;
}
await delay(RATE_LIMIT_MS);
}
console.log(`\nDone. ${success} migrated, ${failed} failed.`);
}
main().catch((err) => {
console.error("Migration failed:", err);
process.exit(1);
});