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.
This commit is contained in:
parent
e521ca02ca
commit
289e673cbc
91 changed files with 414 additions and 17714 deletions
130
scripts/migrate-content.js
Normal file
130
scripts/migrate-content.js
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
#!/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);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue