From a4ba6de8c855d88c556629548ea8f07c01f78955 Mon Sep 17 00:00:00 2001 From: Daniel Grabowski Date: Sun, 28 Jun 2026 17:28:40 +0200 Subject: [PATCH] feat(frontmatter): edit YAML metadata from the preview Render YAML frontmatter as a collapsed properties panel before the Markdown body instead of sending it through Markdown rendering. Scalar fields can be edited inline, and list fields such as keywords can be managed as tags. Add parser and update helpers backed by yaml, preserve document line endings when metadata is written, and keep invalid frontmatter out of the rendered body while surfacing the parser error. Add test:frontmatter coverage for parsing, CRLF preservation, invalid YAML, collapsed disclosure behavior, and tag editing. --- package-lock.json | 526 +++++++++++++++++++++++++- package.json | 5 +- scripts/frontMatter.test.ts | 124 ++++++ scripts/frontMatterDisclosure.test.ts | 34 ++ src/lib/MarkdownViewer.svelte | 415 +++++++++++++++++--- src/lib/utils/frontMatter.ts | 223 +++++++++++ src/styles.css | 235 ++++++++++++ 7 files changed, 1515 insertions(+), 47 deletions(-) create mode 100644 scripts/frontMatter.test.ts create mode 100644 scripts/frontMatterDisclosure.test.ts create mode 100644 src/lib/utils/frontMatter.ts diff --git a/package-lock.json b/package-lock.json index 1558733..9525f3c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "markpad", - "version": "2.6.8", + "version": "2.6.11", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "markpad", - "version": "2.6.8", + "version": "2.6.11", "license": "MIT", "dependencies": { "@tauri-apps/api": "^2", @@ -23,7 +23,8 @@ "mermaid": "^11.12.2", "monaco-editor": "^0.55.1", "monaco-vim": "^0.4.4", - "node-stream-zip": "^1.15.0" + "node-stream-zip": "^1.15.0", + "yaml": "^2.9.0" }, "devDependencies": { "@sveltejs/adapter-static": "^3.0.6", @@ -32,6 +33,7 @@ "@tauri-apps/cli": "^2", "svelte": "^5.0.0", "svelte-check": "^4.0.0", + "tsx": "^4.22.4", "typescript": "~5.6.2", "vite": "^6.0.3" } @@ -3006,6 +3008,509 @@ "node": ">=6.10" } }, + "node_modules/tsx": { + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz", + "integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/tsx/node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, "node_modules/typescript": { "version": "5.6.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz", @@ -3183,6 +3688,21 @@ "integrity": "sha512-AyFQ0EVmsOZOlAnxoFOGOq1SQDWAB7C6aqMGS23svWAllfOaxbuFvcT8D1i8z3Gyn8fraVeZNNmN6e9bxxXkKw==", "license": "MIT" }, + "node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, "node_modules/zimmerframe": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", diff --git a/package.json b/package.json index 3ba2046..bd3c9c6 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "dev:installer": "tauri dev -- -- --install", "build": "vite build", "preview": "vite preview", + "test:frontmatter": "node --test --import tsx scripts/frontMatter.test.ts scripts/frontMatterDisclosure.test.ts", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", "tauri": "tauri" @@ -28,7 +29,8 @@ "mermaid": "^11.12.2", "monaco-editor": "^0.55.1", "monaco-vim": "^0.4.4", - "node-stream-zip": "^1.15.0" + "node-stream-zip": "^1.15.0", + "yaml": "^2.9.0" }, "devDependencies": { "@sveltejs/adapter-static": "^3.0.6", @@ -37,6 +39,7 @@ "@tauri-apps/cli": "^2", "svelte": "^5.0.0", "svelte-check": "^4.0.0", + "tsx": "^4.22.4", "typescript": "~5.6.2", "vite": "^6.0.3" } diff --git a/scripts/frontMatter.test.ts b/scripts/frontMatter.test.ts new file mode 100644 index 0000000..4e5cbc5 --- /dev/null +++ b/scripts/frontMatter.test.ts @@ -0,0 +1,124 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; + +import { + addFrontMatterListItem, + addFrontMatterListItems, + getMarkdownBodyWithoutFrontMatter, + getFrontMatterListItems, + parseFrontMatter, + removeFrontMatterListItem, + updateFrontMatterListItem, + updateFrontMatterField, +} from '../src/lib/utils/frontMatter.js'; + +const planMarkdown = `--- +type: plan +name: dflex-logger-onoff-dir-synlog +title: "Sterowanie loggerami dFLEX: on/off domyslnie OFF" +project: "dflex" +keywords: [logger, synlog, appconfig, onoff, codesite] +created: 2026-06-26T18:29:35+02:00 +status: in-progress +--- + +## Summary + +Body text. +`; + +test('parseFrontMatter extracts top-level YAML metadata and body', () => { + const parsed = parseFrontMatter(planMarkdown); + + assert.equal(parsed.exists, true); + assert.equal(parsed.valid, true); + assert.equal(parsed.body, '## Summary\n\nBody text.\n'); + assert.deepEqual( + parsed.fields.map((field) => [field.key, field.kind, field.displayValue]), + [ + ['type', 'string', 'plan'], + ['name', 'string', 'dflex-logger-onoff-dir-synlog'], + ['title', 'string', 'Sterowanie loggerami dFLEX: on/off domyslnie OFF'], + ['project', 'string', 'dflex'], + ['keywords', 'list', 'logger, synlog, appconfig, onoff, codesite'], + ['created', 'string', '2026-06-26T18:29:35+02:00'], + ['status', 'string', 'in-progress'], + ], + ); +}); + +test('parseFrontMatter ignores thematic breaks outside the first document line', () => { + const markdown = `# Title + +--- + +Body`; + const parsed = parseFrontMatter(markdown); + + assert.equal(parsed.exists, false); + assert.equal(parsed.body, markdown); + assert.equal(getMarkdownBodyWithoutFrontMatter(markdown), markdown); +}); + +test('parseFrontMatter strips invalid front matter from rendered body but keeps the error', () => { + const parsed = parseFrontMatter(`--- +title: [broken +--- + +# Body +`); + + assert.equal(parsed.exists, true); + assert.equal(parsed.valid, false); + assert.match(parsed.error || '', /Flow sequence|unexpected|end/i); + assert.equal(parsed.body, '# Body\n'); + assert.deepEqual(parsed.fields, []); +}); + +test('updateFrontMatterField updates one property without changing markdown body', () => { + const updated = updateFrontMatterField(planMarkdown, 'status', 'done'); + const parsed = parseFrontMatter(updated); + + assert.equal(parsed.exists, true); + assert.equal(parsed.valid, true); + assert.equal(parsed.fields.find((field) => field.key === 'status')?.displayValue, 'done'); + assert.equal(parsed.body, '## Summary\n\nBody text.\n'); +}); + +test('updateFrontMatterField preserves CRLF documents', () => { + const markdown = '---\r\nname: old\r\ntags:\r\n - one\r\n---\r\n\r\nText\r\n'; + const updated = updateFrontMatterField(markdown, 'name', 'new'); + + assert.match(updated, /^---\r\nname: new\r\ntags:/); + assert.equal(parseFrontMatter(updated).body, 'Text\r\n'); +}); + +test('getFrontMatterListItems returns string tag values for YAML lists', () => { + const parsed = parseFrontMatter(planMarkdown); + const keywords = parsed.fields.find((field) => field.key === 'keywords'); + + assert.ok(keywords); + assert.deepEqual(getFrontMatterListItems(keywords), ['logger', 'synlog', 'appconfig', 'onoff', 'codesite']); +}); + +test('front matter tag helpers add, edit, and remove tags predictably', () => { + const original = ['logger', 'synlog']; + + assert.deepEqual(addFrontMatterListItem(original, ' appconfig '), ['logger', 'synlog', 'appconfig']); + assert.deepEqual(addFrontMatterListItem(original, 'logger'), ['logger', 'synlog']); + assert.deepEqual(addFrontMatterListItems(original, [' ', 'synlog', 'codesite, onoff']), ['logger', 'synlog', 'codesite', 'onoff']); + assert.deepEqual(updateFrontMatterListItem(original, 1, ' syslog '), ['logger', 'syslog']); + assert.deepEqual(updateFrontMatterListItem(original, 1, 'logger'), ['logger', 'synlog']); + assert.deepEqual(updateFrontMatterListItem(original, 1, ' '), ['logger', 'synlog']); + assert.deepEqual(removeFrontMatterListItem(original, 0), ['synlog']); +}); + +test('updateFrontMatterField writes edited keywords as a YAML list', () => { + const updated = updateFrontMatterField(planMarkdown, 'keywords', ['logger', 'synlog']); + const parsed = parseFrontMatter(updated); + const keywords = parsed.fields.find((field) => field.key === 'keywords'); + + assert.ok(keywords); + assert.deepEqual(getFrontMatterListItems(keywords), ['logger', 'synlog']); + assert.equal(parsed.body, '## Summary\n\nBody text.\n'); +}); diff --git a/scripts/frontMatterDisclosure.test.ts b/scripts/frontMatterDisclosure.test.ts new file mode 100644 index 0000000..1e09b44 --- /dev/null +++ b/scripts/frontMatterDisclosure.test.ts @@ -0,0 +1,34 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { test } from 'node:test'; + +const viewerSource = readFileSync(new URL('../src/lib/MarkdownViewer.svelte', import.meta.url), 'utf8'); + +test('front matter panel uses native details disclosure so collapsed summary remains expandable', () => { + const panelStart = viewerSource.indexOf('frontmatter-panel'); + assert.notEqual(panelStart, -1); + + const panelSource = viewerSource.slice(panelStart - 160, panelStart + 900); + + assert.match(panelSource, /]*class="frontmatter-panel"/); + assert.match(panelSource, /]*class="frontmatter-summary"/); + assert.doesNotMatch(panelSource, /]*class="frontmatter-summary"/); +}); + +test('front matter panel is collapsed by default until the user opens it', () => { + assert.match(viewerSource, /frontMatterCollapsedByKey\[frontMatterPanelKey\]\s*\?\?\s*true/); + assert.match(viewerSource, /open=\{!isFrontMatterCollapsed\}/); +}); + +test('front matter list fields render as editable tags instead of one comma input', () => { + const listBranchStart = viewerSource.indexOf("field.kind === 'list'"); + assert.notEqual(listBranchStart, -1); + + const listBranch = viewerSource.slice(listBranchStart, listBranchStart + 4000); + + assert.match(listBranch, /class="frontmatter-tags"/); + assert.match(listBranch, /class="frontmatter-tag-remove"/); + assert.match(listBranch, /class="frontmatter-tag-add-button"/); + assert.match(listBranch, /startFrontMatterTagEdit/); + assert.match(listBranch, /value=\{getFrontMatterTagDraft\(field\)\}/); +}); diff --git a/src/lib/MarkdownViewer.svelte b/src/lib/MarkdownViewer.svelte index 12077c7..ad82df1 100644 --- a/src/lib/MarkdownViewer.svelte +++ b/src/lib/MarkdownViewer.svelte @@ -22,6 +22,17 @@ import { exportAsHtml as _exportHtml, exportAsPdf } from './utils/export'; import ZoomOverlay from './components/ZoomOverlay.svelte'; import { processMarkdownHtml } from './utils/markdown'; +import { + addFrontMatterListItems, + getMarkdownBodyWithoutFrontMatter, + getFrontMatterListItems, + parseFrontMatter, + parseFrontMatterEditableValue, + removeFrontMatterListItem, + updateFrontMatterListItem, + updateFrontMatterField, + type FrontMatterField, +} from './utils/frontMatter.js'; import { decodeLinkPath, getMarkdownLinkTarget as getRelativeMarkdownTarget, @@ -158,9 +169,17 @@ import { t } from './utils/i18n.js'; let isEditing = $derived(activeTab?.isEditing ?? false); let rawContent = $derived(activeTab?.rawContent ?? ''); let isSplit = $derived(activeTab?.isSplit ?? false); + let frontMatterInfo = $derived(parseFrontMatter(rawContent)); // derived from tab manager let currentFile = $derived(tabManager.activeTab?.path ?? ''); + let frontMatterPanelKey = $derived(currentFile || tabManager.activeTabId || 'untitled'); + let frontMatterCollapsedByKey = $state>({}); + let frontMatterEditErrors = $state>({}); + let frontMatterTagDrafts = $state>({}); + let frontMatterTagEditIndexes = $state>({}); + let frontMatterTagEditDrafts = $state>({}); + let isFrontMatterCollapsed = $derived(frontMatterCollapsedByKey[frontMatterPanelKey] ?? true); const markdownLinkExtensions = ['.md', '.markdown', '.mdown', '.mkd', '.txt']; let isMarkdown = $derived(hasMarkdownLinkExtension(currentFile)); let editorLanguage = $derived(getLanguage(currentFile)); @@ -498,6 +517,210 @@ import { t } from './utils/i18n.js'; } } + async function renderMarkdownPreview(raw: string, filePath: string) { + const body = getMarkdownBodyWithoutFrontMatter(raw); + const html = (await invoke('render_markdown', { content: body })) as string; + return processMarkdownHtml(html, filePath, collapsedHeaders); + } + + async function renderTabPreviewFromRaw(tab: { id: string; path: string; rawContent: string; [key: string]: any }) { + const processed = await renderMarkdownPreview(tab.rawContent, tab.path); + tabManager.updateTabContent(tab.id, processed); + tab._lastRenderedRawContent = tab.rawContent; + await tick(); + renderRichContent(); + } + + function frontMatterFieldId(key: string) { + return `frontmatter-${key.replace(/[^a-zA-Z0-9_-]/g, '-')}`; + } + + function frontMatterFieldStateKey(field: FrontMatterField) { + return `${frontMatterPanelKey}:${field.key}`; + } + + function tagsEqual(left: string[], right: string[]) { + return left.length === right.length && left.every((value, index) => value === right[index]); + } + + function focusAndSelect(node: HTMLInputElement) { + requestAnimationFrame(() => { + node.focus(); + node.select(); + }); + } + + function setFrontMatterCollapsed(collapsed: boolean) { + frontMatterCollapsedByKey = { + ...frontMatterCollapsedByKey, + [frontMatterPanelKey]: collapsed, + }; + } + + function clearFrontMatterEditError(key: string) { + if (!frontMatterEditErrors[key]) return; + const next = { ...frontMatterEditErrors }; + delete next[key]; + frontMatterEditErrors = next; + } + + async function handleFrontMatterEdit(field: FrontMatterField, value: string) { + const tab = tabManager.activeTab; + if (!tab) return; + + try { + const nextValue = parseFrontMatterEditableValue(field, value); + const nextRaw = updateFrontMatterField(tab.rawContent, field.key, nextValue); + tabManager.updateTabRawContent(tab.id, nextRaw); + clearFrontMatterEditError(field.key); + await renderTabPreviewFromRaw(tab); + } catch (error) { + frontMatterEditErrors = { + ...frontMatterEditErrors, + [field.key]: String(error), + }; + } + } + + function getFrontMatterTagDraft(field: FrontMatterField) { + return frontMatterTagDrafts[frontMatterFieldStateKey(field)] ?? ''; + } + + function setFrontMatterTagDraft(field: FrontMatterField, value: string) { + frontMatterTagDrafts = { + ...frontMatterTagDrafts, + [frontMatterFieldStateKey(field)]: value, + }; + clearFrontMatterEditError(field.key); + } + + function clearFrontMatterTagDraft(field: FrontMatterField) { + const key = frontMatterFieldStateKey(field); + if (!frontMatterTagDrafts[key]) return; + + const next = { ...frontMatterTagDrafts }; + delete next[key]; + frontMatterTagDrafts = next; + } + + function getFrontMatterTagEditIndex(field: FrontMatterField) { + return frontMatterTagEditIndexes[frontMatterFieldStateKey(field)] ?? null; + } + + function getFrontMatterTagEditDraft(field: FrontMatterField, fallback: string) { + return frontMatterTagEditDrafts[frontMatterFieldStateKey(field)] ?? fallback; + } + + function setFrontMatterTagEditDraft(field: FrontMatterField, value: string) { + frontMatterTagEditDrafts = { + ...frontMatterTagEditDrafts, + [frontMatterFieldStateKey(field)]: value, + }; + clearFrontMatterEditError(field.key); + } + + function startFrontMatterTagEdit(field: FrontMatterField, index: number, value: string) { + const key = frontMatterFieldStateKey(field); + frontMatterTagEditIndexes = { + ...frontMatterTagEditIndexes, + [key]: index, + }; + frontMatterTagEditDrafts = { + ...frontMatterTagEditDrafts, + [key]: value, + }; + clearFrontMatterEditError(field.key); + } + + function clearFrontMatterTagEdit(field: FrontMatterField) { + const key = frontMatterFieldStateKey(field); + const nextIndexes = { ...frontMatterTagEditIndexes }; + const nextDrafts = { ...frontMatterTagEditDrafts }; + delete nextIndexes[key]; + delete nextDrafts[key]; + frontMatterTagEditIndexes = nextIndexes; + frontMatterTagEditDrafts = nextDrafts; + } + + async function handleFrontMatterListChange(field: FrontMatterField, nextItems: string[]) { + const tab = tabManager.activeTab; + if (!tab) return; + + try { + const nextRaw = updateFrontMatterField(tab.rawContent, field.key, nextItems); + tabManager.updateTabRawContent(tab.id, nextRaw); + clearFrontMatterEditError(field.key); + await renderTabPreviewFromRaw(tab); + } catch (error) { + frontMatterEditErrors = { + ...frontMatterEditErrors, + [field.key]: String(error), + }; + } + } + + async function commitFrontMatterTagAdd(field: FrontMatterField) { + const draft = getFrontMatterTagDraft(field); + if (!draft.trim()) return; + + const currentItems = getFrontMatterListItems(field); + const nextItems = addFrontMatterListItems(currentItems, [draft]); + if (tagsEqual(currentItems, nextItems)) { + clearFrontMatterTagDraft(field); + return; + } + + await handleFrontMatterListChange(field, nextItems); + clearFrontMatterTagDraft(field); + } + + async function removeFrontMatterTag(field: FrontMatterField, index: number) { + const currentItems = getFrontMatterListItems(field); + const nextItems = removeFrontMatterListItem(currentItems, index); + if (tagsEqual(currentItems, nextItems)) return; + + await handleFrontMatterListChange(field, nextItems); + } + + async function commitFrontMatterTagEdit(field: FrontMatterField, index: number) { + if (getFrontMatterTagEditIndex(field) !== index) return; + + const draft = getFrontMatterTagEditDraft(field, ''); + const currentItems = getFrontMatterListItems(field); + const nextItems = updateFrontMatterListItem(currentItems, index, draft); + + clearFrontMatterTagEdit(field); + if (tagsEqual(currentItems, nextItems)) return; + + await handleFrontMatterListChange(field, nextItems); + } + + function handleFrontMatterTagAddKeydown(event: KeyboardEvent, field: FrontMatterField) { + if (event.key === 'Enter' || event.key === ',') { + event.preventDefault(); + void commitFrontMatterTagAdd(field); + return; + } + + if (event.key === 'Escape') { + event.preventDefault(); + clearFrontMatterTagDraft(field); + } + } + + function handleFrontMatterTagEditKeydown(event: KeyboardEvent, field: FrontMatterField, index: number) { + if (event.key === 'Enter') { + event.preventDefault(); + void commitFrontMatterTagEdit(field, index); + return; + } + + if (event.key === 'Escape') { + event.preventDefault(); + clearFrontMatterTagEdit(field); + } + } + type LoadMarkdownOptions = { navigate?: boolean; skipTabManagement?: boolean; @@ -537,11 +760,11 @@ import { t } from './utils/i18n.js'; if (tab && !options.preserveEditState && !existing) { tab.isEditing = settings.startInEditor; } - const [html, content, isFull] = await invoke('open_markdown_preview', { path: filePath, maxBytes: 50000 }) as [string, string, boolean]; + const [, content, isFull] = await invoke('open_markdown_preview', { path: filePath, maxBytes: 50000 }) as [string, string, boolean]; if (pendingNavigateTabId) { tabManager.navigate(pendingNavigateTabId, filePath); } - const processedInfo = processMarkdownHtml(html, filePath, collapsedHeaders); + const processedInfo = await renderMarkdownPreview(content, filePath); tabManager.updateTabContent(activeId, processedInfo); tabManager.setTabRawContent(activeId, content); @@ -550,10 +773,7 @@ import { t } from './utils/i18n.js'; tick().then(() => { if (markdownBody) isAtBottom = markdownBody.scrollHeight <= markdownBody.clientHeight + 100; }); - Promise.all([ - invoke('open_markdown', { path: filePath }) as Promise, - invoke('read_file_content', { path: filePath }) as Promise - ]).then(([fullHtml, fullContent]) => { + (invoke('read_file_content', { path: filePath }) as Promise).then((fullContent) => { const applyFull = () => { try { if (isScrolling) { @@ -561,15 +781,22 @@ import { t } from './utils/i18n.js'; return; } if (tabManager.tabs.find((t) => t.id === activeId)?.path === filePath) { - const fullProcessed = processMarkdownHtml(fullHtml, filePath, collapsedHeaders); - tabManager.updateTabContent(activeId, fullProcessed); - tabManager.setTabRawContent(activeId, fullContent); - loadingTabs = loadingTabs.filter((id) => id !== activeId); - if (tabManager.activeTabId === activeId) { - tick().then(() => { - setTimeout(renderRichContent, 10); + renderMarkdownPreview(fullContent, filePath) + .then((fullProcessed) => { + tabManager.updateTabContent(activeId, fullProcessed); + tabManager.setTabRawContent(activeId, fullContent); + loadingTabs = loadingTabs.filter((id) => id !== activeId); + if (tabManager.activeTabId === activeId) { + tick().then(() => { + setTimeout(renderRichContent, 10); + }); + } + }) + .catch((renderErr) => { + console.error("render full markdown error:", renderErr); + addToast('Error processing full markdown: ' + String(renderErr), 'error'); + loadingTabs = loadingTabs.filter((id) => id !== activeId); }); - } } else { loadingTabs = loadingTabs.filter((id) => id !== activeId); } @@ -586,7 +813,7 @@ import { t } from './utils/i18n.js'; setTimeout(applyFull, 100); } }).catch((e) => { - console.error("Promise.all error:", e); + console.error("read full markdown error:", e); addToast('Backend Error loading full markdown: ' + String(e), 'error'); loadingTabs = loadingTabs.filter((id) => id !== activeId); }); @@ -1357,16 +1584,15 @@ import { t } from './utils/i18n.js'; tab.isEditing = false; if (tab.path !== '') { await loadMarkdown(tab.path, { preserveEditState: true }); - } else { - // Untitled: render the in-memory buffer for the preview. - try { - const html = (await invoke('render_markdown', { content: tab.rawContent })) as string; - const processedInfo = processMarkdownHtml(html, '', collapsedHeaders); - tabManager.updateTabContent(tab.id, processedInfo); - } catch (e) { - console.error('Failed to render markdown', e); + } else { + // Untitled: render the in-memory buffer for the preview. + try { + const processedInfo = await renderMarkdownPreview(tab.rawContent, ''); + tabManager.updateTabContent(tab.id, processedInfo); + } catch (e) { + console.error('Failed to render markdown', e); + } } - } } else { // Switch to edit if (tab.path !== '') { @@ -1935,9 +2161,8 @@ import { t } from './utils/i18n.js'; clearTimeout(debounceTimer); debounceTimer = setTimeout(() => { - invoke('render_markdown', { content: tab.rawContent }) - .then((html) => { - const processed = processMarkdownHtml(html as string, tab.path, collapsedHeaders); + renderMarkdownPreview(tab.rawContent, tab.path) + .then((processed) => { tabManager.updateTabContent(tab.id, processed); (tab as any)._lastRenderedRawContent = tab.rawContent; tick().then(renderRichContent); @@ -2329,9 +2554,8 @@ import { t } from './utils/i18n.js'; tabManager.restoreState(savedData); for (const tab of tabManager.tabs) { if (!tab.content && tab.rawContent) { - invoke('render_markdown', { content: tab.rawContent }) - .then((html) => { - const processed = processMarkdownHtml(html as string, tab.path, collapsedHeaders); + renderMarkdownPreview(tab.rawContent, tab.path) + .then((processed) => { tabManager.updateTabContent(tab.id, processed); if (tabManager.activeTabId === tab.id) { tick().then(renderRichContent); @@ -2798,19 +3022,124 @@ import { t } from './utils/i18n.js'; {markdownBody} language={settings.language} /> -
-
{ if(e.key === 'Enter' || e.key === ' ') handleLinkClick(e as unknown as MouseEvent); }} - tabindex="-1" - style="outline: none; font-family: {settings.previewFont}, sans-serif; font-size: {settings.previewFontSize}px; flex: 1;"> -
- {#if tabManager.activeTabId && loadingTabs.includes(tabManager.activeTabId) && isAtBottom} +
+
{ + const target = e.target as HTMLElement; + if (target.closest('.frontmatter-panel')) return; + if(e.key === 'Enter' || e.key === ' ') handleLinkClick(e as unknown as MouseEvent); + }} + tabindex="-1" + style="outline: none; font-family: {settings.previewFont}, sans-serif; font-size: {settings.previewFontSize}px; flex: 1;"> + {#if frontMatterInfo.exists} +
setFrontMatterCollapsed(!(e.currentTarget as HTMLDetailsElement).open)}> + + + Properties + {frontMatterInfo.valid ? frontMatterInfo.fields.length : 0} + + + {#if frontMatterInfo.valid} +
+ {#each frontMatterInfo.fields as field (field.key)} + +
+ {#if field.editable} + {#if field.kind === 'boolean'} + + {:else if field.kind === 'list'} +
+
+ {#each getFrontMatterListItems(field) as tag, index (`${tag}-${index}`)} + + {#if getFrontMatterTagEditIndex(field) === index} + setFrontMatterTagEditDraft(field, (e.currentTarget as HTMLInputElement).value)} + onkeydown={(e) => handleFrontMatterTagEditKeydown(e, field, index)} + onblur={() => commitFrontMatterTagEdit(field, index)} /> + {:else} + + + {/if} + + {/each} +
+
+ setFrontMatterTagDraft(field, (e.currentTarget as HTMLInputElement).value)} + onkeydown={(e) => handleFrontMatterTagAddKeydown(e, field)} /> + +
+
+ {:else} + handleFrontMatterEdit(field, (e.currentTarget as HTMLInputElement).value)} /> + {/if} + {:else} + {field.displayValue} + {/if} + {#if frontMatterEditErrors[field.key]} +
{frontMatterEditErrors[field.key]}
+ {/if} +
+ {/each} +
+ {:else} +
{frontMatterInfo.error}
+ {/if} + +
+ {/if} + {@html sanitizedHtml} +
+ {#if tabManager.activeTabId && loadingTabs.includes(tabManager.activeTabId) && isAtBottom}
{t('common.loadingFullDocument', settings.language)} diff --git a/src/lib/utils/frontMatter.ts b/src/lib/utils/frontMatter.ts new file mode 100644 index 0000000..3fd1009 --- /dev/null +++ b/src/lib/utils/frontMatter.ts @@ -0,0 +1,223 @@ +import { parseDocument } from 'yaml'; + +export type FrontMatterValueKind = 'string' | 'number' | 'boolean' | 'list' | 'object' | 'null'; + +export type FrontMatterField = { + key: string; + value: unknown; + kind: FrontMatterValueKind; + displayValue: string; + editableValue: string; + editable: boolean; +}; + +export type FrontMatterParseResult = { + exists: boolean; + valid: boolean; + raw: string; + body: string; + fields: FrontMatterField[]; + data: Record; + error?: string; + lineEnding: '\n' | '\r\n'; +}; + +type FrontMatterRange = { + raw: string; + body: string; + lineEnding: '\n' | '\r\n'; +}; + +function detectLineEnding(content: string): '\n' | '\r\n' { + return content.includes('\r\n') ? '\r\n' : '\n'; +} + +function findFrontMatterRange(content: string): FrontMatterRange | null { + const lineEnding = detectLineEnding(content); + const firstLineMatch = content.match(/^(?:\uFEFF)?---[ \t]*(?:\r?\n|$)/); + if (!firstLineMatch) return null; + + let cursor = firstLineMatch[0].length; + while (cursor <= content.length) { + const nextNewline = content.indexOf('\n', cursor); + const lineEnd = nextNewline === -1 ? content.length : nextNewline + 1; + const line = content.slice(cursor, lineEnd).replace(/\r?\n$/, ''); + if (line.trim() === '---') { + const raw = content.slice(firstLineMatch[0].length, cursor); + let bodyStart = lineEnd; + if (content.startsWith('\r\n', bodyStart)) { + bodyStart += 2; + } else if (content.startsWith('\n', bodyStart)) { + bodyStart += 1; + } + + return { + raw, + body: content.slice(bodyStart), + lineEnding, + }; + } + + if (nextNewline === -1) break; + cursor = lineEnd; + } + + return null; +} + +function getValueKind(value: unknown): FrontMatterValueKind { + if (value === null || value === undefined) return 'null'; + if (Array.isArray(value)) return 'list'; + if (typeof value === 'boolean') return 'boolean'; + if (typeof value === 'number') return 'number'; + if (typeof value === 'string') return 'string'; + return 'object'; +} + +function stringifyDisplayValue(value: unknown): string { + if (Array.isArray(value)) { + return value.map((item) => stringifyDisplayValue(item)).join(', '); + } + if (value === null || value === undefined) return ''; + if (value instanceof Date) return value.toISOString(); + if (typeof value === 'object') return JSON.stringify(value); + return String(value); +} + +function toPlainRecord(value: unknown): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; + return value as Record; +} + +function toField([key, value]: [string, unknown]): FrontMatterField { + const kind = getValueKind(value); + return { + key, + value, + kind, + displayValue: stringifyDisplayValue(value), + editableValue: Array.isArray(value) ? value.map((item) => stringifyDisplayValue(item)).join(', ') : stringifyDisplayValue(value), + editable: kind !== 'object', + }; +} + +export function parseFrontMatter(content: string): FrontMatterParseResult { + const range = findFrontMatterRange(content); + const lineEnding = range?.lineEnding ?? detectLineEnding(content); + if (!range) { + return { + exists: false, + valid: true, + raw: '', + body: content, + fields: [], + data: {}, + lineEnding, + }; + } + + const doc = parseDocument(range.raw, { prettyErrors: false }); + if (doc.errors.length > 0) { + return { + exists: true, + valid: false, + raw: range.raw, + body: range.body, + fields: [], + data: {}, + error: doc.errors.map((error) => error.message).join('\n'), + lineEnding, + }; + } + + const data = toPlainRecord(doc.toJSON()); + return { + exists: true, + valid: true, + raw: range.raw, + body: range.body, + fields: Object.entries(data).map(toField), + data, + lineEnding, + }; +} + +export function getMarkdownBodyWithoutFrontMatter(content: string): string { + return parseFrontMatter(content).body; +} + +export function parseFrontMatterEditableValue(field: FrontMatterField, value: string): unknown { + const trimmed = value.trim(); + switch (field.kind) { + case 'boolean': + return trimmed.toLowerCase() === 'true'; + case 'number': { + const parsed = Number(trimmed); + return Number.isFinite(parsed) ? parsed : value; + } + case 'list': + return parseFrontMatterTagInput(value); + case 'null': + return trimmed === '' ? null : value; + case 'string': + default: + return value; + } +} + +export function updateFrontMatterField(content: string, key: string, value: unknown): string { + const parsed = parseFrontMatter(content); + if (!parsed.exists) return content; + if (!parsed.valid) throw new Error(parsed.error || 'Invalid front matter'); + + const doc = parseDocument(parsed.raw, { prettyErrors: false }); + if (doc.errors.length > 0) throw new Error(doc.errors.map((error) => error.message).join('\n')); + + doc.set(key, value); + let serialized = doc.toString({ lineWidth: 0 }).trimEnd(); + if (parsed.lineEnding === '\r\n') serialized = serialized.replace(/\n/g, '\r\n'); + + return `---${parsed.lineEnding}${serialized}${parsed.lineEnding}---${parsed.lineEnding}${parsed.lineEnding}${parsed.body}`; +} + +export function parseFrontMatterTagInput(value: string): string[] { + return value + .split(',') + .map((item) => item.trim()) + .filter(Boolean); +} + +export function getFrontMatterListItems(field: FrontMatterField): string[] { + if (!Array.isArray(field.value)) return []; + return field.value + .map((item) => stringifyDisplayValue(item).trim()) + .filter(Boolean); +} + +export function addFrontMatterListItems(items: string[], values: string[]): string[] { + const next = [...items]; + for (const value of values.flatMap(parseFrontMatterTagInput)) { + if (!next.includes(value)) next.push(value); + } + return next; +} + +export function addFrontMatterListItem(items: string[], value: string): string[] { + return addFrontMatterListItems(items, [value]); +} + +export function removeFrontMatterListItem(items: string[], index: number): string[] { + if (index < 0 || index >= items.length) return items; + return items.filter((_, itemIndex) => itemIndex !== index); +} + +export function updateFrontMatterListItem(items: string[], index: number, value: string): string[] { + if (index < 0 || index >= items.length) return items; + + const trimmed = value.trim(); + if (!trimmed || items.some((item, itemIndex) => itemIndex !== index && item === trimmed)) return items; + + const next = [...items]; + next[index] = trimmed; + return next; +} diff --git a/src/styles.css b/src/styles.css index ca405e1..da5d86e 100644 --- a/src/styles.css +++ b/src/styles.css @@ -233,6 +233,241 @@ body { vertical-align: text-bottom; } +.markdown-body .frontmatter-panel { + margin: 0 0 1.5rem; + border: 1px solid var(--color-border-default); + border-radius: 8px; + background: var(--color-canvas-subtle); + overflow: hidden; +} + +.markdown-body .frontmatter-summary { + width: 100%; + display: flex; + align-items: center; + gap: 0.5rem; + min-height: 2.25rem; + padding: 0.45rem 0.75rem; + border: 0; + background: transparent; + color: var(--color-fg-default); + font: inherit; + font-weight: 600; + text-align: left; + cursor: pointer; + list-style: none; + user-select: none; + -webkit-user-select: none; +} + +.markdown-body .frontmatter-summary::marker, +.markdown-body .frontmatter-summary::-webkit-details-marker { + display: none; + content: ""; +} + +.markdown-body .frontmatter-summary:focus-visible { + outline: 2px solid var(--color-accent-fg); + outline-offset: -3px; +} + +.markdown-body .frontmatter-chevron { + display: inline-flex; + align-items: center; + justify-content: center; + inline-size: 1rem; + block-size: 1rem; + color: var(--color-fg-muted); + transform: rotate(90deg); + transition: transform 0.15s ease; +} + +.markdown-body .frontmatter-panel:not([open]) .frontmatter-chevron { + transform: rotate(0deg); +} + +.markdown-body .frontmatter-title { + flex: 1; +} + +.markdown-body .frontmatter-count { + min-inline-size: 1.5rem; + padding: 0.05rem 0.45rem; + border: 1px solid var(--color-border-muted); + border-radius: 999px; + color: var(--color-fg-muted); + font-size: 0.78em; + font-weight: 500; + text-align: center; +} + +.markdown-body .frontmatter-grid { + display: grid; + grid-template-columns: fit-content(12rem) minmax(0, 1fr); + column-gap: 0.75rem; + row-gap: 0.45rem; + align-items: start; + padding: 0.75rem; + border-top: 1px solid var(--color-border-muted); +} + +.markdown-body .frontmatter-key { + padding-block: 0.35rem; + color: var(--color-fg-muted); + font-size: 0.9em; + overflow-wrap: anywhere; +} + +.markdown-body .frontmatter-value { + min-width: 0; +} + +.markdown-body .frontmatter-value input, +.markdown-body .frontmatter-value select { + width: 100%; + min-height: 2rem; + box-sizing: border-box; + border: 1px solid var(--color-border-default); + border-radius: 6px; + background: var(--color-canvas-default); + color: var(--color-fg-default); + font: inherit; + font-size: 0.92em; + padding: 0.3rem 0.5rem; +} + +.markdown-body .frontmatter-value input:focus-visible, +.markdown-body .frontmatter-value select:focus-visible { + outline: 2px solid var(--color-accent-fg); + outline-offset: 1px; +} + +.markdown-body .frontmatter-value code { + display: block; + padding: 0.35rem 0.5rem; + border: 1px solid var(--color-border-muted); + border-radius: 6px; + background: var(--color-canvas-default); + white-space: pre-wrap; +} + +.markdown-body .frontmatter-tags { + display: flex; + flex-direction: column; + gap: 0.45rem; + min-width: 0; +} + +.markdown-body .frontmatter-tag-list { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.35rem; + min-height: 2rem; +} + +.markdown-body .frontmatter-tag { + display: inline-flex; + align-items: stretch; + max-width: 100%; + border: 1px solid var(--color-border-default); + border-radius: 6px; + background: var(--color-canvas-default); + overflow: hidden; +} + +.markdown-body .frontmatter-tag-text, +.markdown-body .frontmatter-tag-remove, +.markdown-body .frontmatter-tag-add-button { + min-height: 1.8rem; + border: 0; + background: transparent; + color: var(--color-fg-default); + font: inherit; + font-size: 0.9em; +} + +.markdown-body .frontmatter-tag-text { + max-width: min(18rem, 100%); + padding: 0.2rem 0.5rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + cursor: text; +} + +.markdown-body .frontmatter-tag-remove { + inline-size: 1.7rem; + border-left: 1px solid var(--color-border-muted); + color: var(--color-fg-muted); + cursor: pointer; +} + +.markdown-body .frontmatter-tag-add { + display: grid; + grid-template-columns: minmax(8rem, 1fr) 2rem; + gap: 0.35rem; + width: min(100%, 24rem); +} + +.markdown-body .frontmatter-tag-add-button { + border: 1px solid var(--color-border-default); + border-radius: 6px; + background: var(--color-canvas-default); + color: var(--color-fg-muted); + cursor: pointer; +} + +.markdown-body .frontmatter-value .frontmatter-tag-edit-input { + width: min(14rem, 100%); + min-height: 1.8rem; + border: 0; + border-radius: 0; + background: transparent; + padding: 0.2rem 0.5rem; +} + +.markdown-body .frontmatter-tag-text:hover, +.markdown-body .frontmatter-tag-remove:hover, +.markdown-body .frontmatter-tag-add-button:hover { + background: var(--color-neutral-muted); + color: var(--color-fg-default); +} + +.markdown-body .frontmatter-tag-text:focus-visible, +.markdown-body .frontmatter-tag-remove:focus-visible, +.markdown-body .frontmatter-tag-add-button:focus-visible { + outline: 2px solid var(--color-accent-fg); + outline-offset: 1px; +} + +.markdown-body .frontmatter-error, +.markdown-body .frontmatter-field-error { + color: var(--color-danger-fg); + font-size: 0.9em; +} + +.markdown-body .frontmatter-error { + padding: 0.75rem; + border-top: 1px solid var(--color-border-muted); + white-space: pre-wrap; +} + +.markdown-body .frontmatter-field-error { + margin-top: 0.25rem; +} + +@media (max-width: 720px) { + .markdown-body .frontmatter-grid { + grid-template-columns: minmax(0, 1fr); + row-gap: 0.25rem; + } + + .markdown-body .frontmatter-key { + padding-block-end: 0; + } +} + .markdown-body h1:hover .anchor .octicon-link:before, .markdown-body h2:hover .anchor .octicon-link:before, .markdown-body h3:hover .anchor .octicon-link:before,