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.
130 lines
3.6 KiB
JavaScript
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);
|
|
});
|