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:
Jennie Robinson Faber 2026-03-01 15:45:44 +00:00
parent e521ca02ca
commit 289e673cbc
91 changed files with 414 additions and 17714 deletions

View file

@ -1,46 +1,3 @@
# MongoDB Connection # Used by docker-compose.yml for the PostgreSQL service
MONGODB_URI=mongodb://localhost:27017/wiki-ghostguild # Generate with: openssl rand -hex 16
POSTGRES_PASSWORD=CHANGE_ME
# Authentication
JWT_SECRET=your-secret-key-change-in-production
JWT_REFRESH_SECRET=your-refresh-secret-change-in-production
# Ghost Guild OAuth Integration
GHOSTGUILD_API_URL=https://ghostguild.org/api
GHOSTGUILD_API_KEY=your-api-key
GHOSTGUILD_CLIENT_ID=your-client-id
GHOSTGUILD_CLIENT_SECRET=your-client-secret
GHOSTGUILD_REDIRECT_URI=https://wiki.ghostguild.org/api/auth/callback
# Site Configuration
SITE_URL=https://wiki.ghostguild.org
SITE_NAME=Ghost Guild Knowledge Commons
SITE_DESCRIPTION=A wiki for ghosts! 👻
# Forgejo Integration
FORGEJO_URL=https://git.ghostguild.org
FORGEJO_TOKEN=your-forgejo-token
FORGEJO_REPO=ghostguild/wiki-content
# Search (Meilisearch)
MEILISEARCH_URL=http://localhost:7700
MEILISEARCH_KEY=your-master-key
# Email (for notifications)
EMAIL_FROM=wiki@ghostguild.org
EMAIL_SMTP_HOST=smtp.example.com
EMAIL_SMTP_PORT=587
EMAIL_SMTP_USER=your-smtp-user
EMAIL_SMTP_PASS=your-smtp-password
# Analytics (Optional)
PLAUSIBLE_DOMAIN=wiki.ghostguild.org
PLAUSIBLE_API_KEY=your-plausible-key
# Error Tracking (Optional)
SENTRY_DSN=your-sentry-dsn
SENTRY_ENVIRONMENT=production
# Development
NODE_ENV=development
DEBUG=false

15
.gitignore vendored
View file

@ -1,10 +1,5 @@
# Nuxt dev/build outputs # Outline secrets
.output outline.env
.data
.nuxt
.nitro
.cache
dist
# Node dependencies # Node dependencies
node_modules node_modules
@ -33,6 +28,6 @@ conflict-files-obsidian-git.md
.env.* .env.*
!.env.example !.env.example
/*.md # Project docs (local only)
/migration/* /docs
MIGRATION_STATUS.txt /migration

2
.npmrc
View file

@ -1,2 +0,0 @@
ignore-scripts=true
optional=false

View file

@ -1,31 +0,0 @@
# Build stage
FROM node:20-slim AS builder
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install dependencies
RUN npm install --yes --omit=dev
# Copy source
COPY . .
# Generate static site
RUN npm run generate
# Production stage - static nginx server
FROM nginx:alpine
# Copy nginx config
COPY nginx.conf /etc/nginx/nginx.conf
# Copy generated static files from builder
COPY --from=builder /app/.output/public /usr/share/nginx/html
# Expose port
EXPOSE 80
# Start nginx
CMD ["nginx", "-g", "daemon off;"]

View file

@ -1,65 +0,0 @@
export default defineAppConfig({
ui: {
primary: 'blue',
gray: 'neutral',
// Configure prose components
content: {
prose: {
// Base prose styling
prose: {
base: {
fontFamily: "'Crimson Text', Georgia, serif",
fontSize: '1.125rem',
lineHeight: '1.75',
maxWidth: 'none',
}
},
// Paragraph styling
p: {
base: 'font-serif text-lg leading-relaxed mt-5 mb-5',
},
// Heading styles
h1: {
base: 'font-serif font-bold text-4xl mt-8 mb-4',
},
h2: {
base: 'font-serif font-semibold text-3xl mt-8 mb-4',
},
h3: {
base: 'font-serif font-semibold text-2xl mt-6 mb-3',
},
h4: {
base: 'font-serif font-semibold text-xl mt-6 mb-3',
},
// List styling
ul: {
base: 'list-disc pl-6 my-5 space-y-2',
},
ol: {
base: 'list-decimal pl-6 my-5 space-y-2',
},
li: {
base: 'font-serif text-lg leading-relaxed',
},
// Link styling
a: {
base: 'text-blue-600 underline hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300',
},
// Blockquote styling
blockquote: {
base: 'border-l-4 border-gray-300 dark:border-gray-700 pl-4 my-5 italic font-serif',
},
// Code styling
code: {
inline: {
base: 'bg-gray-100 dark:bg-gray-800 px-1 py-0.5 rounded text-sm font-mono',
},
block: {
base: 'bg-gray-100 dark:bg-gray-800 p-4 rounded-lg overflow-x-auto my-5',
}
},
}
}
}
})

View file

@ -1,14 +0,0 @@
<template>
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
</template>
<script setup>
// Initialize auth on app mount
const { fetchUser } = useAuth()
onMounted(async () => {
await fetchUser()
})
</script>

View file

@ -1,15 +0,0 @@
@import url("https://fonts.googleapis.com/css2?family=Crimson+Text:ital,wght@0,400;0,600;0,700;1,400;1,600&display=swap");
@import "tailwindcss";
@plugin "@tailwindcss/typography";
@layer components {
/* Editor styles */
.editor-container {
@apply min-h-[500px] border rounded-lg;
}
/* Comment thread styles */
.comment-thread {
@apply border-l-2 border-gray-200 dark:border-gray-700 pl-4 ml-4;
}
}

View file

@ -1,54 +0,0 @@
<template>
<UAlert
:color="color"
:variant="variant"
:icon="icon"
:title="title"
:description="description"
class="my-4 font-serif"
>
<template v-if="$slots.default" #description>
<div class="prose prose-sm dark:prose-invert max-w-none font-serif">
<slot />
</div>
</template>
</UAlert>
</template>
<script setup lang="ts">
import { computed } from "vue";
type AlertColor =
| "error"
| "info"
| "primary"
| "secondary"
| "success"
| "warning"
| "neutral";
interface Props {
type?: "info" | "warning" | "error" | "success" | "primary";
title?: string;
description?: string;
icon?: string;
variant?: "solid" | "outline" | "soft" | "subtle";
}
const props = withDefaults(defineProps<Props>(), {
type: "primary",
variant: "soft",
});
// Map type to Nuxt UI 3 color
const color = computed<AlertColor>(() => {
const colorMap: Record<string, AlertColor> = {
info: "info",
warning: "warning",
error: "error",
success: "success",
primary: "primary",
};
return colorMap[props.type || "primary"] || "primary";
});
</script>

View file

@ -1,20 +0,0 @@
<template>
<div class="my-4">
<ul v-if="!ordered" class="list-disc list-inside space-y-2 prose prose-lg dark:prose-invert">
<slot />
</ul>
<ol v-else class="list-decimal list-inside space-y-2 prose prose-lg dark:prose-invert">
<slot />
</ol>
</div>
</template>
<script setup lang="ts">
interface Props {
ordered?: boolean
}
withDefaults(defineProps<Props>(), {
ordered: false
})
</script>

View file

@ -1,70 +0,0 @@
import type { User } from "../types/auth";
export const useAuth = () => {
const user = useState<User | null>("auth.user", () => null);
const isAuthenticated = computed(() => !!user.value);
const login = () => {
// Redirect to login endpoint
navigateTo("/api/auth/login", { external: true });
};
const logout = async () => {
try {
await $fetch("/api/auth/logout", { method: "POST" });
user.value = null;
await navigateTo("/");
} catch (error) {
console.error("Logout failed:", error);
}
};
const fetchUser = async () => {
try {
const userData = await $fetch<User>("/api/auth/me");
user.value = userData;
return userData;
} catch (error) {
user.value = null;
return null;
}
};
const hasRole = (role: string) => {
return user.value?.roles?.includes(role) || false;
};
const hasPermission = (
permission: "canEdit" | "canModerate" | "canAdmin",
) => {
return user.value?.permissions?.[permission] || false;
};
const canAccessContent = (accessLevel: string, cohorts?: string[]) => {
if (accessLevel === "public") return true;
if (!isAuthenticated.value) return false;
switch (accessLevel) {
case "member":
return true;
case "cohort":
if (!cohorts || cohorts.length === 0) return true;
return cohorts.some((c) => hasRole(`cohort-${c}`));
case "admin":
return hasPermission("canAdmin");
default:
return false;
}
};
return {
user: readonly(user),
isAuthenticated: readonly(isAuthenticated),
login,
logout,
fetchUser,
hasRole,
hasPermission,
canAccessContent,
};
};

View file

@ -1,64 +0,0 @@
<template>
<div class="min-h-screen bg-gray-50 dark:bg-gray-900">
<!-- Navigation -->
<nav class="bg-white dark:bg-gray-800 shadow">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between h-16">
<div class="flex items-center">
<NuxtLink
to="/"
class="text-xl font-bold text-gray-900 dark:text-white"
>
Ghost Guild Wiki
</NuxtLink>
<div class="ml-10 flex space-x-4">
<NuxtLink
to="/articles"
class="text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white px-3 py-2 rounded-md"
>
Articles
</NuxtLink>
<NuxtLink
v-if="isAuthenticated"
to="/articles/new"
class="text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white px-3 py-2 rounded-md"
>
New Article
</NuxtLink>
</div>
</div>
<div class="flex items-center space-x-4">
<div v-if="isAuthenticated" class="flex items-center space-x-4">
<span class="text-gray-700 dark:text-gray-300">{{
user?.displayName
}}</span>
<button
@click="logout"
class="text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white"
>
Logout
</button>
</div>
<button
v-else
@click="login"
class="bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700"
>
Login
</button>
</div>
</div>
</div>
</nav>
<!-- Main Content -->
<main class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<slot />
</main>
</div>
</template>
<script setup>
const { user, isAuthenticated, login, logout } = useAuth();
</script>

View file

@ -1,152 +0,0 @@
<template>
<div>
<!-- Loading State -->
<div v-if="pending" class="text-center py-8">
<div class="text-gray-600 dark:text-gray-400">Loading article...</div>
</div>
<!-- Error State -->
<div v-else-if="!article" class="text-center py-8">
<div class="text-red-600 dark:text-red-400">Article not found</div>
<NuxtLink
to="/articles"
class="text-blue-600 hover:text-blue-800 mt-4 inline-block"
>
Back to Articles
</NuxtLink>
</div>
<!-- Article Content -->
<article v-else class="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-8">
<!-- Header -->
<div class="border-b border-gray-200 dark:border-gray-700 pb-6 mb-6">
<h1 class="text-3xl font-bold text-gray-900 dark:text-white mb-4">
{{ article.title }}
</h1>
<div
class="flex flex-wrap gap-4 text-sm text-gray-600 dark:text-gray-400"
>
<span>By {{ article.author || "Unknown" }}</span>
<span v-if="article.publishedAt">
{{ new Date(article.publishedAt).toLocaleDateString() }}
</span>
<span
v-if="article.category"
class="px-2 py-1 bg-gray-100 dark:bg-gray-700 rounded"
>
{{ article.category }}
</span>
<span
v-if="article.accessLevel && article.accessLevel !== 'public'"
class="text-orange-600 dark:text-orange-400"
>
🔒 {{ article.accessLevel }}
</span>
</div>
<!-- Edit Button (for future use) -->
<!-- <div v-if="canEdit" class="mt-4">
<NuxtLink
:to="`${article._path}/edit`"
class="bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700"
>
Edit Article
</NuxtLink>
</div> -->
</div>
<!-- Access Control Warning -->
<div
v-if="article.accessLevel === 'member'"
class="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4 mb-6"
>
<p class="text-blue-800 dark:text-blue-200">
👥 This content is for Baby Ghosts members only
</p>
</div>
<div
v-else-if="article.accessLevel === 'cohort'"
class="bg-purple-50 dark:bg-purple-900/20 border border-purple-200 dark:border-purple-800 rounded-lg p-4 mb-6"
>
<p class="text-purple-800 dark:text-purple-200">
🔒 This content is for a specific cohort
</p>
</div>
<div
v-else-if="article.accessLevel === 'admin'"
class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4 mb-6"
>
<p class="text-red-800 dark:text-red-200">
🔐 This is internal/admin content
</p>
</div>
<!-- Article Body -->
<ContentRenderer
:value="article"
class="prose prose-lg prose-gray dark:prose-invert max-w-none"
/>
<!-- Tags -->
<div
v-if="article.tags?.length"
class="mt-8 pt-6 border-t border-gray-200 dark:border-gray-700"
>
<div class="flex flex-wrap gap-2">
<span
v-for="tag in article.tags"
:key="tag"
class="px-3 py-1 bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-full text-sm"
>
#{{ tag }}
</span>
</div>
</div>
<!-- Navigation -->
<div
class="mt-8 pt-6 border-t border-gray-200 dark:border-gray-700 flex gap-4"
>
<NuxtLink
to="/articles"
class="text-blue-600 hover:text-blue-800 dark:text-blue-400"
>
Back to Articles
</NuxtLink>
</div>
</article>
</div>
</template>
<script setup>
const route = useRoute();
const slug = route.params.slug;
// Fetch article from Nuxt Content
const { data: article, pending } = await useAsyncData(
`article-${slug}`,
async () => {
// Query for the specific article by stem (filename without extension)
const articles = await queryCollection("articles").all();
return articles.find((a) => a.stem === slug);
},
);
// SEO
useHead({
title: () => article.value?.title || "Article",
meta: [
{ name: "description", content: () => article.value?.description || "" },
],
});
// Watch for route changes
watch(
() => route.params.slug,
() => {
// Route change will trigger new useAsyncData
},
{ immediate: true },
);
</script>

View file

@ -1,154 +0,0 @@
<template>
<div>
<h1 class="text-3xl font-bold text-gray-900 dark:text-white mb-6">
Articles
</h1>
<!-- Search and Filters -->
<div class="bg-white dark:bg-gray-800 p-4 rounded-lg shadow mb-6">
<div class="flex gap-4">
<input
v-model="search"
type="text"
placeholder="Search articles..."
class="flex-1 px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/>
<select
v-model="selectedCategory"
class="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
>
<option value="">All Categories</option>
<option value="operations">Operations</option>
<option value="funding">Funding</option>
<option value="strategy">Strategy</option>
<option value="templates">Templates</option>
<option value="programs">Programs</option>
</select>
</div>
</div>
<!-- Articles List -->
<div v-if="filteredArticles.length" class="space-y-4">
<article
v-for="article in filteredArticles"
:key="article._path || article.stem || getArticleSlug(article)"
class="bg-white dark:bg-gray-800 p-6 rounded-lg shadow hover:shadow-lg transition-shadow"
>
<div class="flex justify-between items-start">
<div class="flex-1">
<NuxtLink
:to="getArticlePath(article)"
class="text-xl font-semibold text-blue-600 hover:text-blue-800 dark:text-blue-400"
>
{{ article.title }}
</NuxtLink>
<p
class="text-gray-600 dark:text-gray-400 mt-2 font-serif text-lg leading-relaxed"
>
{{ article.description }}
</p>
<div
class="mt-3 flex items-center gap-4 text-sm text-gray-500 dark:text-gray-400"
>
<span class="px-2 py-1 bg-gray-100 dark:bg-gray-700 rounded">
{{ article.category || "uncategorized" }}
</span>
<span>By {{ article.author || "Unknown" }}</span>
<span v-if="article.publishedAt">
{{ new Date(article.publishedAt).toLocaleDateString() }}
</span>
<span
v-if="article.accessLevel && article.accessLevel !== 'public'"
class="text-orange-600 dark:text-orange-400"
>
🔒 {{ article.accessLevel }}
</span>
</div>
</div>
</div>
</article>
</div>
<div v-else class="text-center py-8">
<p class="text-gray-600 dark:text-gray-400">
{{
search || selectedCategory
? "No articles found matching your filters"
: "No articles available"
}}
</p>
</div>
</div>
</template>
<script setup>
const search = ref("");
const selectedCategory = ref("");
// Fetch all articles from Nuxt Content
const { data: articles } = await useAsyncData("articles", () =>
queryCollection("articles").all(),
);
// Helper function to extract title from body
const getArticleTitle = (article) => {
// Try to get title from frontmatter first
if (
article.title &&
typeof article.title === "string" &&
article.title !== "[object Object]"
) {
return article.title;
}
// Fallback: extract from first H1 in body
if (article.meta?.body?.value && Array.isArray(article.meta.body.value)) {
const h1 = article.meta.body.value.find(
(node) => Array.isArray(node) && node[0] === "h1",
);
if (h1 && h1[2]) {
return h1[2];
}
}
return "Untitled";
};
// Resolve the correct Nuxt route for an article entry
const getArticleSlug = (article) => {
if (!article) return "";
// stem is the filename without extension
return article.stem || "";
};
const getArticlePath = (article) => {
if (!article) return "/articles";
const slug = getArticleSlug(article);
return `/articles/${slug}`;
};
// Filter and search articles
const filteredArticles = computed(() => {
if (!articles.value) return [];
return articles.value.filter((article) => {
const title = getArticleTitle(article);
const matchesSearch =
!search.value ||
title?.toLowerCase().includes(search.value.toLowerCase()) ||
article.description?.toLowerCase().includes(search.value.toLowerCase());
const matchesCategory =
!selectedCategory.value || article.category === selectedCategory.value;
return matchesSearch && matchesCategory;
});
});
// Watch for search changes
watch(search, () => {
// Trigger reactivity
});
</script>

View file

@ -1,228 +0,0 @@
<template>
<div class="max-w-4xl mx-auto">
<h1 class="text-3xl font-bold text-gray-900 dark:text-white mb-6">Create New Article</h1>
<form @submit.prevent="createArticle" class="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-8">
<!-- Title -->
<div class="mb-6">
<label for="title" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Title
</label>
<input
v-model="form.title"
id="title"
type="text"
required
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
placeholder="Article title"
>
</div>
<!-- Slug -->
<div class="mb-6">
<label for="slug" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
URL Slug
</label>
<input
v-model="form.slug"
id="slug"
type="text"
required
pattern="[a-z0-9-]+"
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
placeholder="article-url-slug"
>
<p class="text-sm text-gray-500 mt-1">Lowercase letters, numbers, and hyphens only</p>
</div>
<!-- Description -->
<div class="mb-6">
<label for="description" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Description
</label>
<textarea
v-model="form.description"
id="description"
rows="2"
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
placeholder="Brief description of the article"
></textarea>
</div>
<!-- Category -->
<div class="mb-6">
<label for="category" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Category
</label>
<select
v-model="form.category"
id="category"
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
>
<option value="operations">Operations</option>
<option value="funding">Funding</option>
<option value="strategy">Strategy</option>
<option value="templates">Templates</option>
<option value="programs">Programs</option>
</select>
</div>
<!-- Access Level -->
<div class="mb-6">
<label for="accessLevel" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Access Level
</label>
<select
v-model="form.accessLevel"
id="accessLevel"
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
>
<option value="public">Public</option>
<option value="member">Members Only</option>
<option value="cohort">Cohort Only</option>
<option value="admin" v-if="hasPermission('canAdmin')">Admin Only</option>
</select>
</div>
<!-- Content -->
<div class="mb-6">
<label for="content" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Content (Markdown)
</label>
<textarea
v-model="form.content"
id="content"
rows="20"
required
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-white font-mono text-sm"
placeholder="Write your article in Markdown..."
></textarea>
</div>
<!-- Tags -->
<div class="mb-6">
<label for="tags" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Tags (comma-separated)
</label>
<input
v-model="tagsInput"
id="tags"
type="text"
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
placeholder="tag1, tag2, tag3"
>
</div>
<!-- Status -->
<div class="mb-6">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Status
</label>
<div class="flex gap-4">
<label class="flex items-center">
<input
v-model="form.status"
type="radio"
value="draft"
class="mr-2"
>
<span class="text-gray-700 dark:text-gray-300">Save as Draft</span>
</label>
<label class="flex items-center">
<input
v-model="form.status"
type="radio"
value="published"
class="mr-2"
>
<span class="text-gray-700 dark:text-gray-300">Publish Now</span>
</label>
</div>
</div>
<!-- Error Message -->
<div v-if="error" class="mb-6 p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
<p class="text-red-800 dark:text-red-200">{{ error }}</p>
</div>
<!-- Submit Buttons -->
<div class="flex gap-4">
<button
type="submit"
:disabled="loading"
class="bg-blue-600 text-white px-6 py-2 rounded-md hover:bg-blue-700 disabled:opacity-50"
>
{{ loading ? 'Creating...' : 'Create Article' }}
</button>
<NuxtLink to="/articles" class="bg-gray-300 dark:bg-gray-600 text-gray-700 dark:text-gray-300 px-6 py-2 rounded-md hover:bg-gray-400 dark:hover:bg-gray-500">
Cancel
</NuxtLink>
</div>
</form>
</div>
</template>
<script setup>
const { hasPermission, isAuthenticated } = useAuth()
const router = useRouter()
// Redirect if not authenticated
if (!isAuthenticated.value) {
navigateTo('/api/auth/login', { external: true })
}
const form = ref({
title: '',
slug: '',
description: '',
category: 'operations',
accessLevel: 'member',
content: '',
status: 'draft'
})
const tagsInput = ref('')
const loading = ref(false)
const error = ref('')
// Auto-generate slug from title
watch(() => form.value.title, (title) => {
if (!form.value.slug || form.value.slug === slugify(title.slice(0, -1))) {
form.value.slug = slugify(title)
}
})
function slugify(text) {
return text
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
}
async function createArticle() {
loading.value = true
error.value = ''
try {
const tags = tagsInput.value
.split(',')
.map(t => t.trim())
.filter(t => t)
const response = await $fetch('/api/articles', {
method: 'POST',
body: {
...form.value,
tags
}
})
// Redirect to the new article
await router.push(`/articles/${response.slug}`)
} catch (err) {
error.value = err.data?.message || 'Failed to create article'
} finally {
loading.value = false
}
}
</script>

View file

@ -1,101 +0,0 @@
<template>
<div>
<p class="text-xl text-gray-600 dark:text-gray-400 mb-8">
A wiki for ghosts! 👻
</p>
<div class="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
<div class="bg-white dark:bg-gray-800 p-6 rounded-lg shadow">
<h2 class="text-xl font-semibold mb-2">View all articles</h2>
<p class="text-gray-600 dark:text-gray-400 mb-4"></p>
<NuxtLink to="/articles" class="text-blue-600 hover:text-blue-800">
View All Articles
</NuxtLink>
</div>
<!-- <div
v-if="isAuthenticated"
class="bg-white dark:bg-gray-800 p-6 rounded-lg shadow"
>
<h2 class="text-xl font-semibold mb-2">Contribute</h2>
<p class="text-gray-600 dark:text-gray-400 mb-4">
Share your knowledge with the community
</p>
<NuxtLink to="/articles/new" class="text-blue-600 hover:text-blue-800">
Create Article
</NuxtLink>
</div> -->
<div class="bg-white dark:bg-gray-800 p-6 rounded-lg shadow">
<h2 class="text-xl font-semibold mb-2">Ghost Guild</h2>
<p class="text-gray-600 dark:text-gray-400 mb-4"></p>
<a
href="https://ghostguild.org"
class="text-blue-600 hover:text-blue-800"
>
Visit Ghost Guild
</a>
</div>
</div>
<!-- Recent Articles -->
<div class="mt-12">
<h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-6">
Recent Articles
</h2>
<div v-if="pending" class="text-gray-600 dark:text-gray-400">
Loading...
</div>
<div v-else-if="articles?.length" class="space-y-4">
<article
v-for="article in articles"
:key="article.stem"
class="bg-white dark:bg-gray-800 p-4 rounded-lg shadow"
>
<NuxtLink
:to="`/articles/${article.stem}`"
class="text-xl font-semibold text-blue-600 hover:text-blue-800"
>
{{ article.title }}
</NuxtLink>
<p class="text-gray-600 dark:text-gray-400 mt-2">
{{ article.description }}
</p>
<div class="mt-2 text-sm text-gray-500">
<span>{{ article.category }}</span>
<span>{{
new Date(article.publishedAt).toLocaleDateString()
}}</span>
</div>
</article>
</div>
</div>
</div>
</template>
<script setup>
const { isAuthenticated } = useAuth();
// Fetch recent articles from Nuxt Content to keep generate static-friendly
const { data: recentArticles, pending } = await useAsyncData(
"recent-articles",
async () => {
const entries = await queryCollection("articles").all();
const getTimestamp = (value) => {
if (!value) {
return 0;
}
const date = new Date(value);
return Number.isNaN(date.getTime()) ? 0 : date.getTime();
};
return entries
.filter((article) => article.published !== false)
.sort((a, b) => getTimestamp(b.publishedAt) - getTimestamp(a.publishedAt))
.slice(0, 5);
},
);
const articles = computed(() => recentArticles.value || []);
</script>

View file

@ -1,31 +0,0 @@
import { Article } from "../../models/Article";
import { requireRole } from "../../utils/auth";
export default defineEventHandler(async (event) => {
const slug = getRouterParam(event, "slug");
// Only admins can delete articles
const user = await requireRole(event, ["admin"]);
if (!slug) {
throw createError({
statusCode: 400,
statusMessage: "Slug is required",
});
}
// Find and delete article
const article = await Article.findOneAndDelete({ slug });
if (!article) {
throw createError({
statusCode: 404,
statusMessage: "Article not found",
});
}
return {
success: true,
message: "Article deleted successfully",
};
});

View file

@ -1,71 +0,0 @@
import { Article } from '../../models/Article'
import { checkAccessLevel, verifyAuth } from '../../utils/auth'
export default defineEventHandler(async (event) => {
const slug = getRouterParam(event, 'slug')
if (!slug) {
throw createError({
statusCode: 400,
statusMessage: 'Slug is required'
})
}
// Find article
const article = await Article.findOne({ slug })
.populate('author', 'username displayName avatar')
.populate('contributors', 'username displayName avatar')
if (!article) {
throw createError({
statusCode: 404,
statusMessage: 'Article not found'
})
}
// Check access
const hasAccess = await checkAccessLevel(
event,
article.accessLevel,
article.cohorts
)
if (!hasAccess) {
// For protected content, return limited preview
if (article.accessLevel !== 'admin') {
return {
slug: article.slug,
title: article.title,
description: article.description.substring(0, 200) + '...',
category: article.category,
tags: article.tags,
accessLevel: article.accessLevel,
restricted: true,
message: 'This content requires authentication'
}
}
throw createError({
statusCode: 403,
statusMessage: 'Access denied'
})
}
// Increment view count
article.views += 1
await article.save()
// Get user to check if they've liked
const user = await verifyAuth(event)
const userLiked = false // TODO: Implement likes tracking
return {
...article.toObject(),
userLiked,
revisionCount: article.revisions.length,
commentCount: article.comments.length,
// Don't send full revisions and comments in main response
revisions: undefined,
comments: undefined
}
})

View file

