Fix bugs and implement Authentik SSO login

- frontmatter: replace custom YAML parser with gray-matter; add private field (was silently dropped on save)
- types: add private? to NoteMeta so the field propagates through NoteContent
- use-notes: fix all subtopic traversal bugs (deleteNote, renameNote, toggleDraft, updateActiveNote, loadNotes first-select) using recursive mapTopicNotes/filterTopicTree helpers
- api: remove unused moveNote duplicate (moveNoteApi is the correct export)
- auth: add setPendingServerUrl/getPendingServerUrl/clearPendingServerUrl for Authentik redirect flow
- login: implement Authentik SSO flow — save server URL, redirect to /api/studio/auth/authentik, detect #token= on return; show/hide token vs Authentik sections based on server config
- preview: fix useRef type error (missing undefined initializer)
This commit is contained in:
Bruno Deanoz 2026-05-19 09:17:26 +02:00
parent dc8c3aa6f5
commit 177f3d5ab9
10 changed files with 238 additions and 105 deletions

2
next-env.d.ts vendored
View file

@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View file

@ -25,6 +25,7 @@
"@codemirror/state": "^6.5.2",
"@codemirror/view": "^6.36.3",
"@lezer/highlight": "^1.2.1",
"gray-matter": "^4.0.3",
"highlight.js": "^11.11.1",
"katex": "^0.16.47",
"marked": "^15.0.12",

View file

@ -32,6 +32,9 @@ importers:
'@lezer/highlight':
specifier: ^1.2.1
version: 1.2.3
gray-matter:
specifier: ^4.0.3
version: 4.0.3
highlight.js:
specifier: ^11.11.1
version: 11.11.1
@ -529,6 +532,9 @@ packages:
'@types/react@19.2.14':
resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==}
argparse@1.0.10:
resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==}
baseline-browser-mapping@2.10.30:
resolution: {integrity: sha512-xjOFN16Ha1+Rz4nFYKqHU/LSB+gx/Vi3yQLX7r7sAW+Wa+8hhF2h4pvqTrTMc8+WcDBEunnUurr46Jvv0jk3Vg==}
engines: {node: '>=6.0.0'}
@ -558,21 +564,46 @@ packages:
resolution: {integrity: sha512-QyL119InA+XXEkNLNTPCXPugSvOfhwv0JOlGNzvxs0hZaiHLNvXSpudUWsOlsXGWJh8G6ckCScEkVHfX3kw/2Q==}
engines: {node: '>=10.13.0'}
esprima@4.0.1:
resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==}
engines: {node: '>=4'}
hasBin: true
extend-shallow@2.0.1:
resolution: {integrity: sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==}
engines: {node: '>=0.10.0'}
graceful-fs@4.2.11:
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
gray-matter@4.0.3:
resolution: {integrity: sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==}
engines: {node: '>=6.0'}
highlight.js@11.11.1:
resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==}
engines: {node: '>=12.0.0'}
is-extendable@0.1.1:
resolution: {integrity: sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==}
engines: {node: '>=0.10.0'}
jiti@2.7.0:
resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==}
hasBin: true
js-yaml@3.14.2:
resolution: {integrity: sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==}
hasBin: true
katex@0.16.47:
resolution: {integrity: sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==}
hasBin: true
kind-of@6.0.3:
resolution: {integrity: sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==}
engines: {node: '>=0.10.0'}
lightningcss-android-arm64@1.32.0:
resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==}
engines: {node: '>= 12.0.0'}
@ -700,6 +731,10 @@ packages:
scheduler@0.27.0:
resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
section-matter@1.0.0:
resolution: {integrity: sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==}
engines: {node: '>=4'}
semver@7.8.0:
resolution: {integrity: sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==}
engines: {node: '>=10'}
@ -717,6 +752,13 @@ packages:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
sprintf-js@1.0.3:
resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==}
strip-bom-string@1.0.0:
resolution: {integrity: sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g==}
engines: {node: '>=0.10.0'}
style-mod@4.1.3:
resolution: {integrity: sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==}
@ -1330,6 +1372,10 @@ snapshots:
dependencies:
csstype: 3.2.3
argparse@1.0.10:
dependencies:
sprintf-js: 1.0.3
baseline-browser-mapping@2.10.30: {}
caniuse-lite@1.0.30001793: {}
@ -1349,16 +1395,38 @@ snapshots:
graceful-fs: 4.2.11
tapable: 2.3.3
esprima@4.0.1: {}
extend-shallow@2.0.1:
dependencies:
is-extendable: 0.1.1
graceful-fs@4.2.11: {}
gray-matter@4.0.3:
dependencies:
js-yaml: 3.14.2
kind-of: 6.0.3
section-matter: 1.0.0
strip-bom-string: 1.0.0
highlight.js@11.11.1: {}
is-extendable@0.1.1: {}
jiti@2.7.0: {}
js-yaml@3.14.2:
dependencies:
argparse: 1.0.10
esprima: 4.0.1
katex@0.16.47:
dependencies:
commander: 8.3.0
kind-of@6.0.3: {}
lightningcss-android-arm64@1.32.0:
optional: true
@ -1463,6 +1531,11 @@ snapshots:
scheduler@0.27.0: {}
section-matter@1.0.0:
dependencies:
extend-shallow: 2.0.1
kind-of: 6.0.3
semver@7.8.0:
optional: true
@ -1502,6 +1575,10 @@ snapshots:
source-map-js@1.2.1: {}
sprintf-js@1.0.3: {}
strip-bom-string@1.0.0: {}
style-mod@4.1.3: {}
styled-jsx@5.1.6(react@19.2.4):

