From 177f3d5ab99a128370e4ca0e0ae38c8edcc3969f Mon Sep 17 00:00:00 2001 From: Bruno Deanoz Date: Tue, 19 May 2026 09:17:26 +0200 Subject: [PATCH] Fix bugs and implement Authentik SSO login MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- next-env.d.ts | 2 +- package.json | 1 + pnpm-lock.yaml | 77 ++++++++++++++++ src/app/login/page.tsx | 135 +++++++++++++++++++---------- src/components/preview/preview.tsx | 2 +- src/hooks/use-notes.ts | 52 ++++++----- src/lib/api.ts | 7 -- src/lib/auth.ts | 20 +++++ src/lib/frontmatter.ts | 46 ++++------ src/lib/types.ts | 1 + 10 files changed, 238 insertions(+), 105 deletions(-) diff --git a/next-env.d.ts b/next-env.d.ts index 9edff1c..c4b7818 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -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. diff --git a/package.json b/package.json index f486764..98c0102 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d697160..19ea4c2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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): diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index f288c9d..9ed69ee 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -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(null) + const [url, setUrl] = useState(devServer || "") + const [token, setToken] = useState(devToken) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const [serverConf, setServerConf] = useState(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 (
{/* Nebula glows */} @@ -85,34 +123,37 @@ export default function LoginPage() {
-
-
-
+
+ {/* Fehlermeldung */} + {error && ( +
+ + {error} +
+ )} - {/* Fehlermeldung */} - {error && ( -
- - {error} -
- )} - -
- - { 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" - /> -
+ {/* Server URL field — always shown */} +
+
+ + { 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" + /> +
+
+ {/* Token login */} + {showToken && ( +
+ + )} - {/* Divider */} + {/* Divider — only shown when both methods are available */} + {showToken && showAuthentik && (
oder
+ )} - {/* Authentik — noch nicht implementiert */} + {/* Authentik SSO */} + {showAuthentik && (
-
- + )} +

Token aus{" "} diff --git a/src/components/preview/preview.tsx b/src/components/preview/preview.tsx index b8884ad..46b511a 100644 --- a/src/components/preview/preview.tsx +++ b/src/components/preview/preview.tsx @@ -196,7 +196,7 @@ export default function Preview({ content, className, notes, onSelectNote }: Pro // Toast for unresolvable wikilinks const [toast, setToast] = useState(null) - const toastTimer = useRef>() + const toastTimer = useRef | undefined>(undefined) const showToast = (msg: string) => { setToast(msg) clearTimeout(toastTimer.current) diff --git a/src/hooks/use-notes.ts b/src/hooks/use-notes.ts index b81298a..6b0382e 100644 --- a/src/hooks/use-notes.ts +++ b/src/hooks/use-notes.ts @@ -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: diff --git a/src/lib/api.ts b/src/lib/api.ts index 9e16604..be7846a 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -135,13 +135,6 @@ export async function patchNote( }); } -export async function moveNote(slug: string, newTopic: string): Promise { - await request(`/api/cli/notes/${slug}/move`, { - method: "POST", - body: JSON.stringify({ newTopic }), - }); -} - export async function createNote( title: string, topic?: string, diff --git a/src/lib/auth.ts b/src/lib/auth.ts index dce1a2f..427b5e4 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -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); +} diff --git a/src/lib/frontmatter.ts b/src/lib/frontmatter.ts index 7e5413b..ae85f8b 100644 --- a/src/lib/frontmatter.ts +++ b/src/lib/frontmatter.ts @@ -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, } } diff --git a/src/lib/types.ts b/src/lib/types.ts index 504f422..e79d213 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -10,6 +10,7 @@ export interface NoteMeta { status: NoteStatus lang: Lang draft?: boolean + private?: boolean draftStage?: DraftStage order?: number }