@ -1,109 +0,0 @@
import { Article } from "../../models/Article";
import { requireAuth } from "../../utils/auth";
import { Types } from "mongoose";
export default defineEventHandler(async (event) => {
const slug = getRouterParam(event, "slug");
const user = await requireAuth(event);
const body = await readBody(event);
if (!slug) {
throw createError({
statusCode: 400,
statusMessage: "Slug is required",
});
}
// Find article
const article = await Article.findOne({ slug });
if (!article) {
throw createError({
statusCode: 404,
statusMessage: "Article not found",
});
}
// Check permissions
const isAuthor = article.author.toString() === user.userId;
const isAdmin = user.permissions.canAdmin;
const isModerator = user.permissions.canModerate;
const canEdit = user.permissions.canEdit;
if (!isAuthor && !isAdmin && !isModerator) {
throw createError({
statusCode: 403,
statusMessage: "You do not have permission to edit this article",
});
}
// Check if article is locked by another user
if (article.lockedBy && article.lockedBy.toString() !== user.userId) {
const lockExpired =
article.lockedAt &&
new Date().getTime() - article.lockedAt.getTime() > 30 * 60 * 1000; // 30 minutes
if (!lockExpired) {
throw createError({
statusCode: 423,
statusMessage: "Article is currently being edited by another user",
});
}
}
// Update fields
if (body.title) article.title = body.title;
if (body.description) article.description = body.description;
if (body.content) {
// Add to revision history
article.revisions.push({
content: body.content,
author: new Types.ObjectId(user.userId),
message: body.revisionMessage || "Content updated",
createdAt: new Date(),
});
article.content = body.content;
}
if (body.category) article.category = body.category;
if (body.tags) article.tags = body.tags;
if (body.accessLevel && (isAdmin || isModerator)) {
article.accessLevel = body.accessLevel;
}
if (body.cohorts && (isAdmin || isModerator)) {
article.cohorts = body.cohorts;
}
if (body.status) {
article.status = body.status;
if (body.status === "published" && !article.publishedAt) {
article.publishedAt = new Date();
}
}
// Add contributor if not already listed
const userObjectId = new Types.ObjectId(user.userId);
if (!article.contributors.some((c: any) => c.toString() === user.userId)) {
article.contributors.push(userObjectId);
}
// Clear lock
article.lockedBy = undefined;
article.lockedAt = undefined;
// Save changes
try {
await article.save();
return {
success: true,
slug: article.slug,
message: "Article updated successfully",
revision: article.revisions.length,
};
} catch (error) {
console.error("Error updating article:", error);
throw createError({
statusCode: 500,
statusMessage: "Failed to update article",
});
}
});

View file

@ -1,76 +0,0 @@
import { Article } from '../../models/Article'
import { checkAccessLevel } from '../../utils/auth'
export default defineEventHandler(async (event) => {
const query = getQuery(event)
// Pagination
const page = parseInt(query.page as string) || 1
const limit = parseInt(query.limit as string) || 20
const skip = (page - 1) * limit
// Build filter
const filter: any = { status: 'published' }
// Category filter
if (query.category) {
filter.category = query.category
}
// Tags filter
if (query.tags) {
const tags = (query.tags as string).split(',')
filter.tags = { $in: tags }
}
// Search filter
if (query.search) {
filter.$or = [
{ title: { $regex: query.search, $options: 'i' } },
{ description: { $regex: query.search, $options: 'i' } },
{ content: { $regex: query.search, $options: 'i' } }
]
}
// Get articles
const articles = await Article.find(filter)
.populate('author', 'username displayName avatar')
.select('-content -revisions -comments')
.sort({ publishedAt: -1 })
.skip(skip)
.limit(limit)
// Filter by access level
const filteredArticles = []
for (const article of articles) {
const hasAccess = await checkAccessLevel(
event,
article.accessLevel,
article.cohorts
)
if (hasAccess) {
filteredArticles.push(article)
} else if (article.accessLevel === 'member' || article.accessLevel === 'cohort') {
// Show preview for protected content
filteredArticles.push({
...article.toObject(),
description: article.description.substring(0, 200) + '...',
restricted: true
})
}
}
// Get total count
const total = await Article.countDocuments(filter)
return {
articles: filteredArticles,
pagination: {
page,
limit,
total,
pages: Math.ceil(total / limit)
}
}
})

View file

@ -1,72 +0,0 @@
import { Article } from "../../models/Article";
import { requireAuth } from "../../utils/auth";
export default defineEventHandler(async (event) => {
// Require authentication
const user = await requireAuth(event);
// Check if user can create articles
if (!user.permissions.canEdit) {
throw createError({
statusCode: 403,
statusMessage: "You do not have permission to create articles",
});
}
// Get request body
const body = await readBody(event);
// Validate required fields
if (!body.title || !body.slug || !body.content) {
throw createError({
statusCode: 400,
statusMessage: "Title, slug, and content are required",
});
}
// Check if slug already exists
const existing = await Article.findOne({ slug: body.slug });
if (existing) {
throw createError({
statusCode: 409,
statusMessage: "An article with this slug already exists",
});
}
// Create article
try {
const article = await Article.create({
slug: body.slug,
title: body.title,
description: body.description || "",
content: body.content,
category: body.category || "general",
tags: body.tags || [],
accessLevel: body.accessLevel || "public",
cohorts: body.cohorts || [],
author: user.userId,
status: body.status || "draft",
publishedAt: body.status === "published" ? new Date() : null,
revisions: [
{
content: body.content,
author: user.userId,
message: "Initial creation",
createdAt: new Date(),
},
],
});
return {
success: true,
slug: article.slug,
message: "Article created successfully",
};
} catch (error) {
console.error("Error creating article:", error);
throw createError({
statusCode: 500,
statusMessage: "Failed to create article",
});
}
});

View file

@ -1,100 +0,0 @@
import { User } from "../../models/User";
import jwt from "jsonwebtoken";
import type {
OAuthTokenResponse,
GhostGuildUserInfo,
} from "../../../types/auth";
export default defineEventHandler(async (event) => {
const config = useRuntimeConfig();
const query = getQuery(event);
// Verify state for CSRF protection
const storedState = getCookie(event, "oauth_state");
if (!storedState || storedState !== query.state) {
return sendRedirect(event, "/login?error=invalid_state");
}
// Clear the state cookie
deleteCookie(event, "oauth_state");
// Exchange authorization code for access token
try {
const tokenResponse = await $fetch<OAuthTokenResponse>(
`${config.ghostguildApiUrl}/oauth/token`,
{
method: "POST",
body: {
grant_type: "authorization_code",
code: query.code,
redirect_uri: `${config.public.siteUrl}/api/auth/callback`,
client_id: config.ghostguildClientId,
client_secret: config.ghostguildClientSecret,
},
},
);
// Get user information from Ghost Guild
const userInfo = await $fetch<GhostGuildUserInfo>(
`${config.ghostguildApiUrl}/user/me`,
{
headers: {
Authorization: `Bearer ${tokenResponse.access_token}`,
},
},
);
// Find or create user in our database
let user = await User.findOne({ ghostguildId: userInfo.id });
if (!user) {
user = await User.create({
ghostguildId: userInfo.id,
email: userInfo.email,
username: userInfo.username,
displayName: userInfo.displayName || userInfo.username,
avatar: userInfo.avatar,
roles: userInfo.roles || ["member"],
permissions: {
canEdit: userInfo.roles?.includes("member") || false,
canModerate: userInfo.roles?.includes("moderator") || false,
canAdmin: userInfo.roles?.includes("admin") || false,
},
lastLogin: new Date(),
});
} else {
// Update existing user
user.displayName = userInfo.displayName || userInfo.username;
user.avatar = userInfo.avatar;
user.roles = userInfo.roles || ["member"];
user.lastLogin = new Date();
await user.save();
}
// Create JWT token
const token = jwt.sign(
{
userId: user._id,
username: user.username,
roles: user.roles,
permissions: user.permissions,
},
config.jwtSecret as string,
{ expiresIn: "7d" },
);
// Set JWT as httpOnly cookie
setCookie(event, "auth-token", token, {
httpOnly: true,
secure: true,
sameSite: "lax",
maxAge: 60 * 60 * 24 * 7, // 7 days
});
// Redirect to dashboard or home
return sendRedirect(event, "/dashboard");
} catch (error) {
console.error("OAuth callback error:", error);
return sendRedirect(event, "/login?error=authentication_failed");
}
});

View file

@ -1,28 +0,0 @@
export default defineEventHandler(async (event) => {
const config = useRuntimeConfig();
// Generate state for CSRF protection
const state = Math.random().toString(36).substring(7);
// Store state in session (you'll need to implement session storage)
setCookie(event, "oauth_state", state, {
httpOnly: true,
secure: true,
sameSite: "lax",
maxAge: 60 * 10, // 10 minutes
});
// Build OAuth authorization URL
const params = new URLSearchParams({
client_id: String(config.ghostguildClientId || ""),
redirect_uri: `${config.public.siteUrl}/api/auth/callback`,
response_type: "code",
scope: "read:user read:member",
state: state,
});
const authUrl = `${config.ghostguildApiUrl}/oauth/authorize?${params}`;
// Redirect to Ghost Guild OAuth
return sendRedirect(event, authUrl);
});

View file