View file

@ -2,24 +2,56 @@
import { useState, useEffect } from "react"
import { useRouter } from "next/navigation"
import { setCredentials, isAuthenticated } from "@/lib/auth"
import { fetchStudioConfig, testToken, NozApiError } from "@/lib/api"
import {
setCredentials, isAuthenticated,
setPendingServerUrl, getPendingServerUrl, clearPendingServerUrl,
} from "@/lib/auth"
import { fetchStudioConfig, testToken, NozApiError, type StudioServerConfig } from "@/lib/api"
export default function LoginPage() {
const router = useRouter()
const devServer = process.env.NEXT_PUBLIC_DEV_SERVER ?? ""
const devToken = process.env.NEXT_PUBLIC_DEV_TOKEN ?? ""
const [url, setUrl] = useState(devServer || "")
const [token, setToken] = useState(devToken)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [url, setUrl] = useState(devServer || "")
const [token, setToken] = useState(devToken)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [serverConf, setServerConf] = useState<StudioServerConfig | null>(null)
// Fixture-Modus oder bereits angemeldet → direkt zum Editor
useEffect(() => {
if (isAuthenticated()) router.replace("/editor")
}, [router])
// Authentik redirect: detect #token= in URL hash on mount
useEffect(() => {
if (typeof window === "undefined") return
const hash = window.location.hash
if (!hash.startsWith("#token=")) return
const incoming = decodeURIComponent(hash.slice(7))
const savedUrl = getPendingServerUrl()
if (!incoming || !savedUrl) return
clearPendingServerUrl()
window.history.replaceState(null, "", window.location.pathname + window.location.search)
setCredentials(savedUrl, incoming)
router.replace("/editor")
}, [router])
// Fetch server config whenever URL changes (debounced)
useEffect(() => {
const trimmed = url.trim()
if (!trimmed) { setServerConf(null); return }
const t = setTimeout(() => {
fetchStudioConfig(trimmed)
.then(cfg => setServerConf(cfg))
.catch(() => setServerConf(null))
}, 500)
return () => clearTimeout(t)
}, [url])
async function handleConnect(e: React.FormEvent) {
e.preventDefault()
if (!url.trim() || !token.trim()) {
@ -31,13 +63,8 @@ export default function LoginPage() {
setError(null)
try {
// Schritt 1: Server als noz-Instanz verifizieren
await fetchStudioConfig(url.trim())
// Schritt 2: Token validieren
await testToken(url.trim(), token.trim())
// Erfolg: Credentials speichern + weiterleiten
setCredentials(url.trim(), token.trim())
router.push("/editor")
} catch (err) {
@ -50,6 +77,17 @@ export default function LoginPage() {
}
}
function handleAuthentik() {
const trimmed = url.trim()
if (!trimmed) return
setPendingServerUrl(trimmed)
window.location.href = `${trimmed.replace(/\/$/, "")}/api/studio/auth/authentik`
}
const showToken = !serverConf || serverConf.auth === "token" || serverConf.auth === "both"
const showAuthentik = !serverConf || serverConf.auth === "authentik" || serverConf.auth === "both"
const authentikEnabled = !!url.trim() && !loading
return (
<div className="min-h-screen flex items-center justify-center bg-background relative overflow-hidden">
{/* Nebula glows */}
@ -85,34 +123,37 @@ export default function LoginPage() {
</div>
</div>
<form onSubmit={handleConnect}>
<div className="bg-muted border border-border rounded-xl overflow-hidden">
<div className="p-6 space-y-4">
<div className="bg-muted border border-border rounded-xl overflow-hidden">
{/* Fehlermeldung */}
{error && (
<div className="mx-6 mt-4 flex items-start gap-2.5 px-3 py-2.5 rounded-lg text-xs font-mono"
style={{ background: "oklch(from var(--draft) l c h / 0.1)", color: "var(--draft)", border: "1px solid oklch(from var(--draft) l c h / 0.25)" }}>
<ErrorIcon />
<span>{error}</span>
</div>
)}
{/* Fehlermeldung */}
{error && (
<div className="flex items-start gap-2.5 px-3 py-2.5 rounded-lg text-xs font-mono"
style={{ background: "oklch(from var(--draft) l c h / 0.1)", color: "var(--draft)", border: "1px solid oklch(from var(--draft) l c h / 0.25)" }}>
<ErrorIcon />
<span>{error}</span>
</div>
)}
<div className="space-y-1.5">
<label className="text-[10px] font-mono text-muted-fg/60 uppercase tracking-widest">
Server
</label>
<input
type="url"
value={url}
onChange={(e) => { setUrl(e.target.value); setError(null) }}
placeholder="https://noz.example.com"
disabled={loading}
required
className="w-full bg-surface border border-border rounded-lg px-3 py-2 text-sm text-foreground placeholder:text-muted-fg/30 focus:outline-none focus:border-accent transition-colors font-mono disabled:opacity-50"
/>
</div>
{/* Server URL field — always shown */}
<div className="px-6 pt-5 pb-4 space-y-4">
<div className="space-y-1.5">
<label className="text-[10px] font-mono text-muted-fg/60 uppercase tracking-widest">
Server
</label>
<input
type="url"
value={url}
onChange={(e) => { setUrl(e.target.value); setError(null) }}
placeholder="https://noz.example.com"
disabled={loading}
required
className="w-full bg-surface border border-border rounded-lg px-3 py-2 text-sm text-foreground placeholder:text-muted-fg/30 focus:outline-none focus:border-accent transition-colors font-mono disabled:opacity-50"
/>
</div>
</div>
{/* Token login */}
{showToken && (
<form onSubmit={handleConnect} className="px-6 pb-5 space-y-4">
<div className="space-y-1.5">
<label className="text-[10px] font-mono text-muted-fg/60 uppercase tracking-widest">
Token
@ -142,29 +183,33 @@ export default function LoginPage() {
"Verbinden"
)}
</button>
</div>
</form>
)}
{/* Divider */}
{/* Divider — only shown when both methods are available */}
{showToken && showAuthentik && (
<div className="flex items-center gap-3 px-6 pb-1">
<div className="flex-1 h-px bg-border" />
<span className="text-[11px] text-muted-fg/40 font-mono">oder</span>
<div className="flex-1 h-px bg-border" />
</div>
)}
{/* Authentik — noch nicht implementiert */}
{/* Authentik SSO */}
{showAuthentik && (
<div className="p-4 pt-3">
<button
type="button"
disabled
title="Noch nicht verfügbar"
className="w-full flex items-center justify-center gap-2.5 py-2.5 border border-border rounded-lg text-sm text-muted-fg/40 cursor-not-allowed"
disabled={!authentikEnabled}
onClick={handleAuthentik}
className="w-full flex items-center justify-center gap-2.5 py-2.5 border border-border rounded-lg text-sm transition-all active:scale-[0.98] disabled:opacity-40 disabled:cursor-not-allowed enabled:hover:border-accent enabled:hover:text-foreground text-muted-fg/60"
>
<AuthentikIcon />
<span>Mit Authentik anmelden</span>
</button>
</div>
</div>
</form>
)}
</div>
<p className="text-center text-[11px] text-muted-fg/30 mt-5 font-mono">
Token aus{" "}

View file

@ -196,7 +196,7 @@ export default function Preview({ content, className, notes, onSelectNote }: Pro
// Toast for unresolvable wikilinks
const [toast, setToast] = useState<string | null>(null)
const toastTimer = useRef<ReturnType<typeof setTimeout>>()
const toastTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
const showToast = (msg: string) => {
setToast(msg)
clearTimeout(toastTimer.current)

View file

@ -135,10 +135,7 @@ export function useNotes(): UseNotesReturn {
if (e instanceof NozApiError && e.status === 404) {
// Note was deleted externally — remove from local state, then refresh list
cacheRef.current.delete(slug)
setTopics(prev => prev
.map(t => ({ ...t, notes: t.notes.filter(n => n.slug !== slug) }))
.filter(t => t.notes.length > 0 || t.subTopics?.length)
)
setTopics(prev => filterTopicTree(prev, n => n.slug !== slug))
setDrafts(prev => prev.filter(n => n.slug !== slug))
setActiveNote(null)
// Reload full list so sidebar reflects server state
@ -228,10 +225,7 @@ export function useNotes(): UseNotesReturn {
const deleteNote = useCallback(async (slug: string) => {
if (!DEV) await deleteNoteApi(slug)
cacheRef.current.delete(slug)
setTopics(prev => prev
.map(t => ({ ...t, notes: t.notes.filter(n => n.slug !== slug) }))
.filter(t => t.notes.length > 0)
)
setTopics(prev => filterTopicTree(prev, n => n.slug !== slug))
setDrafts(prev => prev.filter(n => n.slug !== slug))
setActiveNote(prev => {
if (prev?.slug !== slug) return prev
@ -256,10 +250,7 @@ export function useNotes(): UseNotesReturn {
if (DEV) {
const cached = cacheRef.current.get(slug)
if (cached) cacheRef.current.set(slug, { ...cached, title })
setTopics(prev => prev.map(t => ({
...t,
notes: t.notes.map(n => n.slug === slug ? { ...n, title } : n),
})))
setTopics(prev => mapTopicNotes(prev, n => n.slug === slug ? { ...n, title } : n))
setDrafts(prev => prev.map(n => n.slug === slug ? { ...n, title } : n))
setActiveNote(prev => prev?.slug === slug ? { ...prev, title } : prev)
return
@ -267,10 +258,7 @@ export function useNotes(): UseNotesReturn {
await patchNoteApi(slug, { title })
const cached = cacheRef.current.get(slug)
if (cached) cacheRef.current.set(slug, { ...cached, title })
setTopics(prev => prev.map(t => ({
...t,
notes: t.notes.map(n => n.slug === slug ? { ...n, title } : n),
})))
setTopics(prev => mapTopicNotes(prev, n => n.slug === slug ? { ...n, title } : n))
setDrafts(prev => prev.map(n => n.slug === slug ? { ...n, title } : n))
setActiveNote(prev => prev?.slug === slug ? { ...prev, title } : prev)
}, [])
@ -279,7 +267,7 @@ export function useNotes(): UseNotesReturn {
const toggleDraft = useCallback(async (slug: string) => {
const meta =
topicsRef.current.flatMap(t => t.notes).find(n => n.slug === slug) ??
flattenNotes(topicsRef.current).find(n => n.slug === slug) ??
draftsRef.current.find(n => n.slug === slug)
if (!meta || meta.draftStage === "draft") return // can't toggle _drafts/ notes here
@ -291,7 +279,7 @@ export function useNotes(): UseNotesReturn {
const cached = cacheRef.current.get(slug)
if (cached) cacheRef.current.set(slug, { ...cached, draft: nextDraft, draftStage: nextStage })
setTopics(prev => prev.map(t => ({ ...t, notes: t.notes.map(update) })))
setTopics(prev => mapTopicNotes(prev, update))
setActiveNote(prev => prev?.slug === slug ? { ...prev, draft: nextDraft, draftStage: nextStage } : prev)
}, [])
@ -338,10 +326,7 @@ export function useNotes(): UseNotesReturn {
const cached = cacheRef.current.get(slug)
if (cached) cacheRef.current.set(slug, { ...cached, ...updates })
setActiveNote(prev => prev?.slug === slug ? { ...prev, ...updates } : prev)
setTopics(prev => prev.map(t => ({
...t,
notes: t.notes.map(n => n.slug === slug ? { ...n, ...updates } : n),
})))
setTopics(prev => mapTopicNotes(prev, n => n.slug === slug ? { ...n, ...updates } : n))
}, [])
/* ── loadNotes ──────────────────────────────────────────────────── */
@ -359,8 +344,7 @@ export function useNotes(): UseNotesReturn {
topicsRef.current = newTopics
draftsRef.current = newDrafts
const firstSlug = newTopics[0]?.notes[0]?.slug
?? newTopics[0]?.subTopics?.[0]?.notes[0]?.slug
const firstSlug = flattenNotes(newTopics)[0]?.slug ?? newDrafts[0]?.slug
if (firstSlug) selectNote(firstSlug)
})
.catch((e: unknown) => {
@ -395,6 +379,26 @@ function findNoteMeta(topics: Topic[], slug: string): NoteMeta | undefined {
return flattenNotes(topics).find(n => n.slug === slug)
}
/** Map a function over every note in the tree, recursing into subTopics. */
function mapTopicNotes(topics: Topic[], fn: (n: NoteMeta) => NoteMeta): Topic[] {
return topics.map(t => ({
...t,
notes: t.notes.map(fn),
subTopics: t.subTopics ? mapTopicNotes(t.subTopics, fn) : undefined,
}))
}
/** Remove notes matching predicate, dropping empty topics (preserves topics that still have subTopics). */
function filterTopicTree(topics: Topic[], keep: (n: NoteMeta) => boolean): Topic[] {
return topics
.map(t => ({
...t,
notes: t.notes.filter(keep),
subTopics: t.subTopics ? filterTopicTree(t.subTopics, keep) : undefined,
}))
.filter(t => t.notes.length > 0 || (t.subTopics?.length ?? 0) > 0)
}
/* buildTopicTree
Baut den Ordner-Baum aus Slug-Pfaden auf, nicht aus dem topic-Frontmatter-Feld.
Beispiel:

View file

@ -135,13 +135,6 @@ export async function patchNote(
});
}
export async function moveNote(slug: string, newTopic: string): Promise<void> {
await request(`/api/cli/notes/${slug}/move`, {
method: "POST",
body: JSON.stringify({ newTopic }),
});
}
export async function createNote(
title: string,
topic?: string,

View file

@ -32,3 +32,23 @@ export function isAuthenticated(): boolean {
if (process.env.NEXT_PUBLIC_DEV_FIXTURES === "true") return true;
return !!(getToken() && getServerUrl());
}
/* ── Authentik redirect helpers (localStorage — persists across navigations) ── */
const KEY_PENDING_URL = "noz_pending_url";
function ls(): Storage | null {
return typeof window !== "undefined" ? localStorage : null;
}
export function setPendingServerUrl(url: string): void {
ls()?.setItem(KEY_PENDING_URL, url);
}
export function getPendingServerUrl(): string | null {
return ls()?.getItem(KEY_PENDING_URL) ?? null;
}
export function clearPendingServerUrl(): void {
ls()?.removeItem(KEY_PENDING_URL);
}

View file

@ -1,3 +1,4 @@
import matter from "gray-matter"
import type { NoteContent, NoteStatus, Lang } from "./types"
export interface FrontmatterFields {
@ -7,6 +8,7 @@ export interface FrontmatterFields {
status: NoteStatus
lang: Lang
draft?: boolean
private?: boolean
order?: number
}
@ -15,32 +17,20 @@ const DEFAULT: FrontmatterFields = {
}
export function splitContent(markdown: string): { fields: FrontmatterFields; body: string } {
if (!markdown.startsWith("---\n")) return { fields: { ...DEFAULT }, body: markdown }
const end = markdown.indexOf("\n---\n", 4)
if (end === -1) return { fields: { ...DEFAULT }, body: markdown }
const yamlStr = markdown.slice(4, end)
const body = markdown.slice(end + 5)
const fields = { ...DEFAULT }
for (const line of yamlStr.split("\n")) {
const m = line.match(/^(\w+):\s*(.*)$/)
if (!m) continue
const [, key, rawVal] = m
const val = rawVal.replace(/^["']|["']$/g, "").trim()
switch (key) {
case "title": fields.title = val; break
case "description": fields.description = val; break
case "topic": fields.topic = val; break
case "status": fields.status = val as NoteStatus; break
case "lang": fields.lang = val as Lang; break
case "draft": fields.draft = rawVal.trim() === "true"; break
case "order": { const n = parseInt(rawVal.trim()); if (!isNaN(n)) fields.order = n } break
}
const { data, content } = matter(markdown)
return {
fields: {
title: typeof data.title === "string" ? data.title : DEFAULT.title,
description: typeof data.description === "string" ? data.description : DEFAULT.description,
topic: typeof data.topic === "string" ? data.topic : DEFAULT.topic,
status: typeof data.status === "string" ? data.status as NoteStatus : DEFAULT.status,
lang: typeof data.lang === "string" ? data.lang as Lang : DEFAULT.lang,
draft: typeof data.draft === "boolean" ? data.draft : undefined,
private: typeof data.private === "boolean" ? data.private : undefined,
order: typeof data.order === "number" ? data.order : undefined,
},
body: content,
}
return { fields, body }
}
export function buildMarkdown(note: NoteContent | FrontmatterFields, body: string): string {
@ -53,8 +43,9 @@ export function buildMarkdown(note: NoteContent | FrontmatterFields, body: strin
`status: ${f.status}`,
`lang: ${f.lang}`,
]
if (f.draft) lines.push("draft: true")
if (f.order) lines.push(`order: ${f.order}`)
if (f.draft) lines.push("draft: true")
if (f.private) lines.push("private: true")
if (f.order) lines.push(`order: ${f.order}`)
lines.push("---", "")
return lines.join("\n") + body
}
@ -67,6 +58,7 @@ function noteToFields(note: NoteContent): FrontmatterFields {
status: note.status,
lang: note.lang,
draft: note.draft,
private: note.private,
order: note.order,
}
}

View file

@ -10,6 +10,7 @@ export interface NoteMeta {
status: NoteStatus
lang: Lang
draft?: boolean
private?: boolean
draftStage?: DraftStage
order?: number
}