From 107fe07c1238cc785aaa339e356282cc55f1c8b6 Mon Sep 17 00:00:00 2001 From: saberzero1 Date: Sun, 22 Feb 2026 23:40:23 +0100 Subject: [PATCH] feat: implement note-properties plugin Move frontmatter processing from core Quartz into a standalone plugin. Parse YAML/TOML frontmatter, extract links from property values, and render an Obsidian-like properties panel in the page. Supports: includeAll/includedProperties/excludedProperties filtering, hidePropertiesView mode, wikilink/markdown link resolution in values, collapsible panel with client-side state persistence, i18n. --- package-lock.json | 509 ++++++------------ package.json | 108 ++-- src/components/ExampleComponent.tsx | 33 -- src/components/NoteProperties.tsx | 171 ++++++ src/components/index.ts | 4 +- src/components/scripts/example.inline.ts | 107 ---- .../scripts/noteProperties.inline.ts | 33 ++ src/components/styles/example.scss | 8 - src/components/styles/noteProperties.scss | 165 ++++++ src/emitter.ts | 91 ---- src/filter.ts | 53 -- src/i18n/locales/en-US.ts | 4 +- src/index.ts | 24 +- src/transformer.ts | 333 ++++++++---- src/types.ts | 55 +- 15 files changed, 881 insertions(+), 817 deletions(-) delete mode 100644 src/components/ExampleComponent.tsx create mode 100644 src/components/NoteProperties.tsx delete mode 100644 src/components/scripts/example.inline.ts create mode 100644 src/components/scripts/noteProperties.inline.ts delete mode 100644 src/components/styles/example.scss create mode 100644 src/components/styles/noteProperties.scss delete mode 100644 src/emitter.ts delete mode 100644 src/filter.ts diff --git a/package-lock.json b/package-lock.json index 6fc560a..907cb1f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,26 +1,25 @@ { - "name": "@quartz-community/plugin-template", + "name": "@quartz-community/note-properties", "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "@quartz-community/plugin-template", + "name": "@quartz-community/note-properties", "version": "0.1.0", "license": "MIT", "dependencies": { "@quartz-community/types": "github:quartz-community/types", - "mdast-util-find-and-replace": "^3.0.1", - "rehype-slug": "^6.0.0", - "remark-gfm": "^4.0.1", - "remark-parse": "^11.0.0", - "remark-stringify": "^11.0.0", + "gray-matter": "^4.0.3", + "js-yaml": "^4.1.0", + "remark-frontmatter": "^5.0.0", + "toml": "^3.0.0", "unified": "^11.0.5", - "unist-util-visit": "^5.0.0", "vfile": "^6.0.3" }, "devDependencies": { "@types/hast": "^3.0.4", + "@types/js-yaml": "^4.0.9", "@types/mdast": "^4.0.4", "@types/node": "^24.10.0", "@typescript-eslint/eslint-plugin": "^7.18.0", @@ -1108,6 +1107,13 @@ "@types/unist": "*" } }, + "node_modules/@types/js-yaml": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz", + "integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/mdast": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", @@ -1546,7 +1552,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, "license": "Python-2.0" }, "node_modules/array-union": { @@ -1645,16 +1650,6 @@ "node": ">=6" } }, - "node_modules/ccount": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", - "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, "node_modules/chai": { "version": "5.3.3", "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", @@ -2095,6 +2090,19 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/esquery": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", @@ -2167,6 +2175,18 @@ "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", "license": "MIT" }, + "node_modules/extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==", + "license": "MIT", + "dependencies": { + "is-extendable": "^0.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -2228,6 +2248,19 @@ "reusify": "^1.0.4" } }, + "node_modules/fault": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/fault/-/fault-2.0.1.tgz", + "integrity": "sha512-WtySTkS4OKev5JtpHXnib4Gxiurzh5NCGvWrFaZ34m6JehfTUhKZvn9njTfw48t6JumVQOmrKqpmGcdwxnhqBQ==", + "license": "MIT", + "dependencies": { + "format": "^0.2.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/file-entry-cache": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", @@ -2305,6 +2338,14 @@ "dev": true, "license": "ISC" }, + "node_modules/format": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/format/-/format-0.2.2.tgz", + "integrity": "sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==", + "engines": { + "node": ">=0.4.x" + } + }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -2327,12 +2368,6 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, - "node_modules/github-slugger": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/github-slugger/-/github-slugger-2.0.0.tgz", - "integrity": "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==", - "license": "ISC" - }, "node_modules/glob": { "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", @@ -2436,6 +2471,43 @@ "dev": true, "license": "MIT" }, + "node_modules/gray-matter": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/gray-matter/-/gray-matter-4.0.3.tgz", + "integrity": "sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==", + "license": "MIT", + "dependencies": { + "js-yaml": "^3.13.1", + "kind-of": "^6.0.2", + "section-matter": "^1.0.0", + "strip-bom-string": "^1.0.0" + }, + "engines": { + "node": ">=6.0" + } + }, + "node_modules/gray-matter/node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/gray-matter/node_modules/js-yaml": { + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", + "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", + "license": "MIT", + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -2446,32 +2518,6 @@ "node": ">=8" } }, - "node_modules/hast-util-heading-rank": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/hast-util-heading-rank/-/hast-util-heading-rank-3.0.0.tgz", - "integrity": "sha512-EJKb8oMUXVHcWZTDepnr+WNbfnXKFNf9duMesmr4S8SXTJBJ9M4Yok08pu9vxdJwdlGRhVumk9mEhkEvKGifwA==", - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/hast-util-to-string": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/hast-util-to-string/-/hast-util-to-string-3.0.1.tgz", - "integrity": "sha512-XelQVTDWvqcl3axRfI0xSeoVKzyIFPwsAGSLIsKdJKQMXDYJS4WYrBNF/8J7RdhIcFI2BOHgAifggsvsxp/3+A==", - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -2528,6 +2574,15 @@ "dev": true, "license": "ISC" }, + "node_modules/is-extendable": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz", + "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -2604,7 +2659,6 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", - "dev": true, "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -2644,6 +2698,15 @@ "json-buffer": "3.0.1" } }, + "node_modules/kind-of": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", + "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -2738,44 +2801,6 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, - "node_modules/markdown-table": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", - "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/mdast-util-find-and-replace": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz", - "integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "escape-string-regexp": "^5.0.0", - "unist-util-is": "^6.0.0", - "unist-util-visit-parents": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-find-and-replace/node_modules/escape-string-regexp": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", - "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/mdast-util-from-markdown": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.2.tgz", @@ -2800,105 +2825,34 @@ "url": "https://opencollective.com/unified" } }, - "node_modules/mdast-util-gfm": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz", - "integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==", - "license": "MIT", - "dependencies": { - "mdast-util-from-markdown": "^2.0.0", - "mdast-util-gfm-autolink-literal": "^2.0.0", - "mdast-util-gfm-footnote": "^2.0.0", - "mdast-util-gfm-strikethrough": "^2.0.0", - "mdast-util-gfm-table": "^2.0.0", - "mdast-util-gfm-task-list-item": "^2.0.0", - "mdast-util-to-markdown": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-gfm-autolink-literal": { + "node_modules/mdast-util-frontmatter": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz", - "integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==", + "resolved": "https://registry.npmjs.org/mdast-util-frontmatter/-/mdast-util-frontmatter-2.0.1.tgz", + "integrity": "sha512-LRqI9+wdgC25P0URIJY9vwocIzCcksduHQ9OF2joxQoyTNVduwLAFUzjoopuRJbJAReaKrNQKAZKL3uCMugWJA==", "license": "MIT", "dependencies": { "@types/mdast": "^4.0.0", - "ccount": "^2.0.0", "devlop": "^1.0.0", - "mdast-util-find-and-replace": "^3.0.0", - "micromark-util-character": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-gfm-footnote": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz", - "integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "devlop": "^1.1.0", + "escape-string-regexp": "^5.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", - "micromark-util-normalize-identifier": "^2.0.0" + "micromark-extension-frontmatter": "^2.0.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/unified" } }, - "node_modules/mdast-util-gfm-strikethrough": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz", - "integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==", + "node_modules/mdast-util-frontmatter/node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "mdast-util-from-markdown": "^2.0.0", - "mdast-util-to-markdown": "^2.0.0" + "engines": { + "node": ">=12" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-gfm-table": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz", - "integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "devlop": "^1.0.0", - "markdown-table": "^3.0.0", - "mdast-util-from-markdown": "^2.0.0", - "mdast-util-to-markdown": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-gfm-task-list-item": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz", - "integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "devlop": "^1.0.0", - "mdast-util-from-markdown": "^2.0.0", - "mdast-util-to-markdown": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/mdast-util-phrasing": { @@ -3028,118 +2982,13 @@ "micromark-util-types": "^2.0.0" } }, - "node_modules/micromark-extension-gfm": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz", - "integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==", - "license": "MIT", - "dependencies": { - "micromark-extension-gfm-autolink-literal": "^2.0.0", - "micromark-extension-gfm-footnote": "^2.0.0", - "micromark-extension-gfm-strikethrough": "^2.0.0", - "micromark-extension-gfm-table": "^2.0.0", - "micromark-extension-gfm-tagfilter": "^2.0.0", - "micromark-extension-gfm-task-list-item": "^2.0.0", - "micromark-util-combine-extensions": "^2.0.0", - "micromark-util-types": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/micromark-extension-gfm-autolink-literal": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz", - "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==", - "license": "MIT", - "dependencies": { - "micromark-util-character": "^2.0.0", - "micromark-util-sanitize-uri": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/micromark-extension-gfm-footnote": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz", - "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==", - "license": "MIT", - "dependencies": { - "devlop": "^1.0.0", - "micromark-core-commonmark": "^2.0.0", - "micromark-factory-space": "^2.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-normalize-identifier": "^2.0.0", - "micromark-util-sanitize-uri": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/micromark-extension-gfm-strikethrough": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz", - "integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==", - "license": "MIT", - "dependencies": { - "devlop": "^1.0.0", - "micromark-util-chunked": "^2.0.0", - "micromark-util-classify-character": "^2.0.0", - "micromark-util-resolve-all": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/micromark-extension-gfm-table": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz", - "integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==", - "license": "MIT", - "dependencies": { - "devlop": "^1.0.0", - "micromark-factory-space": "^2.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/micromark-extension-gfm-tagfilter": { + "node_modules/micromark-extension-frontmatter": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz", - "integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==", + "resolved": "https://registry.npmjs.org/micromark-extension-frontmatter/-/micromark-extension-frontmatter-2.0.0.tgz", + "integrity": "sha512-C4AkuM3dA58cgZha7zVnuVxBhDsbttIMiytjgsM2XbHAB2faRVaHRle40558FBN+DJcrLNCoqG5mlrpdU4cRtg==", "license": "MIT", "dependencies": { - "micromark-util-types": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/micromark-extension-gfm-task-list-item": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz", - "integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==", - "license": "MIT", - "dependencies": { - "devlop": "^1.0.0", - "micromark-factory-space": "^2.0.0", + "fault": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" @@ -3946,65 +3795,15 @@ "url": "https://paulmillr.com/funding/" } }, - "node_modules/rehype-slug": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/rehype-slug/-/rehype-slug-6.0.0.tgz", - "integrity": "sha512-lWyvf/jwu+oS5+hL5eClVd3hNdmwM1kAC0BUvEGD19pajQMIzcNUd/k9GsfQ+FfECvX+JE+e9/btsKH0EjJT6A==", - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0", - "github-slugger": "^2.0.0", - "hast-util-heading-rank": "^3.0.0", - "hast-util-to-string": "^3.0.0", - "unist-util-visit": "^5.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/remark-gfm": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", - "integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==", + "node_modules/remark-frontmatter": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/remark-frontmatter/-/remark-frontmatter-5.0.0.tgz", + "integrity": "sha512-XTFYvNASMe5iPN0719nPrdItC9aU0ssC4v14mH1BCi1u0n1gAocqcujWUrByftZTbLhRtiKRyjYTSIOcr69UVQ==", "license": "MIT", "dependencies": { "@types/mdast": "^4.0.0", - "mdast-util-gfm": "^3.0.0", - "micromark-extension-gfm": "^3.0.0", - "remark-parse": "^11.0.0", - "remark-stringify": "^11.0.0", - "unified": "^11.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/remark-parse": { - "version": "11.0.0", - "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", - "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "mdast-util-from-markdown": "^2.0.0", - "micromark-util-types": "^2.0.0", - "unified": "^11.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/remark-stringify": { - "version": "11.0.0", - "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz", - "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "mdast-util-to-markdown": "^2.0.0", + "mdast-util-frontmatter": "^2.0.0", + "micromark-extension-frontmatter": "^2.0.0", "unified": "^11.0.0" }, "funding": { @@ -4119,6 +3918,19 @@ "queue-microtask": "^1.2.2" } }, + "node_modules/section-matter": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/section-matter/-/section-matter-1.0.0.tgz", + "integrity": "sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==", + "license": "MIT", + "dependencies": { + "extend-shallow": "^2.0.1", + "kind-of": "^6.0.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/semver": { "version": "7.7.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", @@ -4192,6 +4004,12 @@ "node": ">=0.10.0" } }, + "node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "license": "BSD-3-Clause" + }, "node_modules/stackback": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", @@ -4219,6 +4037,15 @@ "node": ">=8" } }, + "node_modules/strip-bom-string": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/strip-bom-string/-/strip-bom-string-1.0.0.tgz", + "integrity": "sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -4404,6 +4231,12 @@ "node": ">=8.0" } }, + "node_modules/toml": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/toml/-/toml-3.0.0.tgz", + "integrity": "sha512-y/mWCZinnvxjTKYhJ+pYxwD0mRLVvOtdS2Awbgxln6iEnt4rk0yBxeSBHkGJcPucRiG0e55mwWp+g/05rsrd6w==", + "license": "MIT" + }, "node_modules/tree-kill": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", diff --git a/package.json b/package.json index b1ade23..864f22b 100644 --- a/package.json +++ b/package.json @@ -1,23 +1,22 @@ { - "name": "@quartz-community/plugin-template", + "name": "@quartz-community/note-properties", "version": "0.1.0", - "description": "Template repository for Quartz community plugins.", + "description": "Frontmatter processing and note properties display for Quartz", "type": "module", "license": "MIT", "author": "Quartz Community", - "homepage": "https://quartz.jzhao.xyz", + "homepage": "https://github.com/quartz-community/note-properties", "repository": { "type": "git", - "url": "https://github.com/quartz-community/plugin-template" + "url": "https://github.com/quartz-community/note-properties" }, "keywords": [ "quartz", "quartz-plugin", - "plugin-template", - "remark", - "rehype", - "mdast", - "hast" + "note-properties", + "frontmatter", + "transformer", + "component" ], "files": [ "dist", @@ -67,17 +66,16 @@ }, "dependencies": { "@quartz-community/types": "github:quartz-community/types", - "mdast-util-find-and-replace": "^3.0.1", - "rehype-slug": "^6.0.0", - "remark-gfm": "^4.0.1", - "remark-parse": "^11.0.0", - "remark-stringify": "^11.0.0", + "gray-matter": "^4.0.3", + "js-yaml": "^4.1.0", + "remark-frontmatter": "^5.0.0", + "toml": "^3.0.0", "unified": "^11.0.5", - "unist-util-visit": "^5.0.0", "vfile": "^6.0.3" }, "devDependencies": { "@types/hast": "^3.0.4", + "@types/js-yaml": "^4.0.9", "@types/mdast": "^4.0.4", "@types/node": "^24.10.0", "@typescript-eslint/eslint-plugin": "^7.18.0", @@ -90,26 +88,70 @@ "typescript": "^5.9.3", "vitest": "^2.1.9" }, - "quartz": { - "name": "plugin-template", - "displayName": "Plugin Template", - "category": "transformer", - "version": "1.0.0", - "quartzVersion": ">=5.0.0", - "dependencies": [], - "defaultOrder": 50, - "defaultEnabled": true, - "defaultOptions": {}, - "components": { - "ExampleComponent": { - "displayName": "Example Component", - "defaultPosition": "right", - "defaultPriority": 50 - } - } - }, "engines": { "node": ">=22", "npm": ">=10.9.2" + }, + "quartz": { + "name": "note-properties", + "displayName": "Note Properties", + "category": [ + "transformer", + "component" + ], + "version": "1.0.0", + "quartzVersion": ">=5.0.0", + "dependencies": [], + "defaultOrder": 5, + "defaultEnabled": true, + "defaultOptions": { + "includeAll": false, + "includedProperties": [ + "description", + "tags", + "aliases" + ], + "excludedProperties": [], + "hidePropertiesView": false, + "delimiters": "---", + "language": "yaml" + }, + "optionSchema": { + "includeAll": { + "type": "boolean", + "description": "Include all frontmatter properties in the display" + }, + "includedProperties": { + "type": "array", + "description": "Properties to include when includeAll is false" + }, + "excludedProperties": { + "type": "array", + "description": "Properties to exclude from display" + }, + "hidePropertiesView": { + "type": "boolean", + "description": "Hide the visual properties panel while still processing frontmatter" + }, + "delimiters": { + "type": "string", + "description": "Frontmatter delimiters" + }, + "language": { + "type": "enum", + "values": [ + "yaml", + "toml" + ], + "description": "Frontmatter language" + } + }, + "components": { + "NotePropertiesComponent": { + "displayName": "Note Properties", + "defaultPosition": "beforeBody", + "defaultPriority": 15 + } + } } } diff --git a/src/components/ExampleComponent.tsx b/src/components/ExampleComponent.tsx deleted file mode 100644 index d18ee6a..0000000 --- a/src/components/ExampleComponent.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import type { - QuartzComponent, - QuartzComponentProps, - QuartzComponentConstructor, -} from "@quartz-community/types"; -import { classNames } from "../util/lang"; -import { i18n } from "../i18n"; -import style from "./styles/example.scss"; -// @ts-ignore -import script from "./scripts/example.inline.ts"; - -export interface ExampleComponentOptions { - prefix?: string; - suffix?: string; - className?: string; -} - -export default ((opts?: ExampleComponentOptions) => { - const { prefix = "", suffix = "", className = "example-component" } = opts ?? {}; - - const Component: QuartzComponent = (props: QuartzComponentProps) => { - const frontmatter = props.fileData?.frontmatter as { title?: string } | undefined; - const title = frontmatter?.title ?? "Untitled"; - const fullText = `${prefix}${title}${suffix}`; - - return
{fullText}
; - }; - - Component.css = style; - Component.afterDOMLoaded = script; - - return Component; -}) satisfies QuartzComponentConstructor; diff --git a/src/components/NoteProperties.tsx b/src/components/NoteProperties.tsx new file mode 100644 index 0000000..d3e107c --- /dev/null +++ b/src/components/NoteProperties.tsx @@ -0,0 +1,171 @@ +import type { + QuartzComponent, + QuartzComponentProps, + QuartzComponentConstructor, +} from "@quartz-community/types"; +import { classNames } from "../util/lang"; +import { i18n } from "../i18n"; +import style from "./styles/noteProperties.scss"; +// @ts-ignore +import script from "./scripts/noteProperties.inline.ts"; + +export interface NotePropertiesComponentOptions { + /** Collapse the properties panel by default */ + collapsed?: boolean; +} + +function renderValue(value: unknown): preact.JSX.Element | string { + if (value === null || value === undefined) { + return ; + } + + if (typeof value === "boolean") { + return ( + + + + ); + } + + if (typeof value === "number") { + return {value}; + } + + if (typeof value === "string") { + // Check for wikilinks + const wikilinkMatch = value.match(/^\[\[([^\]|]+)(?:\|([^\]]+))?\]\]$/); + if (wikilinkMatch) { + const target = wikilinkMatch[1]; + const display = wikilinkMatch[2] ?? target; + return ( + + {display} + + ); + } + + // Check for markdown links + const mdLinkMatch = value.match(/^\[([^\]]*)\]\(([^)]+)\)$/); + if (mdLinkMatch) { + const display = mdLinkMatch[1]!; + const href = mdLinkMatch[2]!; + const isExternal = href.startsWith("http://") || href.startsWith("https://"); + return ( + + {display || href} + + ); + } + + // Check for URLs + if (value.startsWith("http://") || value.startsWith("https://")) { + return ( + + {value} + + ); + } + + return {value}; + } + + if (Array.isArray(value)) { + return ( + + ); + } + + if (typeof value === "object") { + const entries = Object.entries(value as Record); + return ( +
+ {entries.map(([k, v]) => ( + <> +
{k}
+
{renderValue(v)}
+ + ))} +
+ ); + } + + return String(value); +} + +function renderTagList(tags: string[]): preact.JSX.Element { + return ( + + ); +} + +export default ((opts?: NotePropertiesComponentOptions) => { + const { collapsed = false } = opts ?? {}; + + const Component: QuartzComponent = (props: QuartzComponentProps) => { + const noteProps = props.fileData?.noteProperties as + | { properties: Record; hideView: boolean } + | undefined; + + if (!noteProps || noteProps.hideView) return null; + + const properties = noteProps.properties; + const entries = Object.entries(properties); + if (entries.length === 0) return null; + + const locale = props.cfg?.locale || "en-US"; + const i18nData = i18n(locale); + + return ( +
+ + {i18nData.components.noteProperties.title} + {entries.length} + + + + {entries.map(([key, value]) => ( + + + + + ))} + +
{key} + {key === "tags" && Array.isArray(value) + ? renderTagList(value as string[]) + : renderValue(value)} +
+
+ ); + }; + + Component.css = style; + Component.afterDOMLoaded = script; + + return Component; +}) satisfies QuartzComponentConstructor; diff --git a/src/components/index.ts b/src/components/index.ts index f2ca23a..dfb21db 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -1,2 +1,2 @@ -export { default as ExampleComponent } from "./ExampleComponent"; -export type { ExampleComponentOptions } from "./ExampleComponent"; +export { default as NotePropertiesComponent } from "./NoteProperties"; +export type { NotePropertiesComponentOptions } from "./NoteProperties"; diff --git a/src/components/scripts/example.inline.ts b/src/components/scripts/example.inline.ts deleted file mode 100644 index 366f8e5..0000000 --- a/src/components/scripts/example.inline.ts +++ /dev/null @@ -1,107 +0,0 @@ -// @ts-nocheck -// ============================================================================ -// Example Inline Script for Quartz Community Plugin -// ============================================================================ -// This file demonstrates patterns commonly used in Quartz plugin client-side code. -// It is bundled as a string and injected via Component.afterDOMLoaded. -// -// Key patterns demonstrated: -// 1. Listening to Quartz navigation events ('nav', 'prenav') -// 2. Fetching content index data -// 3. DOM manipulation with cleanup -// 4. State persistence (localStorage/sessionStorage) -// 5. Keyboard shortcut handling -// 6. Proper event listener cleanup -// ============================================================================ - -// Helper: Remove all children from an element -function removeAllChildren(element) { - while (element.firstChild) { - element.removeChild(element.firstChild); - } -} - -// Helper: Simplify slug by removing trailing /index -function simplifySlug(slug) { - if (slug.endsWith("/index")) { - return slug.slice(0, -6); - } - return slug; -} - -// Helper: Get current page slug from URL -function getCurrentSlug() { - let slug = window.location.pathname; - if (slug.startsWith("/")) slug = slug.slice(1); - if (slug.endsWith("/")) slug = slug.slice(0, -1); - return slug || "index"; -} - -// Helper: Fetch content index (commonly needed for search, graph, explorer) -async function fetchContentIndex() { - try { - const response = await fetch("/static/contentIndex.json"); - const data = await response.json(); - // Handle both formats: { "slug": {...} } or { "content": { "slug": {...} } } - return data.content || data; - } catch (error) { - console.error("[Plugin] Error fetching content index:", error); - return null; - } -} - -// Main initialization function -function init() { - const components = document.querySelectorAll(".example-component"); - if (components.length === 0) return; - - // Example: Track cleanup functions for event listeners - const cleanupFns = []; - - // Example: Add a keyboard shortcut (Ctrl/Cmd + Shift + E) - function keyboardHandler(e) { - if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key.toLowerCase() === "e") { - e.preventDefault(); - console.log("[ExampleComponent] Keyboard shortcut triggered!"); - // Do something interesting here - } - } - - document.addEventListener("keydown", keyboardHandler); - cleanupFns.push(() => document.removeEventListener("keydown", keyboardHandler)); - - // Example: Click handler with proper cleanup - for (const component of components) { - const clickHandler = () => { - console.log("[ExampleComponent] Clicked!"); - }; - component.addEventListener("click", clickHandler); - cleanupFns.push(() => component.removeEventListener("click", clickHandler)); - } - - // Register cleanup with Quartz's cleanup system - if (typeof window !== "undefined" && window.addCleanup) { - window.addCleanup(() => { - cleanupFns.forEach((fn) => fn()); - }); - } - - console.log("[ExampleComponent] Initialized with", components.length, "component(s)"); -} - -// Listen to Quartz navigation events -// 'nav' fires after page navigation (including initial load) -document.addEventListener("nav", (e) => { - const slug = e.detail?.url || getCurrentSlug(); - console.log("[ExampleComponent] Navigation to:", slug); - init(); -}); - -// 'prenav' fires before navigation - use for saving state -document.addEventListener("prenav", () => { - // Example: Save scroll position before navigation - const component = document.querySelector(".example-component"); - if (component) { - sessionStorage.setItem("exampleScrollTop", component.scrollTop?.toString() || "0"); - } -}); diff --git a/src/components/scripts/noteProperties.inline.ts b/src/components/scripts/noteProperties.inline.ts new file mode 100644 index 0000000..151f1e0 --- /dev/null +++ b/src/components/scripts/noteProperties.inline.ts @@ -0,0 +1,33 @@ +// @ts-nocheck + + +const STORAGE_KEY = "note-properties-collapsed"; + +function init() { + const details = document.querySelector("details.note-properties"); + if (!details) return; + + + const saved = localStorage.getItem(STORAGE_KEY); + if (saved !== null) { + const isCollapsed = saved === "true"; + details.open = !isCollapsed; + } + + + function toggleHandler() { + localStorage.setItem(STORAGE_KEY, String(!details.open)); + } + + details.addEventListener("toggle", toggleHandler); + + if (typeof window !== "undefined" && window.addCleanup) { + window.addCleanup(() => { + details.removeEventListener("toggle", toggleHandler); + }); + } +} + +document.addEventListener("nav", () => { + init(); +}); diff --git a/src/components/styles/example.scss b/src/components/styles/example.scss deleted file mode 100644 index 49b4379..0000000 --- a/src/components/styles/example.scss +++ /dev/null @@ -1,8 +0,0 @@ -.example-component { - padding: 8px 16px; - background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); - color: white; - border-radius: 4px; - font-weight: 600; - display: inline-block; -} diff --git a/src/components/styles/noteProperties.scss b/src/components/styles/noteProperties.scss new file mode 100644 index 0000000..cb5a271 --- /dev/null +++ b/src/components/styles/noteProperties.scss @@ -0,0 +1,165 @@ +.note-properties { + margin: 0.5rem 0 1rem; + border: 1px solid var(--lightgray); + border-radius: 5px; + font-size: 0.9rem; + + &[open] > .note-properties-header { + border-bottom: 1px solid var(--lightgray); + } + + .note-properties-header { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.4rem 0.8rem; + cursor: pointer; + user-select: none; + list-style: none; + color: var(--darkgray); + font-weight: 600; + + &::-webkit-details-marker { + display: none; + } + + &::before { + content: ""; + display: inline-block; + width: 0.5em; + height: 0.5em; + border-right: 2px solid var(--darkgray); + border-bottom: 2px solid var(--darkgray); + transform: rotate(-45deg); + transition: transform 0.2s ease; + } + } + + &[open] > .note-properties-header::before { + transform: rotate(45deg); + } + + .note-properties-count { + margin-left: auto; + font-size: 0.75rem; + color: var(--gray); + font-weight: 400; + } + + .note-properties-table { + width: 100%; + border-collapse: collapse; + table-layout: fixed; + } + + .note-properties-row { + border-bottom: 1px solid var(--lightgray); + + &:last-child { + border-bottom: none; + } + } + + .note-properties-key { + width: 35%; + padding: 0.35rem 0.8rem; + color: var(--gray); + font-size: 0.85rem; + vertical-align: top; + word-break: break-word; + } + + .note-properties-value { + padding: 0.35rem 0.8rem; + vertical-align: top; + word-break: break-word; + } + + .note-properties-empty { + color: var(--gray); + font-style: italic; + } + + .note-properties-boolean { + input[type="checkbox"] { + pointer-events: none; + margin: 0; + vertical-align: middle; + } + } + + .note-properties-number { + font-family: var(--codeFont); + font-size: 0.85em; + } + + .note-properties-link { + text-decoration: none; + color: var(--secondary); + + &:hover { + text-decoration: underline; + } + } + + .note-properties-list { + list-style: none; + padding: 0; + margin: 0; + display: flex; + flex-wrap: wrap; + gap: 0.25rem 0.5rem; + + li { + margin: 0; + } + } + + .note-properties-tags { + list-style: none; + padding: 0; + margin: 0; + display: flex; + flex-wrap: wrap; + gap: 0.25rem 0.5rem; + + li { + margin: 0; + } + + .tag-link { + display: inline-block; + padding: 0.1rem 0.4rem; + border-radius: 3px; + background: var(--highlight); + color: var(--secondary); + font-size: 0.85em; + text-decoration: none; + + &:hover { + background: var(--secondary); + color: var(--light); + } + } + } + + .note-properties-object { + margin: 0; + display: grid; + grid-template-columns: auto 1fr; + gap: 0.15rem 0.5rem; + + dt { + color: var(--gray); + font-size: 0.85em; + + &::after { + content: ":"; + } + } + + dd { + margin: 0; + } + } +} diff --git a/src/emitter.ts b/src/emitter.ts deleted file mode 100644 index 00d69be..0000000 --- a/src/emitter.ts +++ /dev/null @@ -1,91 +0,0 @@ -import path from "node:path"; -import fs from "node:fs/promises"; -import type { - QuartzEmitterPlugin, - ProcessedContent, - BuildCtx, - FilePath, - FullSlug, -} from "@quartz-community/types"; -import type { ExampleEmitterOptions } from "./types"; - -const defaultOptions: ExampleEmitterOptions = { - manifestSlug: "plugin-manifest", - includeFrontmatter: true, - metadata: { - generator: "Quartz Plugin Template", - }, -}; - -const joinSegments = (...segments: string[]) => - segments - .filter((segment) => segment.length > 0) - .join("/") - .replace(/\/+/g, "/") as FilePath; - -const writeFile = async ( - outputDir: string, - slug: FullSlug, - ext: `.${string}` | "", - content: string, -) => { - const outputPath = joinSegments(outputDir, `${slug}${ext}`) as FilePath; - await fs.mkdir(path.dirname(outputPath), { recursive: true }); - await fs.writeFile(outputPath, content); - return outputPath; -}; - -/** - * Example emitter that writes a JSON manifest of content metadata. - */ -export const ExampleEmitter: QuartzEmitterPlugin> = ( - userOptions?: Partial, -) => { - const options = { ...defaultOptions, ...userOptions }; - const emitManifest = async (ctx: BuildCtx, content: ProcessedContent[]) => { - const manifest = { - ...options.metadata, - generatedAt: new Date().toISOString(), - pages: content.map(([_tree, vfile]) => { - const frontmatter = (vfile.data?.frontmatter ?? {}) as { - title?: string; - tags?: string[]; - [key: string]: unknown; - }; - return { - slug: vfile.data?.slug ?? null, - title: frontmatter.title ?? null, - tags: frontmatter.tags ?? null, - filePath: vfile.data?.filePath ?? null, - frontmatter: options.includeFrontmatter ? frontmatter : undefined, - }; - }), - }; - - let json = `${JSON.stringify(manifest, null, 2)}\n`; - if (options.transformManifest) { - json = options.transformManifest(json); - } - - const output = await writeFile( - ctx.argv.output, - options.manifestSlug as FullSlug, - ".json", - json, - ); - return [output]; - }; - - return { - name: "ExampleEmitter", - async emit(ctx, content, _resources) { - return emitManifest(ctx, content); - }, - async *partialEmit(ctx, content, _resources, _changeEvents) { - const outputPaths = await emitManifest(ctx, content); - for (const outputPath of outputPaths) { - yield outputPath; - } - }, - }; -}; diff --git a/src/filter.ts b/src/filter.ts deleted file mode 100644 index 5d308c6..0000000 --- a/src/filter.ts +++ /dev/null @@ -1,53 +0,0 @@ -import type { QuartzFilterPlugin, ProcessedContent, BuildCtx } from "@quartz-community/types"; -import type { ExampleFilterOptions } from "./types"; - -const defaultOptions: ExampleFilterOptions = { - allowDrafts: false, - excludeTags: ["private"], - excludePathPrefixes: ["_drafts/", "_private/"], -}; - -const normalizeTag = (tag: unknown) => (typeof tag === "string" ? tag.trim().toLowerCase() : ""); - -const includesTag = (tags: unknown, excludedTags: string[]) => { - if (!Array.isArray(tags)) { - return false; - } - - const normalizedExcluded = excludedTags.map((tag) => tag.toLowerCase()); - return tags.some((tag) => normalizedExcluded.includes(normalizeTag(tag))); -}; - -/** - * Example filter that removes drafts, tagged pages, and excluded path prefixes. - */ -export const ExampleFilter: QuartzFilterPlugin> = ( - userOptions?: Partial, -) => { - const options = { ...defaultOptions, ...userOptions }; - return { - name: "ExampleFilter", - shouldPublish(_ctx: BuildCtx, [_tree, vfile]: ProcessedContent) { - const frontmatter = (vfile.data?.frontmatter ?? {}) as { - draft?: boolean | string; - tags?: string[]; - }; - const isDraft = frontmatter.draft === true || frontmatter.draft === "true"; - if (isDraft && !options.allowDrafts) { - return false; - } - - if (includesTag(frontmatter.tags, options.excludeTags)) { - return false; - } - - const filePath = typeof vfile.data?.filePath === "string" ? vfile.data.filePath : ""; - const normalizedPath = filePath.replace(/\\/g, "/"); - if (options.excludePathPrefixes.some((prefix) => normalizedPath.startsWith(prefix))) { - return false; - } - - return true; - }, - }; -}; diff --git a/src/i18n/locales/en-US.ts b/src/i18n/locales/en-US.ts index 001f5c2..e0f7933 100644 --- a/src/i18n/locales/en-US.ts +++ b/src/i18n/locales/en-US.ts @@ -1,7 +1,7 @@ export default { components: { - example: { - title: "Example", + noteProperties: { + title: "Properties", }, }, }; diff --git a/src/index.ts b/src/index.ts index d5c31b9..33c44a0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,28 +1,12 @@ -export { ExampleTransformer } from "./transformer"; -export { ExampleFilter } from "./filter"; -export { ExampleEmitter } from "./emitter"; -export { default as ExampleComponent } from "./components/ExampleComponent"; +export { NoteProperties } from "./transformer"; +export { default as NotePropertiesComponent } from "./components/NoteProperties"; -export type { - ExampleTransformerOptions, - ExampleFilterOptions, - ExampleEmitterOptions, -} from "./types"; +export type { NotePropertiesOptions } from "./types"; +export type { NotePropertiesComponentOptions } from "./components/NoteProperties"; -export type { ExampleComponentOptions } from "./components/ExampleComponent"; - -// Re-export shared types from @quartz-community/types export type { QuartzComponent, QuartzComponentProps, QuartzComponentConstructor, - StringResource, QuartzTransformerPlugin, - QuartzFilterPlugin, - QuartzEmitterPlugin, - QuartzPageTypePlugin, - QuartzPageTypePluginInstance, - PageMatcher, - PageGenerator, - VirtualPage, } from "@quartz-community/types"; diff --git a/src/transformer.ts b/src/transformer.ts index 6afeead..9fbd096 100644 --- a/src/transformer.ts +++ b/src/transformer.ts @@ -1,103 +1,256 @@ -import type { PluggableList, Plugin } from "unified"; -import type { Root as MdastRoot } from "mdast"; -import type { Root as HastRoot, Element } from "hast"; -import type { VFile } from "vfile"; -import remarkGfm from "remark-gfm"; -import rehypeSlug from "rehype-slug"; -import { findAndReplace } from "mdast-util-find-and-replace"; -import { visit } from "unist-util-visit"; -import type { QuartzTransformerPlugin, BuildCtx } from "@quartz-community/types"; -import type { ExampleTransformerOptions } from "./types"; +import matter from "gray-matter"; +import remarkFrontmatter from "remark-frontmatter"; +import yaml from "js-yaml"; +import toml from "toml"; +import type { PluggableList } from "unified"; +import type { + QuartzTransformerPlugin, + BuildCtx, + QuartzPluginData, + FullSlug, + FilePath, +} from "@quartz-community/types"; +import type { NotePropertiesOptions } from "./types"; -const defaultOptions: ExampleTransformerOptions = { - highlightToken: "==", - headingClass: "example-plugin-heading", - enableGfm: true, - addHeadingSlugs: true, +const defaultOptions: NotePropertiesOptions = { + includeAll: false, + includedProperties: ["description", "tags", "aliases"], + excludedProperties: [], + hidePropertiesView: false, + delimiters: "---", + language: "yaml", }; -const escapeRegExp = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +function coalesceAliases(data: Record, aliases: string[]): unknown | undefined { + for (const alias of aliases) { + if (data[alias] !== undefined && data[alias] !== null) return data[alias]; + } +} -const remarkHighlightToken = (token: string): Plugin<[], MdastRoot> => { - const escapedToken = escapeRegExp(token); - const pattern = new RegExp(`${escapedToken}([^\n]+?)${escapedToken}`, "g"); - return () => (tree: MdastRoot, _file: VFile) => { - findAndReplace(tree, [ - [ - pattern, - (_match: string, value: string) => ({ - type: "strong", - children: [{ type: "text", value }], - }), - ], - ]); - }; -}; +function coerceToArray(input: unknown): string[] | undefined { + if (input === undefined || input === null) return undefined; -const rehypeHeadingClass = (className: string): Plugin<[], HastRoot> => { - return () => (tree: HastRoot, _file: VFile) => { - visit(tree, "element", (node: Element) => { - if (!/^h[1-6]$/.test(node.tagName)) { - return; + if (!Array.isArray(input)) { + return String(input) + .split(",") + .map((s: string) => s.trim()); + } + + return input + .filter((v: unknown) => typeof v === "string" || typeof v === "number") + .map((v: string | number) => v.toString()); +} + +function slugTag(tag: string): string { + return tag + .split("/") + .map((segment) => + segment + .replace(/\s+/g, "-") + .replace(/[^\w\p{L}\p{M}\p{N}/-]/gu, "") + .toLowerCase(), + ) + .join("/"); +} + +function getFileExtension(fp: string): string { + return fp.split(".").pop() ?? ""; +} + +function slugifyFilePath(fp: string): FullSlug { + fp = fp.replace(/\\/g, "/"); + fp = fp.replace(/\.md$/, ""); + let slug = fp + .split("/") + .map((segment) => segment.replace(/\s+/g, "-").replace(/[^\w\p{L}\p{M}\p{N}/-]/gu, "")) + .join("/"); + slug = slug.replace(/\/$/, ""); + return slug as FullSlug; +} + +function getAliasSlugs(aliases: string[]): FullSlug[] { + return aliases.map((alias) => { + const isMd = getFileExtension(alias) === "md"; + const mockFp = isMd ? alias : alias + ".md"; + return slugifyFilePath(mockFp as FilePath); + }); +} + +// Wikilink pattern: [[target|display]] or [[target]] +const WIKILINK_PATTERN = /\[\[([^\]|]+)(?:\|[^\]]+)?\]\]/g; +// Markdown link pattern: [text](target) +const MDLINK_PATTERN = /\[(?:[^\]]*)\]\(([^)]+)\)/g; + +function extractLinksFromValue(value: unknown): string[] { + if (typeof value === "string") { + const links: string[] = []; + let match: RegExpExecArray | null; + + WIKILINK_PATTERN.lastIndex = 0; + while ((match = WIKILINK_PATTERN.exec(value)) !== null) { + links.push(match[1]!); + } + + MDLINK_PATTERN.lastIndex = 0; + while ((match = MDLINK_PATTERN.exec(value)) !== null) { + links.push(match[1]!); + } + + return links; + } + + if (Array.isArray(value)) { + return value.flatMap((item) => extractLinksFromValue(item)); + } + + if (value !== null && typeof value === "object") { + return Object.values(value).flatMap((v) => extractLinksFromValue(v)); + } + + return []; +} + +function getVisibleProperties( + data: Record, + opts: NotePropertiesOptions, +): Record { + const excluded = new Set(opts.excludedProperties); + + if (opts.includeAll) { + const result: Record = {}; + for (const [key, value] of Object.entries(data)) { + if (!excluded.has(key)) { + result[key] = value; } + } + return result; + } - const existing = node.properties?.className; - const classes: string[] = Array.isArray(existing) - ? existing.filter((value): value is string => typeof value === "string") - : typeof existing === "string" - ? [existing] - : []; - node.properties = { - ...node.properties, - className: [...classes, className], - }; - }); - }; -}; + const result: Record = {}; + for (const key of opts.includedProperties) { + if (!excluded.has(key) && data[key] !== undefined) { + result[key] = data[key]; + } + } + return result; +} -/** - * Example transformer showing remark/rehype usage and resource injection. - */ -export const ExampleTransformer: QuartzTransformerPlugin> = ( - userOptions?: Partial, +export const NoteProperties: QuartzTransformerPlugin> = ( + userOpts, ) => { - const options = { ...defaultOptions, ...userOptions }; + const opts = { ...defaultOptions, ...userOpts }; return { - name: "ExampleTransformer", - textTransform(_ctx: BuildCtx, src: string) { - return src.endsWith("\n") ? src : `${src}\n`; - }, - markdownPlugins(): PluggableList { - const plugins: PluggableList = [remarkHighlightToken(options.highlightToken)]; - if (options.enableGfm) { - plugins.unshift(remarkGfm); - } - return plugins; - }, - htmlPlugins(): PluggableList { - const plugins: PluggableList = [rehypeHeadingClass(options.headingClass)]; - if (options.addHeadingSlugs) { - plugins.unshift(rehypeSlug); - } - return plugins; - }, - externalResources() { - return { - css: [ - { - content: `.${options.headingClass} { letter-spacing: 0.02em; }`, - inline: true, - }, - ], - js: [ - { - contentType: "inline", - loadTime: "afterDOMReady", - script: "document.documentElement.dataset.exampleTransformer = 'true'", - }, - ], - additionalHead: [], - }; + name: "NoteProperties", + markdownPlugins(ctx: BuildCtx) { + const { cfg, allSlugs } = ctx; + return [ + [remarkFrontmatter, ["yaml", "toml"]], + () => { + return (_, file) => { + const fileData = Buffer.from(file.value as Uint8Array); + const { data } = matter(fileData, { + delimiters: opts.delimiters, + language: opts.language, + engines: { + yaml: (s) => yaml.load(s, { schema: yaml.JSON_SCHEMA }) as object, + toml: (s) => toml.parse(s) as object, + }, + }); + + if (data.title != null && data.title.toString() !== "") { + data.title = data.title.toString(); + } else { + data.title = file.stem ?? "Untitled"; + } + + const tags = coerceToArray(coalesceAliases(data, ["tags", "tag"])); + if (tags) data.tags = [...new Set(tags.map((tag: string) => slugTag(tag)))]; + + const aliases = coerceToArray(coalesceAliases(data, ["aliases", "alias"])); + if (aliases) { + data.aliases = aliases; + file.data.aliases = getAliasSlugs(aliases); + allSlugs.push(...file.data.aliases); + } + + if (data.permalink != null && data.permalink.toString() !== "") { + data.permalink = data.permalink.toString() as FullSlug; + const fileAliases = (file.data.aliases as FullSlug[]) ?? []; + fileAliases.push(data.permalink); + file.data.aliases = fileAliases; + allSlugs.push(data.permalink); + } + + const cssclasses = coerceToArray(coalesceAliases(data, ["cssclasses", "cssclass"])); + if (cssclasses) data.cssclasses = cssclasses; + + const socialImage = coalesceAliases(data, ["socialImage", "image", "cover"]); + + const created = coalesceAliases(data, ["created", "date"]); + if (created) data.created = created; + + const modified = coalesceAliases(data, [ + "modified", + "lastmod", + "updated", + "last-modified", + ]); + if (modified) data.modified = modified; + data.modified ||= created; + + const published = coalesceAliases(data, ["published", "publishDate", "date"]); + if (published) data.published = published; + + if (socialImage) data.socialImage = socialImage; + + const uniqueSlugs = [...new Set(allSlugs)]; + allSlugs.splice(0, allSlugs.length, ...uniqueSlugs); + + const frontmatterLinks = extractLinksFromValue(data); + if (frontmatterLinks.length > 0) { + const existingLinks = (file.data.frontmatterLinks as string[]) ?? []; + file.data.frontmatterLinks = [...existingLinks, ...frontmatterLinks]; + } + + const visibleProps = getVisibleProperties(data, opts); + file.data.noteProperties = { + properties: visibleProps, + hideView: opts.hidePropertiesView, + }; + + file.data.frontmatter = data as QuartzPluginData["frontmatter"]; + }; + }, + ]; }, }; }; + +declare module "vfile" { + interface DataMap { + aliases: FullSlug[]; + frontmatter: { [key: string]: unknown } & { + title: string; + } & Partial<{ + tags: string[]; + aliases: string[]; + modified: string; + created: string; + published: string; + description: string; + socialDescription: string; + publish: boolean | string; + draft: boolean | string; + lang: string; + enableToc: string; + cssclasses: string[]; + socialImage: string; + comments: boolean | string; + }>; + frontmatterLinks: string[]; + noteProperties: { + properties: Record; + hideView: boolean; + }; + } +} diff --git a/src/types.ts b/src/types.ts index f3fac5a..865e3c3 100644 --- a/src/types.ts +++ b/src/types.ts @@ -17,46 +17,21 @@ export type { VirtualPage, QuartzPageTypePlugin, QuartzPageTypePluginInstance, + FullSlug, + FilePath, } from "@quartz-community/types"; -export interface ExampleTransformerOptions { - /** Token used to highlight text, defaults to ==highlight== */ - highlightToken: string; - /** Add a CSS class to all headings in the rendered HTML. */ - headingClass: string; - /** Enable remark-gfm for tables/task lists. */ - enableGfm: boolean; - /** Enable adding slug IDs to headings. */ - addHeadingSlugs: boolean; -} - -export interface ExampleFilterOptions { - /** Allow pages marked draft: true to publish. */ - allowDrafts: boolean; - /** Exclude pages that contain any of these frontmatter tags. */ - excludeTags: string[]; - /** Exclude paths that start with any of these prefixes (relative to content root). */ - excludePathPrefixes: string[]; -} - -export interface ExampleEmitterOptions { - /** Filename to emit at the site root. */ - manifestSlug: string; - /** Whether to include the frontmatter block in the manifest. */ - includeFrontmatter: boolean; - /** Extra metadata to write at the top level of the manifest. */ - metadata: Record; - /** Optional hook to transform the emitted manifest JSON string. */ - transformManifest?: (json: string) => string; - /** Add a custom class to the emitted manifest