@ -1,10 +0,0 @@
export default defineEventHandler(async (event) => {
// Clear the auth token cookie
deleteCookie(event, 'auth-token')
// Return success response
return {
success: true,
message: 'Logged out successfully'
}
})

View file

@ -1,53 +0,0 @@
import jwt from "jsonwebtoken";
import { User } from "../../models/User";
export default defineEventHandler(async (event) => {
const config = useRuntimeConfig();
const token = getCookie(event, "auth-token");
if (!token) {
throw createError({
statusCode: 401,
statusMessage: "Unauthorized - No token provided",
});
}
try {
// Verify and decode the token
const decoded = jwt.verify(token, config.jwtSecret as string) as any;
// Get fresh user data from database
const user = await User.findById(decoded.userId).select("-__v");
if (!user) {
throw createError({
statusCode: 404,
statusMessage: "User not found",
});
}
// Return user data (without sensitive fields)
return {
id: user._id,
username: user.username,
displayName: user.displayName,
email: user.email,
avatar: user.avatar,
roles: user.roles,
permissions: user.permissions,
contributions: user.contributions,
};
} catch (error: any) {
if (
error.name === "JsonWebTokenError" ||
error.name === "TokenExpiredError"
) {
deleteCookie(event, "auth-token");
throw createError({
statusCode: 401,
statusMessage: "Invalid or expired token",
});
}
throw error;
}
});

View file

@ -1,32 +0,0 @@
import mongoose from 'mongoose'
export default defineEventHandler(async (event) => {
const checks = {
status: 'ok',
timestamp: new Date().toISOString(),
uptime: process.uptime(),
mongodb: 'disconnected',
memory: process.memoryUsage(),
}
// Check MongoDB connection
try {
if (mongoose.connection.readyState === 1) {
checks.mongodb = 'connected'
} else {
checks.mongodb = 'disconnected'
}
} catch (error) {
checks.mongodb = 'error'
}
// Return 503 if any critical service is down
const isHealthy = checks.mongodb === 'connected'
if (!isHealthy) {
setResponseStatus(event, 503)
checks.status = 'unhealthy'
}
return checks
})

View file

@ -1,117 +0,0 @@
import { Schema, model, Document, Types } from "mongoose";
export interface IRevision {
content: string;
author: Types.ObjectId;
message: string;
createdAt: Date;
}
export interface IComment {
author: Types.ObjectId;
content: string;
parentComment?: Types.ObjectId;
createdAt: Date;
updatedAt: Date;
resolved: boolean;
}
export interface IArticle extends Document {
slug: string;
title: string;
description: string;
content: string; // Current content in markdown
category: string;
tags: string[];
// Access control
accessLevel: "public" | "member" | "cohort" | "admin";
cohorts?: string[]; // Specific cohorts if accessLevel is 'cohort'
// Metadata
author: Types.ObjectId;
contributors: Types.ObjectId[];
views: number;
likes: number;
// Editing
status: "draft" | "published" | "archived";
lockedBy?: Types.ObjectId; // If someone is currently editing
lockedAt?: Date;
// History
revisions: IRevision[];
comments: IComment[];
// Timestamps
createdAt: Date;
updatedAt: Date;
publishedAt?: Date;
}
const revisionSchema = new Schema<IRevision>({
content: { type: String, required: true },
author: { type: Schema.Types.ObjectId, ref: "User", required: true },
message: { type: String, required: true },
createdAt: { type: Date, default: Date.now },
});
const commentSchema = new Schema<IComment>(
{
author: { type: Schema.Types.ObjectId, ref: "User", required: true },
content: { type: String, required: true },
parentComment: { type: Schema.Types.ObjectId, ref: "Comment" },
resolved: { type: Boolean, default: false },
},
{
timestamps: true,
},
);
const articleSchema = new Schema<IArticle>(
{
slug: { type: String, required: true, unique: true },
title: { type: String, required: true },
description: { type: String, required: true },
content: { type: String, required: true },
category: { type: String, required: true },
tags: [{ type: String }],
accessLevel: {
type: String,
enum: ["public", "member", "cohort", "admin"],
default: "public",
},
cohorts: [{ type: String }],
author: { type: Schema.Types.ObjectId, ref: "User", required: true },
contributors: [{ type: Schema.Types.ObjectId, ref: "User" }],
views: { type: Number, default: 0 },
likes: { type: Number, default: 0 },
status: {
type: String,
enum: ["draft", "published", "archived"],
default: "draft",
},
lockedBy: { type: Schema.Types.ObjectId, ref: "User" },
lockedAt: Date,
revisions: [revisionSchema],
comments: [commentSchema],
publishedAt: Date,
},
{
timestamps: true,
},
);
// Indexes for better query performance
articleSchema.index({ slug: 1 });
articleSchema.index({ accessLevel: 1, status: 1 });
articleSchema.index({ tags: 1 });
articleSchema.index({ category: 1 });
articleSchema.index({ author: 1 });
export const Article = model<IArticle>("Article", articleSchema);

View file

@ -1,47 +0,0 @@
import { Schema, model, Document } from 'mongoose'
export interface IUser extends Document {
ghostguildId: string // ID from ghostguild-org
email: string
username: string
displayName: string
avatar?: string
roles: string[] // 'admin', 'moderator', 'member', 'cohort-X'
permissions: {
canEdit: boolean
canModerate: boolean
canAdmin: boolean
}
contributions: {
edits: number
comments: number
articles: number
}
createdAt: Date
updatedAt: Date
lastLogin: Date
}
const userSchema = new Schema<IUser>({
ghostguildId: { type: String, required: true, unique: true },
email: { type: String, required: true, unique: true },
username: { type: String, required: true, unique: true },
displayName: { type: String, required: true },
avatar: String,
roles: [{ type: String }],
permissions: {
canEdit: { type: Boolean, default: false },
canModerate: { type: Boolean, default: false },
canAdmin: { type: Boolean, default: false }
},
contributions: {
edits: { type: Number, default: 0 },
comments: { type: Number, default: 0 },
articles: { type: Number, default: 0 }
},
lastLogin: { type: Date, default: Date.now }
}, {
timestamps: true
})
export const User = model<IUser>('User', userSchema)

View file

@ -1,20 +0,0 @@
import mongoose from "mongoose";
export default defineNitroPlugin(async () => {
const config = useRuntimeConfig();
try {
await mongoose.connect(config.mongodbUri as string);
console.log("✓ MongoDB connected successfully");
} catch (error) {
console.error("✗ MongoDB connection error:", error);
throw error;
}
// Graceful shutdown
process.on("SIGINT", async () => {
await mongoose.connection.close();
console.log("MongoDB connection closed");
process.exit(0);
});
});

View file

@ -1,91 +0,0 @@
/**
* Wikilink Transformation Plugin
*
* Transforms Obsidian wikilink syntax to standard markdown links at build time.
* Handles:
* - [[Page Title]] [Page Title](/articles/page-title)
* - [[Page|Display Text]] [Display Text](/articles/page)
* - ![[image.jpg]] ![image.jpg](/img/image.jpg)
* - ![[image.jpg|alt text]] ![alt text](/img/image.jpg)
*
* Runs during content:file:beforeParse hook for early transformation
*/
export default defineNitroPlugin((nitroApp) => {
nitroApp.hooks.hook('content:file:beforeParse' as any, (file: any) => {
// Only process markdown files in articles collection
if (file._id && file._id.endsWith('.md') && file._id.includes('/articles/')) {
if (file.body) {
file.body = transformWikilinks(file.body);
file.body = transformImageEmbeds(file.body);
}
}
});
});
/**
* Transform Obsidian wikilinks to markdown links
* Patterns:
* - [[P0 - Page Title]]
* - [[Page Title|Display Text]]
* - [[P1 - Long Title|Short]]
*/
function transformWikilinks(content: string): string {
// Pattern: [[P0/P1/P2 - Page Title]] or [[Page Title]] with optional |Display Text
const wikilinkPattern = /\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g;
return content.replace(wikilinkPattern, (match, pageName, displayText) => {
// Clean the page name: strip P0/P1/P2 prefix
const cleanName = pageName.replace(/^P[012]\s*-\s*/i, '').trim();
// Generate slug from clean name
const slug = titleToSlug(cleanName);
// Use display text if provided, otherwise use clean name
const linkText = displayText ? displayText.trim() : cleanName;
// Generate markdown link with /articles/ prefix
return `[${linkText}](/articles/${slug})`;
});
}
/**
* Transform Obsidian image embeds to markdown image syntax
* Patterns:
* - ![[image.jpg]]
* - ![[image.jpg|alt text]]
*/
function transformImageEmbeds(content: string): string {
// Pattern: ![[filename]] or ![[filename|alt text]]
const imagePattern = /!\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g;
return content.replace(imagePattern, (match, filename, altText) => {
// Use alt text if provided, otherwise use filename (without extension)
const alt = altText?.trim() || filename.replace(/\.(jpg|jpeg|png|gif|svg|webp)$/i, '');
// Generate markdown image syntax with /img/ prefix
return `![${alt}](/img/${filename})`;
});
}
/**
* Convert a page title to a URL slug
* Rules:
* 1. Convert to lowercase
* 2. Remove quotation marks
* 3. Replace & with "and"
* 4. Remove other special characters (keep alphanumeric and hyphens)
* 5. Replace spaces with hyphens
* 6. Collapse multiple hyphens to single hyphen
* 7. Trim hyphens from start/end
*/
function titleToSlug(title: string): string {
return title
.toLowerCase()
.replace(/['"]/g, '') // Remove quotes
.replace(/&/g, 'and') // & → and
.replace(/[^a-z0-9\s-]/g, '') // Remove special characters
.replace(/\s+/g, '-') // Spaces → hyphens
.replace(/-+/g, '-') // Collapse multiple hyphens
.replace(/^-|-$/g, ''); // Trim hyphens from edges
}

View file

@ -1,3 +0,0 @@
{
"extends": "../.nuxt/tsconfig.server.json"
}

12
app/server/types.d.ts vendored
View file

@ -1,12 +0,0 @@
declare module "nitropack" {
interface NitroRuntimeConfig {
mongodbUri: string;
jwtSecret: string;
ghostguildApiUrl: string;
ghostguildApiKey: string;
ghostguildClientId: string;
ghostguildClientSecret: string;
}
}
export {};

View file

@ -1,100 +0,0 @@
import jwt from "jsonwebtoken";
import type { H3Event } from "h3";
import { User } from "../models/User";
export interface AuthUser {
userId: string;
username: string;
roles: string[];
permissions: {
canEdit: boolean;
canModerate: boolean;
canAdmin: boolean;
};
}
export async function verifyAuth(event: H3Event): Promise<AuthUser | null> {
const config = useRuntimeConfig();
const token = getCookie(event, "auth-token");
if (!token) {
return null;
}
try {
const decoded = jwt.verify(token, config.jwtSecret as string) as AuthUser;
return decoded;
} catch (error) {
return null;
}
}
export async function requireAuth(event: H3Event): Promise<AuthUser> {
const user = await verifyAuth(event);
if (!user) {
throw createError({
statusCode: 401,
statusMessage: "Authentication required",
});
}
return user;
}
export async function requireRole(
event: H3Event,
requiredRoles: string[],
): Promise<AuthUser> {
const user = await requireAuth(event);
const hasRole = requiredRoles.some((role) => user.roles.includes(role));
if (!hasRole) {
throw createError({
statusCode: 403,
statusMessage: "Insufficient permissions",
});
}
return user;
}
export async function checkAccessLevel(
event: H3Event,
accessLevel: "public" | "member" | "cohort" | "admin",
cohorts?: string[],
): Promise<boolean> {
// Public content is always accessible
if (accessLevel === "public") {
return true;
}
const user = await verifyAuth(event);
// No user = no access to protected content
if (!user) {
return false;
}
// Check access levels
switch (accessLevel) {
case "member":
// Any authenticated user has member access
return true;
case "cohort":
// Check if user belongs to required cohorts
if (!cohorts || cohorts.length === 0) {
return true; // No specific cohort required
}
return cohorts.some((cohort) => user.roles.includes(`cohort-${cohort}`));
case "admin":
// Only admins have admin access
return user.permissions.canAdmin || user.roles.includes("admin");
default:
return false;
}
}

View file

@ -1,46 +0,0 @@
export interface User {
id: string;
username: string;
displayName: string;
email: string;
avatar?: string;
roles: string[];
permissions: {
canEdit: boolean;
canModerate: boolean;
canAdmin: boolean;
};
contributions: {
articles: number;
edits: number;
comments: number;
};
}
export interface AuthUser {
userId: string;
username: string;
roles: string[];
permissions: {
canEdit: boolean;
canModerate: boolean;
canAdmin: boolean;
};
}
export interface OAuthTokenResponse {
access_token: string;
token_type: string;
expires_in: number;
refresh_token?: string;
scope?: string;
}
export interface GhostGuildUserInfo {
id: string;
email: string;
username: string;
displayName?: string;
avatar?: string;
roles?: string[];
}

View file

@ -1,26 +0,0 @@
import { defineContentConfig, defineCollection } from "@nuxt/content";
import { z } from "zod";
export default defineContentConfig({
collections: {
articles: defineCollection({
type: "page",
source: {
include: "articles/*.md",
prefix: "/",
},
schema: z.object({
title: z.string(),
description: z.string().optional(),
category: z.string().optional(),
tags: z.array(z.string()).optional(),
author: z.string().optional(),
contributors: z.array(z.string()).optional(),
published: z.boolean().default(true),
featured: z.boolean().default(false),
accessLevel: z.string().optional(),
publishedAt: z.string().optional(),
}),
}),
},
});

View file

@ -1,64 +1,62 @@
version: '3.8'
services: services:
# MongoDB Database outline:
mongodb: image: docker.getoutline.com/outlinewiki/outline:latest
image: mongo:7 container_name: outline
container_name: wiki-mongodb
restart: unless-stopped restart: unless-stopped
ports: ports:
- "27017:27017" - "127.0.0.1:3100:3000"
environment: env_file: ./outline.env
MONGO_INITDB_ROOT_USERNAME: admin
MONGO_INITDB_ROOT_PASSWORD: changeme
MONGO_INITDB_DATABASE: wiki-ghostguild
volumes:
- mongodb_data:/data/db
- ./docker/init-mongo.js:/docker-entrypoint-initdb.d/init-mongo.js:ro
networks:
- wiki-network
# Wiki Application (for local development)
# Uncomment to run the app in Docker
# wiki:
# build: .
# container_name: wiki-app
# restart: unless-stopped
# ports:
# - "3000:3000"
# environment:
# MONGODB_URI: mongodb://admin:changeme@mongodb:27017/wiki-ghostguild?authSource=admin
# NODE_ENV: development
# depends_on:
# - mongodb
# networks:
# - wiki-network
# volumes:
# - ./app:/app/app
# - ./server:/app/server
# - ./content:/app/content
# Optional: Mongo Express for database management
mongo-express:
image: mongo-express:latest
container_name: wiki-mongo-express
restart: unless-stopped
ports:
- "8081:8081"
environment:
ME_CONFIG_MONGODB_ADMINUSERNAME: admin
ME_CONFIG_MONGODB_ADMINPASSWORD: changeme
ME_CONFIG_MONGODB_URL: mongodb://admin:changeme@mongodb:27017/
ME_CONFIG_BASICAUTH: false
depends_on: depends_on:
- mongodb postgres:
networks: condition: service_healthy
- wiki-network redis:
condition: service_healthy
volumes:
- outline-storage:/var/lib/outline/data
postgres:
image: postgres:16-alpine
container_name: outline-postgres
restart: unless-stopped
environment:
POSTGRES_USER: outline
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: outline
volumes:
- postgres-data:/var/lib/postgresql/data
command:
- "postgres"
- "-c"
- "shared_buffers=128MB"
- "-c"
- "max_connections=20"
- "-c"
- "work_mem=4MB"
- "-c"
- "maintenance_work_mem=64MB"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U outline"]
interval: 10s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
container_name: outline-redis
restart: unless-stopped
command: >
redis-server
--maxmemory 64mb
--maxmemory-policy allkeys-lru
volumes:
- redis-data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
volumes: volumes:
mongodb_data: outline-storage:
driver: local postgres-data:
redis-data:
networks:
wiki-network:
driver: bridge

View file

@ -1,44 +1,31 @@
# DokPloy Configuration for Wiki.GhostGuild.org # Dokploy Configuration for Wiki.GhostGuild.org
# Outline wiki deployed via docker-compose
name: wiki-ghostguild name: wiki-ghostguild
type: nixpacks type: compose
# Build Configuration # Build Configuration
build: build:
dockerfile: null # Using Nixpacks auto-detection composeFile: docker-compose.yml
context: .
# Environment Configuration # Secrets (configured in Dokploy UI)
env: # These should be set in the Dokploy interface, not in this file:
NODE_ENV: production # - POSTGRES_PASSWORD (used by docker-compose for the postgres service)
NITRO_PRESET: node-server # - All Outline env vars are in outline.env on the server
# Secrets (configured in DokPloy UI)
# These should be set in DokPloy interface, not in this file:
# - MONGODB_URI
# - JWT_SECRET
# - JWT_REFRESH_SECRET
# - GHOSTGUILD_CLIENT_ID
# - GHOSTGUILD_CLIENT_SECRET
# - GHOSTGUILD_API_KEY
# Port Configuration
ports:
- 3000:3000
# Health Check # Health Check
healthcheck: healthcheck:
path: /api/health path: /api/health
interval: 30 interval: 30
timeout: 10 timeout: 10
retries: 3 retries: 5
# Resource Limits # Resource Limits (Outline + Postgres + Redis combined)
resources: resources:
limits: limits:
memory: 1GB memory: 3GB
cpu: 1 cpu: 2
requests: requests:
memory: 512MB memory: 1GB
cpu: 0.5 cpu: 0.5
# Domains # Domains
@ -58,34 +45,8 @@ deploy:
maxSurge: 1 maxSurge: 1
maxUnavailable: 0 maxUnavailable: 0
# Volume Mounts (for persistent data)
volumes:
- name: content
path: /app/content
size: 5GB
- name: uploads
path: /app/public/uploads
size: 10GB
# Commands
scripts:
prebuild: |
echo "Installing dependencies..."
npm ci
build: |
echo "Building application..."
npm run build
start: |
echo "Starting production server..."
node .output/server/index.mjs
# Monitoring
monitoring:
enabled: true
metrics_path: /metrics
# Backup Configuration # Backup Configuration
backup: backup:
enabled: true enabled: true
schedule: "0 2 * * *" # Daily at 2 AM schedule: "0 2 * * *" # Daily at 2 AM
retention: 30 # Keep 30 days of backups retention: 14 # Keep 14 days of backups

View file

@ -22,45 +22,59 @@ http {
tcp_nodelay on; tcp_nodelay on;
keepalive_timeout 65; keepalive_timeout 65;
types_hash_max_size 2048; types_hash_max_size 2048;
client_max_body_size 20M; client_max_body_size 50M;
gzip on; gzip on;
gzip_vary on; gzip_vary on;
gzip_min_length 1024; gzip_min_length 1024;
gzip_types text/plain text/css text/xml text/javascript gzip_types text/plain text/css text/xml text/javascript
application/x-javascript application/xml+rss application/javascript application/json
application/javascript application/json; application/xml+rss image/svg+xml;
upstream outline {
server 127.0.0.1:3100;
}
server { server {
listen 80 default_server; listen 80 default_server;
listen [::]:80 default_server; listen [::]:80 default_server;
server_name _; server_name wiki.ghostguild.org;
root /usr/share/nginx/html; # Serve custom theme files
index index.html; location /custom/ {
alias /opt/ghost-guild-wiki-theme/;
expires 1h;
add_header Cache-Control "public";
}
# Health check endpoint # Health check
location /api/health { location = /api/health {
proxy_pass http://outline;
access_log off; access_log off;
add_header Content-Type application/json;
return 200 '{"status":"ok"}';
} }
# Static files # Reverse proxy to Outline with CSS injection
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# HTML files - no cache
location ~* \.html$ {
expires -1;
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate";
}
# SPA fallback - route all unmatched requests to index.html
location / { location / {
try_files $uri $uri/ /index.html; proxy_pass http://outline;
# Standard proxy headers
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Disable encoding so sub_filter can process the response
proxy_set_header Accept-Encoding "";
# WebSocket support (required for real-time collaboration)
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
# Inject custom CSS before </head>
sub_filter '</head>' '<link rel="stylesheet" href="/custom/ghost-guild.css" /></head>';
sub_filter_once on;
sub_filter_types text/html;
} }
} }
} }

View file

@ -1,8 +0,0 @@
[phases.install]
commands = ["npm install --ignore-scripts", "npm rebuild"]
[phases.build]
commands = ["npm run postinstall", "npm run build"]
[variables]
OXCPARSER_SKIP_NATIVE_BINDING="1"

View file

@ -1,73 +0,0 @@
// https://nuxt.com/docs/api/configuration/nuxt-config
export default defineNuxtConfig({
future: {
compatibilityVersion: 4,
},
srcDir: "app/",
compatibilityDate: "2024-11-01",
devtools: { enabled: true },
modules: ["@nuxt/ui", "@nuxt/content", "nuxt-auth-utils", "@vueuse/nuxt"],
runtimeConfig: {
// Private runtime config (server-side only)
mongodbUri:
process.env.MONGODB_URI || "mongodb://localhost:27017/wiki-ghostguild",
jwtSecret: process.env.JWT_SECRET || "your-secret-key-change-in-production",
ghostguildApiUrl:
process.env.GHOSTGUILD_API_URL || "https://ghostguild.org/api",
ghostguildApiKey: process.env.GHOSTGUILD_API_KEY || "",
ghostguildClientId: process.env.GHOSTGUILD_CLIENT_ID || "",
ghostguildClientSecret: process.env.GHOSTGUILD_CLIENT_SECRET || "",
// Public runtime config (client-side)
public: {
siteUrl: process.env.SITE_URL || "https://wiki.ghostguild.org",
siteName: "Ghost Guild Knowledge Commons",
siteDescription: "A wiki for ghosts! 👻",
},
},
nitro: {
experimental: {
wasm: true,
},
prerender: {
routes: ["/", "/articles", "/articles/new"],
failOnError: false,
},
},
typescript: {
strict: true,
typeCheck: true,
},
css: ["~/assets/css/main.css"],
app: {
head: {
link: [
{
rel: "preconnect",
href: "https://fonts.googleapis.com",
},
{
rel: "preconnect",
href: "https://fonts.gstatic.com",
crossorigin: "",
},
{
rel: "stylesheet",
href: "https://fonts.googleapis.com/css2?family=Crimson+Text:ital,wght@0,400;0,600;0,700;1,400;1,600&display=swap",
},
],
},
},
vite: {
build: {
minify: "esbuild",
},
},
});

61
outline.env.example Normal file
View file

@ -0,0 +1,61 @@
# =============================================================================
# Outline Wiki — Environment Configuration
# Copy to outline.env and fill in all values before running docker compose up.
# =============================================================================
# ---------------------
# Core secrets
# ---------------------
# Generate each with: openssl rand -hex 32
SECRET_KEY=
UTILS_SECRET=
# ---------------------
# Database
# ---------------------
DATABASE_URL=postgres://outline:CHANGE_ME@postgres:5432/outline
REDIS_URL=redis://redis:6379
# ---------------------
# Application
# ---------------------
URL=https://wiki.ghostguild.org
PORT=3000
FORCE_HTTPS=true
# File storage — local (stored in Docker volume)
FILE_STORAGE=local
FILE_STORAGE_LOCAL_ROOT_DIR=/var/lib/outline/data
# ---------------------
# OIDC Authentication (Ghost Guild SSO)
# ---------------------
OIDC_CLIENT_ID=outline-wiki
OIDC_CLIENT_SECRET=CHANGE_ME
OIDC_AUTH_URI=https://ghostguild.org/oidc/auth
OIDC_TOKEN_URI=https://ghostguild.org/oidc/token
OIDC_USERINFO_URI=https://ghostguild.org/oidc/me
OIDC_LOGOUT_URI=https://ghostguild.org/oidc/session/end
OIDC_DISPLAY_NAME=Ghost Guild
OIDC_SCOPES=openid profile email
# ---------------------
# Email (via Resend SMTP)
# ---------------------
SMTP_HOST=smtp.resend.com
SMTP_PORT=465
SMTP_SECURE=true
SMTP_USERNAME=resend
SMTP_PASSWORD=CHANGE_ME
SMTP_FROM_EMAIL=wiki@ghostguild.org
SMTP_REPLY_EMAIL=support@ghostguild.org
# ---------------------
# Optional
# ---------------------
# PGSSLMODE=disable
# LOG_LEVEL=info
# DEFAULT_LANGUAGE=en_US
# RATE_LIMITER_ENABLED=true
# RATE_LIMITER_REQUESTS=1000
# RATE_LIMITER_DURATION_WINDOW=60

15050
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,38 +0,0 @@
{
"name": "wiki-ghostguild",
"private": true,
"type": "module",
"engines": {
"node": "^20.19.0"
},
"scripts": {
"build": "nuxt generate",
"dev": "nuxt dev",
"generate": "nuxt generate",
"preview": "nuxt preview",
"start": "node .output/server/index.mjs",
"postinstall": "nuxt prepare || true"
},
"dependencies": {
"@nuxt/content": "^3.7.1",
"@nuxt/ui": "^3.3.7",
"@tailwindcss/typography": "^0.5.19",
"@types/jsonwebtoken": "^9.0.10",
"@types/marked": "^5.0.2",
"@types/mongoose": "^5.11.96",
"@vueuse/nuxt": "^14.0.0",
"better-sqlite3": "^12.4.1",
"jsonwebtoken": "^9.0.2",
"marked": "^17.0.0",
"mongoose": "^8.19.3",
"nuxt": "^4.2.1",
"nuxt-auth-utils": "^0.5.25",
"vue": "^3.5.22",
"vue-router": "^4.6.3"
},
"devDependencies": {
"@types/node": "^24.10.0",
"typescript": "^5.9.3",
"vue-tsc": "^3.1.3"
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

View file

@ -1,11 +0,0 @@
# Image directory
# Images are stored here and referenced from articles via /img/filename
# Users can add images by:
# 1. Opening /content/articles/ as Obsidian vault
# 2. Pasting images in articles (auto-saves here)
# 3. Or manually adding images to this directory
# Naming convention:
# - Use lowercase, hyphens (not spaces)
# - Example: article-diagram.jpg

Binary file not shown.

Before

Width:  |  Height:  |  Size: 288 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 61 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 231 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 559 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 454 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 503 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 595 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 320 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 217 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 561 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 640 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 505 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 928 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 806 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 455 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 486 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 540 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 590 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 591 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 308 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 603 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 454 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 308 KiB

View file

@ -1,2 +0,0 @@
User-Agent: *
Disallow:

View file

@ -1,39 +0,0 @@
#!/bin/bash
# Audit script to identify missing image references
echo "=== Image Reference Audit ==="
echo ""
echo "Finding all image references in articles..."
echo ""
REFS=$(grep -rh '!\[' content/articles/ 2>/dev/null | grep -oE '\(/img/[^)]+\)' | sed 's/(//' | sed 's/)//' | sort -u)
echo "Total image references found: $(echo "$REFS" | wc -l)"
echo ""
echo "Checking which exist in /public/img/:"
echo ""
MISSING_COUNT=0
EXISTING_COUNT=0
while read -r ref; do
if [ -z "$ref" ]; then continue; fi
filename=$(basename "$ref")
fullpath="/Users/jennie/Sites/wiki-ghostguild${ref}"
if [ -f "$fullpath" ]; then
echo "$filename"
((EXISTING_COUNT++))
else
echo "✗ MISSING: $filename"
((MISSING_COUNT++))
fi
done <<< "$REFS"
echo ""
echo "=== Summary ==="
echo "Existing images: $EXISTING_COUNT"
echo "Missing images: $MISSING_COUNT"
echo ""
echo "Missing images need to be added to /public/img/ or updated in article markdown."

130
scripts/migrate-content.js Normal file
View 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);
});

39
scripts/outline-backup.sh Executable file
View file

@ -0,0 +1,39 @@
#!/usr/bin/env bash
set -euo pipefail
# =============================================================================
# Outline Wiki Backup Script
# Backs up PostgreSQL database and file storage from Docker containers.
#
# Usage:
# ./outline-backup.sh [backup_dir]
#
# Crontab (daily at 3 AM):
# 0 3 * * * /path/to/outline-backup.sh /backups/outline >> /var/log/outline-backup.log 2>&1
# =============================================================================
BACKUP_DIR="${1:-/backups/outline}"
DATE=$(date +%Y-%m-%d_%H%M)
RETENTION_DAYS=14
mkdir -p "$BACKUP_DIR"
echo "=== Outline Backup — $DATE ==="
# PostgreSQL dump
echo "Backing up PostgreSQL..."
docker exec outline-postgres pg_dump -U outline -Fc outline \
> "$BACKUP_DIR/outline-db-$DATE.dump"
echo " Database: outline-db-$DATE.dump"
# File storage backup
echo "Backing up file storage..."
docker cp outline:/var/lib/outline/data - \
| gzip > "$BACKUP_DIR/outline-files-$DATE.tar.gz"
echo " Files: outline-files-$DATE.tar.gz"
# Prune old backups
echo "Pruning backups older than $RETENTION_DAYS days..."
find "$BACKUP_DIR" -type f -mtime +$RETENTION_DAYS -delete
echo "=== Backup complete ==="

7
scripts/package.json Normal file
View file

@ -0,0 +1,7 @@
{
"private": true,
"type": "module",
"dependencies": {
"gray-matter": "^4.0.3"
}
}

View file

@ -1,200 +0,0 @@
import type { Config } from "tailwindcss";
import defaultTheme from "tailwindcss/defaultTheme";
export default {
content: [
"./app/components/**/*.{js,vue,ts}",
"./app/layouts/**/*.vue",
"./app/pages/**/*.vue",
"./app/plugins/**/*.{js,ts}",
"./app/app.vue",
"./app/error.vue",
"./app/**/*.{js,vue,ts}",
"./content/**/*.{md,mdc,json,yml,yaml}",
],
theme: {
extend: {
fontFamily: {
sans: ["Inter var", ...defaultTheme.fontFamily.sans],
serif: ["Crimson Text", "Georgia", ...defaultTheme.fontFamily.serif],
},
typography: ({ theme }) => ({
DEFAULT: {
css: {
fontFamily: "'Crimson Text', Georgia, serif",
fontSize: "1.125rem",
lineHeight: "1.8",
color: theme("colors.gray.900"),
maxWidth: "none",
h1: {
fontFamily: "'Crimson Text', Georgia, serif",
fontWeight: "700",
fontSize: "2.5rem",
marginTop: "2rem",
marginBottom: "1rem",
lineHeight: "1.2",
},
h2: {
fontFamily: "'Crimson Text', Georgia, serif",
fontWeight: "600",
fontSize: "2rem",
marginTop: "1.75rem",
marginBottom: "0.75rem",
lineHeight: "1.3",
},
h3: {
fontFamily: "'Crimson Text', Georgia, serif",
fontWeight: "600",
fontSize: "1.5rem",
marginTop: "1.5rem",
marginBottom: "0.5rem",
lineHeight: "1.4",
},
p: {
marginTop: "1.25rem",
marginBottom: "1.25rem",
fontSize: "1.125rem",
lineHeight: "1.8",
color: theme("colors.gray.700"),
},
li: {
marginTop: "0.5rem",
marginBottom: "0.5rem",
fontSize: "1.125rem",
lineHeight: "1.8",
},
"ul > li": {
paddingLeft: "1.75rem",
},
"ol > li": {
paddingLeft: "1.75rem",
},
"ol li::marker": {
color: theme("colors.gray.400"),
fontWeight: "600",
},
"ul li::marker": {
color: theme("colors.gray.400"),
},
a: {
color: theme("colors.blue.600"),
textDecoration: "underline",
fontWeight: "500",
"&:hover": {
color: theme("colors.blue.800"),
},
},
strong: {
fontWeight: "700",
},
code: {
fontFamily:
"'SFMono-Regular', ui-monospace, 'JetBrains Mono', monospace",
fontSize: "0.875em",
fontWeight: "500",
backgroundColor: theme("colors.gray.100"),
padding: "0.15em 0.35em",
borderRadius: theme("borderRadius.lg"),
},
"code::before": {
content: '""',
},
"code::after": {
content: '""',
},
blockquote: {
fontStyle: "italic",
borderLeftColor: theme("colors.gray.300"),
borderLeftWidth: "0.25rem",
paddingLeft: "1rem",
marginTop: "1.5rem",
marginBottom: "1.5rem",
color: theme("colors.gray.600"),
},
pre: {
fontFamily:
"'SFMono-Regular', ui-monospace, 'JetBrains Mono', monospace",
backgroundColor: theme("colors.gray.100"),
borderRadius: theme("borderRadius.xl"),
padding: "1.5rem",
color: theme("colors.gray.800"),
},
hr: {
borderColor: theme("colors.gray.200"),
},
},
},
lg: {
css: {
fontSize: "1.25rem",
lineHeight: "1.8",
p: {
fontSize: "1.25rem",
lineHeight: "1.8",
},
li: {
fontSize: "1.25rem",
lineHeight: "1.8",
},
h1: {
fontSize: "3rem",
},
h2: {
fontSize: "2.25rem",
},
h3: {
fontSize: "1.75rem",
},
},
},
invert: {
css: {
color: theme("colors.gray.100"),
h1: {
color: theme("colors.gray.100"),
},
h2: {
color: theme("colors.gray.100"),
},
h3: {
color: theme("colors.gray.100"),
},
h4: {
color: theme("colors.gray.100"),
},
h5: {
color: theme("colors.gray.100"),
},
h6: {
color: theme("colors.gray.100"),
},
strong: {
color: theme("colors.gray.100"),
},
a: {
color: theme("colors.blue.400"),
"&:hover": {
color: theme("colors.blue.300"),
},
},
blockquote: {
borderLeftColor: theme("colors.gray.700"),
color: theme("colors.gray.300"),
},
code: {
color: theme("colors.gray.100"),
backgroundColor: theme("colors.gray.800"),
},
pre: {
backgroundColor: theme("colors.gray.800"),
color: theme("colors.gray.100"),
},
hr: {
borderColor: theme("colors.gray.700"),
},
},
},
}),
},
},
} satisfies Config;

61
theme/ghost-guild.css Normal file
View file

@ -0,0 +1,61 @@
/*
* Ghost Guild Outline Wiki Custom Theme
*
* Injected via nginx sub_filter into every Outline page.
* Served from /custom/ghost-guild.css.
*
* NOTE: Outline's internal class names and DOM structure may change between
* versions. After upgrading Outline, verify these selectors still work.
*/
/* ---------------------------------------------------------------------------
Brand color overrides
--------------------------------------------------------------------------- */
:root {
--ghost-guild-primary: #2563eb;
--ghost-guild-bg: #fafafa;
--ghost-guild-text: #1a1a2e;
}
/* ---------------------------------------------------------------------------
Hide Outline branding
--------------------------------------------------------------------------- */
/* "Built with Outline" footer link */
a[href="https://www.getoutline.com"] {
display: none !important;
}
/* Outline logo in sidebar */
[data-testid="sidebar-logo"],
a[href="/"] > svg {
/* Replace with Ghost Guild logo via background-image if desired:
background-image: url(/custom/logo.svg);
background-size: contain;
background-repeat: no-repeat;
*/
}
/* ---------------------------------------------------------------------------
Typography
--------------------------------------------------------------------------- */
/* Placeholder: uncomment and update with Ghost Guild fonts
@font-face {
font-family: "GhostGuild";
src: url("/custom/fonts/ghost-guild.woff2") format("woff2");
font-weight: 400;
font-style: normal;
font-display: swap;
}
*/
/* ---------------------------------------------------------------------------
Layout adjustments
--------------------------------------------------------------------------- */
/* Widen the document area slightly */
.document-editor,
[class*="DocumentEditor"] {
max-width: 48rem;
}

View file

@ -1,4 +0,0 @@
{
// https://nuxt.com/docs/guide/concepts/typescript
"extends": "./.nuxt/tsconfig.json"
}