diff --git a/quartz.lock.json b/quartz.lock.json index 467d73c28..299d72b16 100644 --- a/quartz.lock.json +++ b/quartz.lock.json @@ -4,224 +4,224 @@ "explorer": { "source": "github:quartz-community/explorer", "resolved": "https://github.com/quartz-community/explorer.git", - "commit": "47b42a06f7a6688393b5790cf062dd2d3a3816a3", - "installedAt": "2026-02-14T01:15:32.713Z" + "commit": "09452e44a4c0e9b2c1d94e6e7a5dc98137dac230", + "installedAt": "2026-02-17T17:28:27.727Z" }, "graph": { "source": "github:quartz-community/graph", "resolved": "https://github.com/quartz-community/graph.git", - "commit": "6b10d168ff8c2df9ee7d2aee8076b787f79a22ec", - "installedAt": "2026-02-14T00:12:10.810Z" + "commit": "f61ef4aaad0a560428373508e49efa8bf5d0dbf6", + "installedAt": "2026-02-17T17:28:28.934Z" }, "search": { "source": "github:quartz-community/search", "resolved": "https://github.com/quartz-community/search.git", - "commit": "9939f74a49f5caba98b57d8f24492e89f48979ae", - "installedAt": "2026-02-14T00:12:11.259Z" + "commit": "5d206baacbad17e99925f61c6263e98a494258d1", + "installedAt": "2026-02-17T17:28:29.442Z" }, "backlinks": { "source": "github:quartz-community/backlinks", "resolved": "https://github.com/quartz-community/backlinks.git", - "commit": "b6c6ec516ede1fe766353e471cba056317347f32", - "installedAt": "2026-02-14T01:15:37.731Z" + "commit": "8adb778566a3d8425d321d6bbc0b673cba9ec63d", + "installedAt": "2026-02-17T17:28:30.109Z" }, "table-of-contents": { "source": "github:quartz-community/table-of-contents", "resolved": "https://github.com/quartz-community/table-of-contents.git", - "commit": "43aaea2e8a80baaf38c2c633765299d6a03e0f59", - "installedAt": "2026-02-14T01:15:39.471Z" + "commit": "38e7f0198114e4466cb39ea0c2ebb01e46847d39", + "installedAt": "2026-02-17T17:28:30.541Z" }, "comments": { "source": "github:quartz-community/comments", "resolved": "https://github.com/quartz-community/comments.git", - "commit": "ff4313309b0e56214ef4875e2c0aca2ab0306ab0", - "installedAt": "2026-02-14T01:15:41.231Z" + "commit": "a2bee0c8f2f899f4f181a8a6f52ef300eb6bb8e4", + "installedAt": "2026-02-17T17:28:30.983Z" }, "breadcrumbs": { "source": "github:quartz-community/breadcrumbs", "resolved": "https://github.com/quartz-community/breadcrumbs.git", - "commit": "25ada82faf132e926603d7d74d3812e59cf9cfa1", - "installedAt": "2026-02-14T01:15:42.947Z" + "commit": "441c3c474b2a7aeecb483aa4421c7a633458e97d", + "installedAt": "2026-02-17T17:28:31.401Z" }, "recent-notes": { "source": "github:quartz-community/recent-notes", "resolved": "https://github.com/quartz-community/recent-notes.git", - "commit": "3d9ef14936e6ee50c03de8c01ad04006293f7101", - "installedAt": "2026-02-14T01:15:44.526Z" + "commit": "96d5df9fc6d7e0e8bd3a90856faaa08d2ac3441b", + "installedAt": "2026-02-17T17:28:31.827Z" }, "latex": { "source": "github:quartz-community/latex", "resolved": "https://github.com/quartz-community/latex.git", - "commit": "ae28358f91b9414c367d7f92aef17ddb860f51cd", - "installedAt": "2026-02-14T01:15:46.351Z" + "commit": "d7d4a8de001ec18289d12b58c8144f27ab72cb06", + "installedAt": "2026-02-17T17:28:32.350Z" }, "article-title": { "source": "github:quartz-community/article-title", "resolved": "https://github.com/quartz-community/article-title.git", - "commit": "32c2c9238f6cc7d031c5f46934fe8823ae0b941b", - "installedAt": "2026-02-14T01:15:48.273Z" + "commit": "031872e39ba30d8567af63c6a3891b3054432194", + "installedAt": "2026-02-17T17:28:32.818Z" }, "tag-list": { "source": "github:quartz-community/tag-list", "resolved": "https://github.com/quartz-community/tag-list.git", - "commit": "adcb320f51824a1a18f675fb4c106cb64ac3002a", - "installedAt": "2026-02-14T01:15:50.018Z" + "commit": "a0a30b822447261a7a9bcf96d649ca7525eb3ded", + "installedAt": "2026-02-17T17:28:33.366Z" }, "page-title": { "source": "github:quartz-community/page-title", "resolved": "https://github.com/quartz-community/page-title.git", - "commit": "f93a92587938c51623b13902c2f40d8907746cf3", - "installedAt": "2026-02-14T01:15:51.758Z" + "commit": "d14bffe11830eff6e4489945324a0dd33d994bf6", + "installedAt": "2026-02-17T17:28:33.828Z" }, "darkmode": { "source": "github:quartz-community/darkmode", "resolved": "https://github.com/quartz-community/darkmode.git", - "commit": "c321b81c7a049987914e8247ba1c7c2251b295a6", - "installedAt": "2026-02-14T01:15:53.419Z" + "commit": "453e59cf913b2276a5bb6d6bfdc0685304618877", + "installedAt": "2026-02-17T17:28:34.395Z" }, "reader-mode": { "source": "github:quartz-community/reader-mode", "resolved": "https://github.com/quartz-community/reader-mode.git", - "commit": "69b32f805407c3289d5509d52dd66d42f0337f7d", - "installedAt": "2026-02-14T01:15:55.038Z" + "commit": "d362013e0de040dcd8711e7c6bf68b93a0668bae", + "installedAt": "2026-02-17T17:28:34.989Z" }, "content-meta": { "source": "github:quartz-community/content-meta", "resolved": "https://github.com/quartz-community/content-meta.git", - "commit": "87fa94a83a658396dd9683705a47860b47385933", - "installedAt": "2026-02-14T01:15:56.873Z" + "commit": "be178b7ab4f521f0ca29051676f207a23b706051", + "installedAt": "2026-02-17T17:28:35.416Z" }, "footer": { "source": "github:quartz-community/footer", "resolved": "https://github.com/quartz-community/footer.git", - "commit": "0fe310de1e7d306e2a7e9db28a536239716dec42", - "installedAt": "2026-02-14T01:15:58.525Z" + "commit": "eef4ba0af26ea6097543784fc5cfa6820d6b8250", + "installedAt": "2026-02-17T17:28:35.903Z" }, "content-page": { "source": "github:quartz-community/content-page", "resolved": "https://github.com/quartz-community/content-page.git", - "commit": "2cacb9751b07c5b3c2f13cc79c44f76e6726d8c9", - "installedAt": "2026-02-14T01:16:00.123Z" + "commit": "9975389d442707d8772aebeaba02676169569c60", + "installedAt": "2026-02-17T17:28:36.353Z" }, "folder-page": { "source": "github:quartz-community/folder-page", "resolved": "https://github.com/quartz-community/folder-page.git", - "commit": "cb70580327a83e72517dfc2426f30add46a6d41b", - "installedAt": "2026-02-14T01:16:01.754Z" + "commit": "630c26711de6a5cb91ba2b81882f4fad91bbbaeb", + "installedAt": "2026-02-17T17:28:36.806Z" }, "tag-page": { "source": "github:quartz-community/tag-page", "resolved": "https://github.com/quartz-community/tag-page.git", - "commit": "49e4a6ce0201295275ca3512bd95b118a42e1a15", - "installedAt": "2026-02-14T01:16:03.431Z" + "commit": "27ad16e609832805b2e11c7836937706a38caec1", + "installedAt": "2026-02-17T17:28:37.377Z" }, "created-modified-date": { "source": "github:quartz-community/created-modified-date", "resolved": "https://github.com/quartz-community/created-modified-date.git", - "commit": "1b42ce86e4c3e8f8b8906cdb5e7fe8b0f7a4e1c6", - "installedAt": "2026-02-14T01:16:05.122Z" + "commit": "4d976e88da01d9fa0dbdcd11e756b1b3037b3a49", + "installedAt": "2026-02-17T17:28:37.833Z" }, "syntax-highlighting": { "source": "github:quartz-community/syntax-highlighting", "resolved": "https://github.com/quartz-community/syntax-highlighting.git", - "commit": "92936ff025f068bf4d72950ff58ec2e56f4da607", - "installedAt": "2026-02-14T01:16:06.769Z" + "commit": "a3ec90046f68e8d8bbbd275f07f606268cb6e001", + "installedAt": "2026-02-17T17:28:38.257Z" }, "obsidian-flavored-markdown": { "source": "github:quartz-community/obsidian-flavored-markdown", "resolved": "https://github.com/quartz-community/obsidian-flavored-markdown.git", - "commit": "b985da8db841cb8a453785608ff0a5feb847a69b", - "installedAt": "2026-02-16T21:29:43.859Z" + "commit": "a5f45c21cec287e8aa2896b5032fe48962ed9b77", + "installedAt": "2026-02-17T17:28:38.693Z" }, "github-flavored-markdown": { "source": "github:quartz-community/github-flavored-markdown", "resolved": "https://github.com/quartz-community/github-flavored-markdown.git", - "commit": "8885e276caa3d3a298d1bfff286571889ad1e47b", - "installedAt": "2026-02-14T01:16:10.497Z" + "commit": "7e19794a519debc994e2e99c67b223661b0aac0c", + "installedAt": "2026-02-17T17:28:39.147Z" }, "crawl-links": { "source": "github:quartz-community/crawl-links", "resolved": "https://github.com/quartz-community/crawl-links.git", - "commit": "eb09d4d77fb2aa704e12db157d42fa1c76d81b02", - "installedAt": "2026-02-14T01:16:12.154Z" + "commit": "3ca88b4ba1a049cc79b3cf1154b771e4eb8f7549", + "installedAt": "2026-02-17T17:28:39.629Z" }, "description": { "source": "github:quartz-community/description", "resolved": "https://github.com/quartz-community/description.git", - "commit": "8b34242c39639c9fdd0c2161ac43871a0895b7f3", - "installedAt": "2026-02-14T01:16:13.775Z" + "commit": "82f1c95e53acc7521fa6bf46e9ea20018ee28264", + "installedAt": "2026-02-17T17:28:40.060Z" }, "hard-line-breaks": { "source": "github:quartz-community/hard-line-breaks", "resolved": "https://github.com/quartz-community/hard-line-breaks.git", - "commit": "146ff2162a62de2bf6b341d2402aa43e88c9d221", - "installedAt": "2026-02-14T01:16:15.617Z" + "commit": "b14a477db87e6657440a044e6d2fbf9d943472e6", + "installedAt": "2026-02-17T17:28:40.498Z" }, "citations": { "source": "github:quartz-community/citations", "resolved": "https://github.com/quartz-community/citations.git", - "commit": "90c35ef22561e37a5f2e681b7307477ca2d8f631", - "installedAt": "2026-02-14T01:16:17.397Z" + "commit": "b5db4ea6fd6f3fc9c1ce37cd9cdba0ca9e74ecc8", + "installedAt": "2026-02-17T17:28:40.960Z" }, "ox-hugo": { "source": "github:quartz-community/ox-hugo", "resolved": "https://github.com/quartz-community/ox-hugo.git", - "commit": "4f0616e3a65b6b93a66a1cbd4948dd26781a37f3", - "installedAt": "2026-02-14T01:16:19.224Z" + "commit": "d8fdf7c6a54464e960c6b817147968cf07719086", + "installedAt": "2026-02-17T17:28:41.395Z" }, "roam": { "source": "github:quartz-community/roam", "resolved": "https://github.com/quartz-community/roam.git", - "commit": "9bb85bfbd99624ab014ec65bc58fe665fecf1f3b", - "installedAt": "2026-02-14T01:16:20.879Z" + "commit": "f94276c284edb01e6b4136ec6f7683f29123c54c", + "installedAt": "2026-02-17T17:28:41.832Z" }, "remove-draft": { "source": "github:quartz-community/remove-draft", "resolved": "https://github.com/quartz-community/remove-draft.git", - "commit": "eab9c76ddb279b953a90b6d5bcc65971d488ef7c", - "installedAt": "2026-02-14T01:16:22.581Z" + "commit": "61744308cf739f63fe351dea8dffd6b7c434440d", + "installedAt": "2026-02-17T17:28:42.324Z" }, "explicit-publish": { "source": "github:quartz-community/explicit-publish", "resolved": "https://github.com/quartz-community/explicit-publish.git", - "commit": "3166770fc61d3698e800fb42285a1abfdf3ad8d9", - "installedAt": "2026-02-14T01:16:24.321Z" + "commit": "ba19e6f32393fa5fe788dbf39a9487bc8e73247a", + "installedAt": "2026-02-17T17:28:42.799Z" }, "alias-redirects": { "source": "github:quartz-community/alias-redirects", "resolved": "https://github.com/quartz-community/alias-redirects.git", - "commit": "0fd53121bddf87528fa180343e5a0c06dc606945", - "installedAt": "2026-02-14T01:16:26.164Z" + "commit": "65897957ab8108d015cd4255d42d84df7377c083", + "installedAt": "2026-02-17T17:28:43.267Z" }, "cname": { "source": "github:quartz-community/cname", "resolved": "https://github.com/quartz-community/cname.git", - "commit": "dd2bb729663d63ff745573a811fdea4e346d04c3", - "installedAt": "2026-02-14T01:16:28.105Z" + "commit": "752470f04410576a767b48094e926d41eef3eb18", + "installedAt": "2026-02-17T17:28:43.713Z" }, "favicon": { "source": "github:quartz-community/favicon", "resolved": "https://github.com/quartz-community/favicon.git", - "commit": "2112d5c5813dccef150fd7a7c00b6da8406c5ada", - "installedAt": "2026-02-14T01:16:29.931Z" + "commit": "71cfff28b7a41f28618f574e672b2b399c6b781d", + "installedAt": "2026-02-17T17:28:44.365Z" }, "content-index": { "source": "github:quartz-community/content-index", "resolved": "https://github.com/quartz-community/content-index.git", - "commit": "6162a6ae8afdaff97358337a368299dc0dcb3c55", - "installedAt": "2026-02-14T01:16:31.670Z" + "commit": "2b9c62d659b7f8e9e7cc028a201cae7061224653", + "installedAt": "2026-02-17T17:28:44.806Z" }, "og-image": { "source": "github:quartz-community/og-image", "resolved": "https://github.com/quartz-community/og-image.git", - "commit": "c72ed66e951663a40bd2d0834725090572ffb124", - "installedAt": "2026-02-14T01:16:33.273Z" + "commit": "9a00809fccb0e1e4b7db4ee6e6eed002952396f4", + "installedAt": "2026-02-17T17:28:45.533Z" }, "canvas-page": { "source": "github:quartz-community/canvas-page", "resolved": "https://github.com/quartz-community/canvas-page.git", - "commit": "505c09999e3ff32a34bc76a0bb398dca837e1a5f", - "installedAt": "2026-02-15T00:26:57.162Z" + "commit": "683c2da6bb068d3e596cdb3bfcca68007829cd17", + "installedAt": "2026-02-17T17:28:46.012Z" } } } diff --git a/quartz.plugins.default.json b/quartz.plugins.default.json new file mode 100644 index 000000000..ab43c0085 --- /dev/null +++ b/quartz.plugins.default.json @@ -0,0 +1,318 @@ +{ + "$schema": "./quartz/plugins/quartz-plugins.schema.json", + "configuration": { + "pageTitle": "Quartz 5", + "pageTitleSuffix": "", + "enableSPA": true, + "enablePopovers": true, + "analytics": { "provider": "plausible" }, + "locale": "en-US", + "baseUrl": "quartz.jzhao.xyz", + "ignorePatterns": ["private", "templates", ".obsidian"], + "defaultDateType": "modified", + "theme": { + "fontOrigin": "googleFonts", + "cdnCaching": true, + "typography": { + "header": "Schibsted Grotesk", + "body": "Source Sans Pro", + "code": "IBM Plex Mono" + }, + "colors": { + "lightMode": { + "light": "#faf8f8", + "lightgray": "#e5e5e5", + "gray": "#b8b8b8", + "darkgray": "#4e4e4e", + "dark": "#2b2b2b", + "secondary": "#284b63", + "tertiary": "#84a59d", + "highlight": "rgba(143, 159, 169, 0.15)", + "textHighlight": "#fff23688" + }, + "darkMode": { + "light": "#161618", + "lightgray": "#393639", + "gray": "#646464", + "darkgray": "#d4d4d4", + "dark": "#ebebec", + "secondary": "#7b97aa", + "tertiary": "#84a59d", + "highlight": "rgba(143, 159, 169, 0.15)", + "textHighlight": "#b3aa0288" + } + } + } + }, + "plugins": [ + { + "source": "github:quartz-community/created-modified-date", + "enabled": true, + "options": { "priority": ["frontmatter", "git", "filesystem"] }, + "order": 10 + }, + { + "source": "github:quartz-community/syntax-highlighting", + "enabled": true, + "options": { + "theme": { "light": "github-light", "dark": "github-dark" }, + "keepBackground": false + }, + "order": 20 + }, + { + "source": "github:quartz-community/obsidian-flavored-markdown", + "enabled": true, + "options": { "enableInHtmlEmbed": false, "enableCheckbox": true }, + "order": 30 + }, + { + "source": "github:quartz-community/github-flavored-markdown", + "enabled": true, + "order": 40 + }, + { + "source": "github:quartz-community/table-of-contents", + "enabled": true, + "order": 50 + }, + { + "source": "github:quartz-community/crawl-links", + "enabled": true, + "options": { "markdownLinkResolution": "shortest" }, + "order": 60 + }, + { + "source": "github:quartz-community/description", + "enabled": true, + "order": 70 + }, + { + "source": "github:quartz-community/latex", + "enabled": true, + "options": { "renderEngine": "katex" }, + "order": 80 + }, + { + "source": "github:quartz-community/citations", + "enabled": false, + "order": 85 + }, + { + "source": "github:quartz-community/hard-line-breaks", + "enabled": false, + "order": 90 + }, + { + "source": "github:quartz-community/ox-hugo", + "enabled": false, + "order": 91 + }, + { + "source": "github:quartz-community/roam", + "enabled": false, + "order": 92 + }, + { + "source": "github:quartz-community/remove-draft", + "enabled": true + }, + { + "source": "github:quartz-community/explicit-publish", + "enabled": false + }, + { + "source": "github:quartz-community/alias-redirects", + "enabled": true + }, + { + "source": "github:quartz-community/content-index", + "enabled": true, + "options": { "enableSiteMap": true, "enableRSS": true } + }, + { + "source": "github:quartz-community/favicon", + "enabled": true + }, + { + "source": "github:quartz-community/og-image", + "enabled": true + }, + { + "source": "github:quartz-community/cname", + "enabled": true + }, + { + "source": "github:quartz-community/canvas-page", + "enabled": true + }, + { + "source": "github:quartz-community/content-page", + "enabled": true + }, + { + "source": "github:quartz-community/folder-page", + "enabled": true + }, + { + "source": "github:quartz-community/tag-page", + "enabled": true + }, + { + "source": "github:quartz-community/explorer", + "enabled": true, + "layout": { + "position": "left", + "priority": 50 + } + }, + { + "source": "github:quartz-community/graph", + "enabled": true, + "layout": { + "position": "right", + "priority": 10 + } + }, + { + "source": "github:quartz-community/search", + "enabled": true, + "layout": { + "position": "left", + "priority": 20, + "group": "toolbar", + "groupOptions": { "grow": true } + } + }, + { + "source": "github:quartz-community/backlinks", + "enabled": true, + "layout": { + "position": "right", + "priority": 30 + } + }, + { + "source": "github:quartz-community/article-title", + "enabled": true, + "layout": { + "position": "beforeBody", + "priority": 10 + } + }, + { + "source": "github:quartz-community/content-meta", + "enabled": true, + "layout": { + "position": "beforeBody", + "priority": 20 + } + }, + { + "source": "github:quartz-community/tag-list", + "enabled": true, + "layout": { + "position": "beforeBody", + "priority": 30 + } + }, + { + "source": "github:quartz-community/page-title", + "enabled": true, + "layout": { + "position": "left", + "priority": 10 + } + }, + { + "source": "github:quartz-community/darkmode", + "enabled": true, + "layout": { + "position": "left", + "priority": 30, + "group": "toolbar" + } + }, + { + "source": "github:quartz-community/reader-mode", + "enabled": true, + "layout": { + "position": "left", + "priority": 35, + "group": "toolbar" + } + }, + { + "source": "github:quartz-community/spacer", + "enabled": true, + "layout": { + "position": "left", + "priority": 15, + "display": "mobile-only" + } + }, + { + "source": "github:quartz-community/breadcrumbs", + "enabled": true, + "layout": { + "position": "beforeBody", + "priority": 5, + "condition": "not-index" + } + }, + { + "source": "github:quartz-community/comments", + "enabled": false, + "options": { "provider": "giscus", "options": {} }, + "layout": { + "position": "afterBody", + "priority": 10 + } + }, + { + "source": "github:quartz-community/footer", + "enabled": true, + "options": { + "links": { + "GitHub": "https://github.com/jackyzha0/quartz", + "Discord Community": "https://discord.gg/cRFFHYye7t" + } + } + }, + { + "source": "github:quartz-community/recent-notes", + "enabled": false + } + ], + "layout": { + "groups": { + "toolbar": { + "direction": "row", + "gap": "0.5rem" + } + }, + "byPageType": { + "content": {}, + "folder": { + "exclude": ["reader-mode"], + "positions": { + "right": [] + } + }, + "tag": { + "exclude": ["reader-mode"], + "positions": { + "right": [] + } + }, + "404": { + "positions": { + "beforeBody": [], + "left": [], + "right": [] + } + }, + "canvas": {} + } + } +} diff --git a/quartz/bootstrap-cli.mjs b/quartz/bootstrap-cli.mjs index bdf6d0fa1..78d3dc606 100755 --- a/quartz/bootstrap-cli.mjs +++ b/quartz/bootstrap-cli.mjs @@ -8,6 +8,7 @@ import { handleRestore, handleSync, } from "./cli/handlers.js" +import { handleMigrate } from "./cli/migrate-handler.js" import { handlePluginInstall as handleGitPluginInstall, handlePluginAdd, @@ -15,6 +16,10 @@ import { handlePluginUpdate, handlePluginRestore, handlePluginList, + handlePluginEnable, + handlePluginDisable, + handlePluginConfig, + handlePluginCheck, } from "./cli/plugin-git-handlers.js" import { CommonArgv, @@ -51,6 +56,9 @@ yargs(hideBin(process.argv)) .command("build", "Build Quartz into a bundle of static HTML files", BuildArgv, async (argv) => { await handleBuild(argv) }) + .command("migrate", "Migrate old config to quartz.plugins.json", CommonArgv, async () => { + await handleMigrate() + }) .command( "plugin ", "Manage Quartz plugins", @@ -84,6 +92,39 @@ yargs(hideBin(process.argv)) await handlePluginRestore() }, ) + .command( + "enable ", + "Enable plugins in quartz.plugins.json", + CommonArgv, + async (argv) => { + await handlePluginEnable(argv.names) + }, + ) + .command( + "disable ", + "Disable plugins in quartz.plugins.json", + CommonArgv, + async (argv) => { + await handlePluginDisable(argv.names) + }, + ) + .command( + "config ", + "View or set plugin configuration", + { + ...CommonArgv, + set: { + string: true, + describe: "Set a config value (key=value)", + }, + }, + async (argv) => { + await handlePluginConfig(argv.name, { set: argv.set }) + }, + ) + .command("check", "Check for plugin updates", CommonArgv, async () => { + await handlePluginCheck() + }) .demandCommand(1, "Please specify a plugin subcommand") }, async () => { diff --git a/quartz/cli/constants.js b/quartz/cli/constants.js index f4a9ce52b..83c702af9 100644 --- a/quartz/cli/constants.js +++ b/quartz/cli/constants.js @@ -6,7 +6,8 @@ import { readFileSync } from "fs" */ export const ORIGIN_NAME = "origin" export const UPSTREAM_NAME = "upstream" -export const QUARTZ_SOURCE_BRANCH = "v4" +export const QUARTZ_SOURCE_BRANCH = "v5" +export const QUARTZ_SOURCE_REPO = "https://github.com/jackyzha0/quartz.git" export const cwd = process.cwd() export const cacheDir = path.join(cwd, ".quartz-cache") export const cacheFile = "./quartz/.quartz-cache/transpiled-build.mjs" diff --git a/quartz/cli/handlers.js b/quartz/cli/handlers.js index 9b68aede5..c65b816dc 100644 --- a/quartz/cli/handlers.js +++ b/quartz/cli/handlers.js @@ -23,9 +23,11 @@ import { popContentFolder, stashContentFolder, } from "./helpers.js" +import { handlePluginRestore, handlePluginCheck } from "./plugin-git-handlers.js" import { UPSTREAM_NAME, QUARTZ_SOURCE_BRANCH, + QUARTZ_SOURCE_REPO, ORIGIN_NAME, version, fp, @@ -215,14 +217,20 @@ See the [documentation](https://quartz.jzhao.xyz) for how to get started. ) await fs.promises.writeFile(configFilePath, configContent) + const pluginsJsonPath = path.join(cwd, "quartz.plugins.json") + const defaultPluginsJsonPath = path.join(cwd, "quartz.plugins.default.json") + if (!fs.existsSync(pluginsJsonPath) && fs.existsSync(defaultPluginsJsonPath)) { + await fs.promises.copyFile(defaultPluginsJsonPath, pluginsJsonPath) + console.log(styleText("green", "Created quartz.plugins.json from defaults")) + } + // setup remote - execSync( - `git remote show upstream || git remote add upstream https://github.com/jackyzha0/quartz.git`, - { stdio: "ignore" }, - ) + execSync(`git remote show upstream || git remote add upstream ${QUARTZ_SOURCE_REPO}`, { + stdio: "ignore", + }) outro(`You're all set! Not sure what to do next? Try: - • Customizing Quartz a bit more by editing \`quartz.config.ts\` + • Customizing Quartz a bit more by editing \`quartz.plugins.json\` • Running \`npx quartz build --serve\` to preview your Quartz locally • Hosting your Quartz online (see: https://quartz.jzhao.xyz/hosting) `) @@ -494,9 +502,7 @@ export async function handleUpdate(argv) { const contentFolder = resolveContentPath(argv.directory) console.log(`\n${styleText(["bgGreen", "black"], ` Quartz v${version} `)} \n`) console.log("Backing up your content") - execSync( - `git remote show upstream || git remote add upstream https://github.com/jackyzha0/quartz.git`, - ) + execSync(`git remote show upstream || git remote add upstream ${QUARTZ_SOURCE_REPO}`) await stashContentFolder(contentFolder) console.log( "Pulling updates... you may need to resolve some `git` conflicts if you've made changes to components or plugins.", @@ -532,10 +538,18 @@ export async function handleUpdate(argv) { const res = spawnSync("npm", ["i"], opts) if (res.status === 0) { - console.log(styleText("green", "Done!")) + console.log(styleText("green", "Dependencies updated!")) } else { console.log(styleText("red", "An error occurred above while installing dependencies.")) } + + console.log("Restoring plugins from lockfile...") + await handlePluginRestore() + + console.log("Checking plugin compatibility...") + await handlePluginCheck() + + console.log(styleText("green", "Done!")) } /** diff --git a/quartz/cli/helpers.js b/quartz/cli/helpers.js index 46b5018be..18c160b0b 100644 --- a/quartz/cli/helpers.js +++ b/quartz/cli/helpers.js @@ -33,7 +33,7 @@ export async function stashContentFolder(contentFolder) { } export function gitPull(origin, branch) { - const flags = ["--no-rebase", "--autostash", "-s", "recursive", "-X", "ours", "--no-edit"] + const flags = ["--no-rebase", "--autostash", "--no-edit"] const out = spawnSync("git", ["pull", ...flags, origin, branch], { stdio: "inherit" }) if (out.stderr) { throw new Error(styleText("red", `Error while pulling updates: ${out.stderr}`)) diff --git a/quartz/cli/migrate-handler.js b/quartz/cli/migrate-handler.js new file mode 100644 index 000000000..9bc0f217c --- /dev/null +++ b/quartz/cli/migrate-handler.js @@ -0,0 +1,192 @@ +import fs from "fs" +import path from "path" +import { spawnSync } from "child_process" +import { styleText } from "util" + +const CWD = process.cwd() +const CONFIG_PATH = path.join(CWD, "quartz.config.ts") +const LAYOUT_PATH = path.join(CWD, "quartz.layout.ts") +const PLUGINS_JSON_PATH = path.join(CWD, "quartz.plugins.json") +const DEFAULT_PLUGINS_JSON_PATH = path.join(CWD, "quartz.plugins.default.json") +const LOCKFILE_PATH = path.join(CWD, "quartz.lock.json") +const PLUGINS_DIR = path.join(CWD, ".quartz", "plugins") +const PACKAGE_JSON_PATH = path.join(CWD, "package.json") + +function readJson(filePath) { + if (!fs.existsSync(filePath)) return null + try { + return JSON.parse(fs.readFileSync(filePath, "utf-8")) + } catch { + return null + } +} + +function hasTsx() { + const pkg = readJson(PACKAGE_JSON_PATH) + return Boolean(pkg?.devDependencies?.tsx || pkg?.dependencies?.tsx) +} + +function extractWithTsx() { + const script = ` + const { default: config } = await import("./quartz.config.ts") + const { layout } = await import("./quartz.layout.ts") + const result = { + configuration: config?.configuration ?? null, + layoutInfo: { + defaults: { + afterBody: Array.isArray(layout?.defaults?.afterBody) ? layout.defaults.afterBody.length : 0, + hasFooter: Boolean(layout?.defaults?.footer), + }, + pageTypes: layout?.byPageType ? Object.keys(layout.byPageType) : [], + }, + } + console.log(JSON.stringify(result)) + ` + + const res = spawnSync("node", ["--import", "tsx/esm", "--input-type=module", "-e", script], { + encoding: "utf-8", + cwd: CWD, + }) + + if (res.error || res.status !== 0) { + return { ok: false, error: res.error ?? res.stderr } + } + + try { + return { ok: true, data: JSON.parse(res.stdout.trim()) } + } catch (error) { + return { ok: false, error } + } +} + +function readManifest(pluginDir) { + const pkgPath = path.join(pluginDir, "package.json") + if (!fs.existsSync(pkgPath)) return null + try { + const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8")) + return pkg.quartz ?? null + } catch { + return null + } +} + +function ensureLayoutDefaults(layout) { + if (!layout.groups) layout.groups = {} + if (!layout.groups.toolbar) { + layout.groups.toolbar = { direction: "row", gap: "0.5rem" } + } + if (!layout.byPageType) layout.byPageType = {} + if (!layout.byPageType["404"]) { + layout.byPageType["404"] = { positions: { beforeBody: [], left: [], right: [] } } + } else if (!layout.byPageType["404"].positions) { + layout.byPageType["404"].positions = { beforeBody: [], left: [], right: [] } + } + return layout +} + +function buildPluginEntry(name, entry) { + const pluginDir = path.join(PLUGINS_DIR, name) + const manifest = readManifest(pluginDir) + const source = entry?.source ?? `github:quartz-community/${name}` + const pluginEntry = { + source, + enabled: manifest?.defaultEnabled ?? true, + options: manifest?.defaultOptions ?? {}, + order: manifest?.defaultOrder ?? 50, + } + + if (manifest?.components) { + const component = Object.values(manifest.components).find((comp) => comp?.defaultPosition) + if (component?.defaultPosition) { + pluginEntry.layout = { + position: component.defaultPosition, + priority: component.defaultPriority ?? 50, + display: "all", + } + } + } + + return pluginEntry +} + +export async function handleMigrate() { + console.log(styleText("cyan", "Migrating Quartz configuration...")) + + if (!fs.existsSync(CONFIG_PATH)) { + console.log(styleText("red", "✗ quartz.config.ts not found. Aborting migration.")) + return + } + + if (fs.existsSync(PLUGINS_JSON_PATH)) { + console.log(styleText("yellow", "⚠ quartz.plugins.json already exists. Overwriting.")) + } + + const defaultJson = readJson(DEFAULT_PLUGINS_JSON_PATH) + let configuration = defaultJson?.configuration ?? {} + let layout = ensureLayoutDefaults(defaultJson?.layout ?? {}) + let layoutInfo = null + + console.log(styleText("gray", "→ Extracting configuration...")) + if (hasTsx()) { + const extracted = extractWithTsx() + if (extracted.ok) { + configuration = extracted.data?.configuration ?? configuration + layoutInfo = extracted.data?.layoutInfo ?? null + } else { + console.log(styleText("yellow", "⚠ Failed to import TS config with tsx. Using defaults.")) + } + } else { + console.log(styleText("yellow", "⚠ tsx not found. Using defaults.")) + } + + if (layoutInfo?.pageTypes?.length) { + for (const pageType of layoutInfo.pageTypes) { + if (!layout.byPageType[pageType]) { + layout.byPageType[pageType] = {} + } + } + } + + console.log(styleText("gray", "→ Reading plugin lockfile...")) + const lockfile = readJson(LOCKFILE_PATH) + const plugins = [] + + if (lockfile?.plugins) { + for (const [name, entry] of Object.entries(lockfile.plugins)) { + plugins.push(buildPluginEntry(name, entry)) + } + } else if (defaultJson?.plugins) { + console.log(styleText("yellow", "⚠ quartz.lock.json not found. Using default plugins.")) + for (const plugin of defaultJson.plugins) { + plugins.push(plugin) + } + } else { + console.log(styleText("yellow", "⚠ No lockfile or default plugins found. Writing empty list.")) + } + + const outputJson = { + $schema: "./quartz/plugins/quartz-plugins.schema.json", + configuration, + plugins, + layout, + } + + fs.writeFileSync(PLUGINS_JSON_PATH, JSON.stringify(outputJson, null, 2) + "\n") + + const configTemplate = + 'import { loadQuartzConfig } from "./quartz/plugins/loader/config-loader"\n' + + "export default await loadQuartzConfig()\n" + const layoutTemplate = + 'import { loadQuartzLayout } from "./quartz/plugins/loader/config-loader"\n' + + "export const layout = await loadQuartzLayout()\n" + + fs.writeFileSync(CONFIG_PATH, configTemplate) + fs.writeFileSync(LAYOUT_PATH, layoutTemplate) + + console.log(styleText("green", "✓ Created quartz.plugins.json")) + console.log(styleText("green", "✓ Replaced quartz.config.ts")) + console.log(styleText("green", "✓ Replaced quartz.layout.ts")) + console.log() + console.log(styleText("yellow", "⚠ Verify plugin options in quartz.plugins.json")) + console.log(styleText("gray", `Plugins migrated: ${plugins.length}`)) +} diff --git a/quartz/cli/plugin-git-handlers.js b/quartz/cli/plugin-git-handlers.js index a92003bb0..c300f1fd4 100644 --- a/quartz/cli/plugin-git-handlers.js +++ b/quartz/cli/plugin-git-handlers.js @@ -5,8 +5,48 @@ import { styleText } from "util" const LOCKFILE_PATH = path.join(process.cwd(), "quartz.lock.json") const PLUGINS_DIR = path.join(process.cwd(), ".quartz", "plugins") +const PLUGINS_JSON_PATH = path.join(process.cwd(), "quartz.plugins.json") const INTERNAL_EXPORTS = new Set(["manifest", "default"]) +function readPluginsJson() { + if (!fs.existsSync(PLUGINS_JSON_PATH)) return null + try { + return JSON.parse(fs.readFileSync(PLUGINS_JSON_PATH, "utf-8")) + } catch { + return null + } +} + +function writePluginsJson(data) { + fs.writeFileSync(PLUGINS_JSON_PATH, JSON.stringify(data, null, 2) + "\n") +} + +function extractPluginName(source) { + if (source.startsWith("github:")) { + const withoutPrefix = source.replace("github:", "") + const [repoPath] = withoutPrefix.split("#") + const parts = repoPath.split("/") + return parts[parts.length - 1] + } + if (source.startsWith("git+") || source.startsWith("https://")) { + const url = source.replace("git+", "") + const match = url.match(/\/([^/]+?)(?:\.git)?(?:#|$)/) + return match?.[1] ?? source + } + return source +} + +function readManifestFromPackageJson(pluginDir) { + const pkgPath = path.join(pluginDir, "package.json") + if (!fs.existsSync(pkgPath)) return null + try { + const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8")) + return pkg.quartz ?? null + } catch { + return null + } +} + function buildPlugin(pluginDir, name) { try { console.log(styleText("cyan", ` → ${name}: installing dependencies...`)) @@ -255,7 +295,7 @@ export async function handlePluginAdd(sources) { installedAt: new Date().toISOString(), } - addedPlugins.push({ name, pluginDir }) + addedPlugins.push({ name, pluginDir, source }) console.log(styleText("green", `✓ Added ${name}@${commit.slice(0, 7)}`)) } catch (error) { console.log(styleText("red", `✗ Failed to add ${source}: ${error}`)) @@ -274,6 +314,33 @@ export async function handlePluginAdd(sources) { } writeLockfile(lockfile) + const pluginsJson = readPluginsJson() + if (pluginsJson?.plugins) { + for (const { pluginDir, source } of addedPlugins) { + const manifest = readManifestFromPackageJson(pluginDir) + const newEntry = { + source, + enabled: manifest?.defaultEnabled ?? true, + options: manifest?.defaultOptions ?? {}, + order: manifest?.defaultOrder ?? 50, + } + + if (manifest?.components) { + const firstComponentKey = Object.keys(manifest.components)[0] + const comp = manifest.components[firstComponentKey] + if (comp?.defaultPosition) { + newEntry.layout = { + position: comp.defaultPosition, + priority: comp.defaultPriority ?? 50, + display: "all", + } + } + } + + pluginsJson.plugins.push(newEntry) + } + writePluginsJson(pluginsJson) + } console.log() console.log(styleText("gray", "Updated quartz.lock.json")) } @@ -310,10 +377,174 @@ export async function handlePluginRemove(names) { } writeLockfile(lockfile) + const pluginsJson = readPluginsJson() + if (pluginsJson?.plugins) { + pluginsJson.plugins = pluginsJson.plugins.filter( + (plugin) => + !names.includes(extractPluginName(plugin.source)) && !names.includes(plugin.source), + ) + writePluginsJson(pluginsJson) + } console.log() console.log(styleText("gray", "Updated quartz.lock.json")) } +export async function handlePluginEnable(names) { + const json = readPluginsJson() + if (!json) { + console.log(styleText("red", "✗ No quartz.plugins.json found. Cannot enable plugins.")) + return + } + + for (const name of names) { + const entry = json.plugins.find( + (e) => extractPluginName(e.source) === name || e.source === name, + ) + if (!entry) { + console.log(styleText("yellow", `⚠ Plugin "${name}" not found in quartz.plugins.json`)) + continue + } + if (entry.enabled) { + console.log(styleText("gray", `✓ ${name} is already enabled`)) + continue + } + entry.enabled = true + console.log(styleText("green", `✓ Enabled ${name}`)) + } + + writePluginsJson(json) +} + +export async function handlePluginDisable(names) { + const json = readPluginsJson() + if (!json) { + console.log(styleText("red", "✗ No quartz.plugins.json found. Cannot disable plugins.")) + return + } + + for (const name of names) { + const entry = json.plugins.find( + (e) => extractPluginName(e.source) === name || e.source === name, + ) + if (!entry) { + console.log(styleText("yellow", `⚠ Plugin "${name}" not found in quartz.plugins.json`)) + continue + } + if (!entry.enabled) { + console.log(styleText("gray", `✓ ${name} is already disabled`)) + continue + } + entry.enabled = false + console.log(styleText("green", `✓ Disabled ${name}`)) + } + + writePluginsJson(json) +} + +export async function handlePluginConfig(name, options = {}) { + const json = readPluginsJson() + if (!json) { + console.log(styleText("red", "✗ No quartz.plugins.json found.")) + return + } + + const entry = json.plugins.find((e) => extractPluginName(e.source) === name || e.source === name) + if (!entry) { + console.log(styleText("red", `✗ Plugin "${name}" not found in quartz.plugins.json`)) + return + } + + if (options.set) { + const eqIndex = options.set.indexOf("=") + if (eqIndex === -1) { + console.log(styleText("red", "✗ Invalid format. Use: --set key=value")) + return + } + const key = options.set.slice(0, eqIndex) + let value = options.set.slice(eqIndex + 1) + + try { + value = JSON.parse(value) + } catch {} + + if (!entry.options) entry.options = {} + entry.options[key] = value + writePluginsJson(json) + console.log(styleText("green", `✓ Set ${name}.${key} = ${JSON.stringify(value)}`)) + } else { + console.log(styleText("bold", `Plugin: ${name}`)) + console.log(` Source: ${entry.source}`) + console.log(` Enabled: ${entry.enabled}`) + console.log(` Order: ${entry.order ?? 50}`) + if (entry.options && Object.keys(entry.options).length > 0) { + console.log(` Options:`) + for (const [k, v] of Object.entries(entry.options)) { + console.log(` ${k}: ${JSON.stringify(v)}`) + } + } else { + console.log(` Options: (none)`) + } + if (entry.layout) { + console.log(` Layout:`) + for (const [k, v] of Object.entries(entry.layout)) { + console.log(` ${k}: ${JSON.stringify(v)}`) + } + } + } +} + +export async function handlePluginCheck() { + const lockfile = readLockfile() + if (!lockfile || Object.keys(lockfile.plugins).length === 0) { + console.log(styleText("gray", "No plugins installed")) + return + } + + console.log(styleText("bold", "Checking for plugin updates...\n")) + + const results = [] + for (const [name, entry] of Object.entries(lockfile.plugins)) { + try { + const latestCommit = execSync(`git ls-remote ${entry.resolved} HEAD`, { + encoding: "utf-8", + }) + .split("\t")[0] + .trim() + + const isCurrent = latestCommit === entry.commit + results.push({ + name, + installed: entry.commit.slice(0, 7), + latest: latestCommit.slice(0, 7), + status: isCurrent ? "up to date" : "update available", + }) + } catch { + results.push({ + name, + installed: entry.commit.slice(0, 7), + latest: "?", + status: "check failed", + }) + } + } + + const nameWidth = Math.max(6, ...results.map((r) => r.name.length)) + 2 + const header = `${"Plugin".padEnd(nameWidth)}${"Installed".padEnd(12)}${"Latest".padEnd(12)}Status` + console.log(styleText("bold", header)) + console.log("─".repeat(header.length)) + + for (const r of results) { + const color = + r.status === "up to date" ? "green" : r.status === "check failed" ? "red" : "yellow" + console.log( + `${r.name.padEnd(nameWidth)}${r.installed.padEnd(12)}${r.latest.padEnd(12)}${styleText( + color, + r.status, + )}`, + ) + } +} + export async function handlePluginUpdate(names) { const lockfile = readLockfile() if (!lockfile) { diff --git a/quartz/plugins/loader/conditions.ts b/quartz/plugins/loader/conditions.ts new file mode 100644 index 000000000..b53a1e922 --- /dev/null +++ b/quartz/plugins/loader/conditions.ts @@ -0,0 +1,33 @@ +import { QuartzComponentProps } from "../../components/types" + +export type ConditionPredicate = (props: QuartzComponentProps) => boolean + +const builtinConditions: Record = { + "not-index": (props) => props.fileData.slug !== "index", + "has-tags": (props) => { + const tags = props.fileData.frontmatter?.tags + return Array.isArray(tags) && tags.length > 0 + }, + "has-backlinks": (props) => { + const backlinks = (props.fileData as Record).backlinks + return Array.isArray(backlinks) && backlinks.length > 0 + }, + "has-toc": (props) => { + const toc = (props.fileData as Record).toc + return Array.isArray(toc) && toc.length > 0 + }, +} + +const customConditions = new Map() + +export function registerCondition(name: string, predicate: ConditionPredicate): void { + customConditions.set(name, predicate) +} + +export function getCondition(name: string): ConditionPredicate | undefined { + return customConditions.get(name) ?? builtinConditions[name] +} + +export function getAllConditionNames(): string[] { + return [...Object.keys(builtinConditions), ...customConditions.keys()] +} diff --git a/quartz/plugins/loader/config-loader.ts b/quartz/plugins/loader/config-loader.ts new file mode 100644 index 000000000..ce4e7d787 --- /dev/null +++ b/quartz/plugins/loader/config-loader.ts @@ -0,0 +1,680 @@ +import fs from "fs" +import path from "path" +import { styleText } from "util" +import { QuartzConfig, GlobalConfiguration, FullPageLayout } from "../../cfg" +import { QuartzComponent } from "../../components/types" +import { PluginTypes } from "../types" +import { + PluginManifest, + PluginJsonEntry, + QuartzPluginsJson, + LayoutConfig, + PluginLayoutDeclaration, + FlexGroupConfig, + PluginCategory, +} from "./types" +import { parsePluginSource, installPlugin, getPluginEntryPoint } from "./gitLoader" +import { loadComponentsFromPackage } from "./componentLoader" +import { componentRegistry } from "../../components/registry" +import { getCondition } from "./conditions" + +const PLUGINS_JSON_PATH = path.join(process.cwd(), "quartz.plugins.json") + +function readPluginsJson(): QuartzPluginsJson | null { + if (!fs.existsSync(PLUGINS_JSON_PATH)) { + return null + } + const raw = fs.readFileSync(PLUGINS_JSON_PATH, "utf-8") + return JSON.parse(raw) as QuartzPluginsJson +} + +function extractPluginName(source: string): string { + if (source.startsWith("github:")) { + const withoutPrefix = source.replace("github:", "") + const [repoPath] = withoutPrefix.split("#") + const parts = repoPath.split("/") + return parts[parts.length - 1] + } + if (source.startsWith("git+") || source.startsWith("https://")) { + const url = source.replace("git+", "") + const match = url.match(/\/([^/]+?)(?:\.git)?(?:#|$)/) + return match?.[1] ?? source + } + return source +} + +interface DependencyValidationResult { + errors: string[] + warnings: string[] +} + +function validateDependencies( + entries: PluginJsonEntry[], + manifests: Map, +): DependencyValidationResult { + const errors: string[] = [] + const warnings: string[] = [] + + const sourceToEntry = new Map() + const nameToSource = new Map() + for (const entry of entries) { + sourceToEntry.set(entry.source, entry) + nameToSource.set(extractPluginName(entry.source), entry.source) + } + + for (const entry of entries) { + if (!entry.enabled) continue + const manifest = manifests.get(entry.source) + if (!manifest?.dependencies?.length) continue + + const pluginName = manifest.displayName || extractPluginName(entry.source) + const pluginOrder = entry.order ?? manifest.defaultOrder ?? 50 + + for (const dep of manifest.dependencies) { + const depEntry = sourceToEntry.get(dep) + const depName = extractPluginName(dep) + + if (!depEntry) { + errors.push( + `Plugin "${pluginName}" requires "${depName}". Run: npx quartz plugin add ${dep}`, + ) + continue + } + + if (!depEntry.enabled) { + warnings.push( + `Plugin "${pluginName}" depends on "${depName}" which is disabled. "${pluginName}" may not function correctly.`, + ) + } + + const depManifest = manifests.get(dep) + const depOrder = depEntry.order ?? depManifest?.defaultOrder ?? 50 + + if (pluginOrder < depOrder) { + errors.push( + `Plugin "${pluginName}" (order: ${pluginOrder}) depends on "${depName}" (order: ${depOrder}), ` + + `but "${pluginName}" is configured to run first. Either increase "${pluginName}"'s order above ${depOrder} ` + + `or decrease "${depName}"'s order below ${pluginOrder}.`, + ) + } + } + } + + // Circular dependency detection + const graph = new Map() + for (const entry of entries) { + const manifest = manifests.get(entry.source) + if (manifest?.dependencies?.length) { + graph.set(entry.source, manifest.dependencies) + } + } + + const visited = new Set() + const inStack = new Set() + + function detectCycle(node: string, pathSoFar: string[]): string[] | null { + if (inStack.has(node)) { + const cycleStart = pathSoFar.indexOf(node) + return pathSoFar.slice(cycleStart).concat(node) + } + if (visited.has(node)) return null + + visited.add(node) + inStack.add(node) + + for (const dep of graph.get(node) ?? []) { + const cycle = detectCycle(dep, [...pathSoFar, node]) + if (cycle) return cycle + } + + inStack.delete(node) + return null + } + + for (const node of graph.keys()) { + const cycle = detectCycle(node, []) + if (cycle) { + const names = cycle.map(extractPluginName) + errors.push(`Circular dependency detected: ${names.join(" → ")}`) + break + } + } + + return { errors, warnings } +} + +async function resolvePluginManifest(source: string): Promise { + try { + const gitSpec = parsePluginSource(source) + const entryPoint = getPluginEntryPoint(gitSpec.name, gitSpec.subdir) + const module = await import(entryPoint) + return module.manifest ?? null + } catch { + return null + } +} + +async function readManifestFromPackageJson(source: string): Promise { + try { + const gitSpec = parsePluginSource(source) + const pluginDir = path.join(process.cwd(), ".quartz", "plugins", gitSpec.name) + const pkgPath = path.join(pluginDir, "package.json") + if (!fs.existsSync(pkgPath)) return null + + const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8")) + if (!pkg.quartz) return null + + const q = pkg.quartz + return { + name: q.name ?? gitSpec.name, + displayName: q.displayName ?? q.name ?? gitSpec.name, + description: q.description ?? pkg.description ?? "No description", + version: q.version ?? pkg.version ?? "1.0.0", + author: q.author ?? pkg.author, + homepage: q.homepage ?? pkg.homepage, + category: q.category, + quartzVersion: q.quartzVersion, + dependencies: q.dependencies, + defaultOrder: q.defaultOrder, + defaultEnabled: q.defaultEnabled, + defaultOptions: q.defaultOptions, + configSchema: q.configSchema, + components: q.components, + } + } catch { + return null + } +} + +async function getManifest(source: string): Promise { + // Try package.json quartz field first (preferred), then fall back to manifest.ts export + return (await readManifestFromPackageJson(source)) ?? (await resolvePluginManifest(source)) +} + +export async function loadQuartzConfig(): Promise { + const json = readPluginsJson() + + if (!json) { + // Fallback: import old-style config directly + const oldConfig = await import("../../../quartz.config") + return oldConfig.default + } + + const configuration = json.configuration as unknown as GlobalConfiguration + + const enabledEntries = json.plugins.filter((e) => e.enabled) + const manifests = new Map() + + // Ensure all plugins are installed and collect manifests + for (const entry of enabledEntries) { + try { + const gitSpec = parsePluginSource(entry.source) + await installPlugin(gitSpec, { verbose: false }) + + const manifest = await getManifest(entry.source) + if (manifest) { + manifests.set(entry.source, manifest) + } + } catch (err) { + console.error( + styleText("red", `✗`) + + ` Failed to install plugin: ${styleText("yellow", entry.source)}\n` + + ` ${err instanceof Error ? err.message : String(err)}`, + ) + } + } + + // Validate dependencies + const validation = validateDependencies(enabledEntries, manifests) + for (const warning of validation.warnings) { + console.warn(styleText("yellow", `⚠`) + ` ${warning}`) + } + if (validation.errors.length > 0) { + for (const error of validation.errors) { + console.error(styleText("red", `✗`) + ` ${error}`) + } + throw new Error( + `Plugin dependency validation failed with ${validation.errors.length} error(s). See above for details.`, + ) + } + + // Categorize and sort plugins + const transformers: { entry: PluginJsonEntry; manifest: PluginManifest | undefined }[] = [] + const filters: { entry: PluginJsonEntry; manifest: PluginManifest | undefined }[] = [] + const emitters: { entry: PluginJsonEntry; manifest: PluginManifest | undefined }[] = [] + const pageTypes: { entry: PluginJsonEntry; manifest: PluginManifest | undefined }[] = [] + + for (const entry of enabledEntries) { + const manifest = manifests.get(entry.source) + const category = manifest?.category + + switch (category) { + case "transformer": + transformers.push({ entry, manifest }) + break + case "filter": + filters.push({ entry, manifest }) + break + case "emitter": + emitters.push({ entry, manifest }) + break + case "pageType": + pageTypes.push({ entry, manifest }) + break + default: { + // Try to detect category from the loaded module + const gitSpec = parsePluginSource(entry.source) + const entryPoint = getPluginEntryPoint(gitSpec.name, gitSpec.subdir) + try { + const module = await import(entryPoint) + const detected = detectCategoryFromModule(module) + if (detected) { + const target = { + transformer: transformers, + filter: filters, + emitter: emitters, + pageType: pageTypes, + }[detected] + target.push({ entry, manifest }) + } else { + console.warn( + styleText("yellow", `⚠`) + + ` Could not determine category for plugin "${extractPluginName(entry.source)}". Skipping.`, + ) + } + } catch { + console.warn( + styleText("yellow", `⚠`) + + ` Could not load plugin "${extractPluginName(entry.source)}" to detect category. Skipping.`, + ) + } + } + } + } + + // Sort by order within each category + const sortByOrder = ( + a: { entry: PluginJsonEntry; manifest: PluginManifest | undefined }, + b: { entry: PluginJsonEntry; manifest: PluginManifest | undefined }, + ) => { + const orderA = a.entry.order ?? a.manifest?.defaultOrder ?? 50 + const orderB = b.entry.order ?? b.manifest?.defaultOrder ?? 50 + return orderA - orderB + } + + transformers.sort(sortByOrder) + filters.sort(sortByOrder) + emitters.sort(sortByOrder) + pageTypes.sort(sortByOrder) + + // Instantiate plugins + const instantiate = async ( + items: { entry: PluginJsonEntry; manifest: PluginManifest | undefined }[], + ) => { + const instances = [] + for (const { entry, manifest } of items) { + try { + const gitSpec = parsePluginSource(entry.source) + const entryPoint = getPluginEntryPoint(gitSpec.name, gitSpec.subdir) + const module = await import(entryPoint) + + // Load components if declared + if (manifest?.components && Object.keys(manifest.components).length > 0) { + await loadComponentsFromPackage(entryPoint, manifest) + } + + const factory = module.default ?? module.plugin + if (typeof factory !== "function") { + console.warn( + styleText("yellow", `⚠`) + + ` Plugin "${extractPluginName(entry.source)}" has no factory function. Skipping.`, + ) + continue + } + + // Merge default options with user options + const options = { ...manifest?.defaultOptions, ...entry.options } + instances.push(factory(Object.keys(options).length > 0 ? options : undefined)) + } catch (err) { + console.error( + styleText("red", `✗`) + + ` Failed to instantiate plugin "${extractPluginName(entry.source)}": ${err instanceof Error ? err.message : String(err)}`, + ) + } + } + return instances + } + + // Import built-in plugins + const builtinPlugins = await import("../index") + const builtinTransformers = [builtinPlugins.FrontMatter()] + const builtinEmitters = [ + builtinPlugins.ComponentResources(), + builtinPlugins.Assets(), + builtinPlugins.Static(), + ] + const builtinPageTypes = [builtinPlugins.PageTypes.NotFoundPageType()] + + const plugins: PluginTypes = { + transformers: [...builtinTransformers, ...(await instantiate(transformers))], + filters: await instantiate(filters), + emitters: [...builtinEmitters, ...(await instantiate(emitters))], + pageTypes: [...(await instantiate(pageTypes)), ...builtinPageTypes], + } + + return { + configuration, + plugins, + } +} + +function detectCategoryFromModule(module: unknown): PluginCategory | null { + if (!module || typeof module !== "object") return null + const mod = module as Record + + if (typeof mod.default === "function") { + // Try to instantiate and inspect + try { + const instance = (mod.default as Function)() + if (instance && typeof instance === "object") { + if ("match" in instance && "body" in instance && "layout" in instance) return "pageType" + if ("emit" in instance) return "emitter" + if ("shouldPublish" in instance) return "filter" + if ( + "textTransform" in instance || + "markdownPlugins" in instance || + "htmlPlugins" in instance + ) + return "transformer" + } + } catch { + // Couldn't instantiate, skip detection + } + } + + return null +} + +export async function loadQuartzLayout(): Promise<{ + defaults: Partial + byPageType: Record> +}> { + const json = readPluginsJson() + + if (!json) { + // Fallback: import old-style layout directly + const oldLayout = await import("../../../quartz.layout") + return oldLayout.layout + } + + const enabledWithLayout = json.plugins.filter((e) => e.enabled && e.layout) + const layoutConfig = json.layout ?? {} + + // Build default layout for all page types + const defaultLayout = buildLayoutForEntries(enabledWithLayout, layoutConfig) + + // Build per-page-type overrides + const byPageType: Record> = {} + if (layoutConfig.byPageType) { + for (const [pageType, override] of Object.entries(layoutConfig.byPageType)) { + let filteredEntries = enabledWithLayout + + // Apply exclusions + if (override.exclude?.length) { + filteredEntries = filteredEntries.filter((e) => { + const name = extractPluginName(e.source) + return !override.exclude!.includes(name) + }) + } + + const ptLayout = buildLayoutForEntries(filteredEntries, layoutConfig) + + // Apply position overrides (empty array = clear position) + if (override.positions) { + for (const [pos, components] of Object.entries(override.positions)) { + if (Array.isArray(components) && components.length === 0) { + const key = pos as keyof Pick< + FullPageLayout, + "left" | "right" | "beforeBody" | "afterBody" + > + if (key in ptLayout) { + ;(ptLayout as Record)[key] = [] + } + } + } + } + + byPageType[pageType] = ptLayout + } + } + + // Add Head (built-in) and Footer (plugin) + const HeadModule = await import("../../components/Head") + const head = HeadModule.default() + + // Find footer plugin + const footerEntry = json.plugins.find( + (e) => e.enabled && extractPluginName(e.source) === "footer", + ) + let footer: QuartzComponent | undefined + if (footerEntry) { + try { + const gitSpec = parsePluginSource(footerEntry.source) + const entryPoint = getPluginEntryPoint(gitSpec.name, gitSpec.subdir) + const module = await import(entryPoint) + const factory = module.default ?? module.plugin + if (typeof factory === "function") { + const options = { ...footerEntry.options } + footer = factory(Object.keys(options).length > 0 ? options : undefined) + } + } catch { + // Footer not available + } + } + + // Apply structural defaults + defaultLayout.head = head + defaultLayout.header = defaultLayout.header ?? [] + if (footer) { + defaultLayout.footer = footer + } + + // Ensure all byPageType entries inherit structural slots + for (const pageType of Object.keys(byPageType)) { + const pt = byPageType[pageType] + if (!pt.head) pt.head = head + if (!pt.header) pt.header = [] + if (footer && !pt.footer) pt.footer = footer + } + + return { defaults: defaultLayout, byPageType } +} + +function buildLayoutForEntries( + entries: PluginJsonEntry[], + layoutConfig: LayoutConfig, +): Partial { + const positions: Record< + string, + { + component: QuartzComponent + priority: number + group?: string + groupOptions?: PluginLayoutDeclaration["groupOptions"] + }[] + > = { + left: [], + right: [], + beforeBody: [], + afterBody: [], + } + + for (const entry of entries) { + if (!entry.layout) continue + + const layout = entry.layout + const name = extractPluginName(entry.source) + + // Look up component from registry + const registered = + componentRegistry.get(name) ?? componentRegistry.get(`${entry.source}/${name}`) + if (!registered) { + // Try common naming patterns + const pascalName = name + .split("-") + .map((s) => s.charAt(0).toUpperCase() + s.slice(1)) + .join("") + const altRegistered = componentRegistry.get(pascalName) + if (!altRegistered) continue + } + + const reg = + registered ?? + componentRegistry.get( + name + .split("-") + .map((s) => s.charAt(0).toUpperCase() + s.slice(1)) + .join(""), + ) + if (!reg) continue + + let component: QuartzComponent + if (typeof reg.component === "function" && !("displayName" in reg.component)) { + // It's a constructor, instantiate with options + const opts = { ...entry.options } + component = (reg.component as Function)( + Object.keys(opts).length > 0 ? opts : undefined, + ) as QuartzComponent + } else { + component = reg.component as QuartzComponent + } + + // Apply display modifier + if (layout.display && layout.display !== "all") { + component = applyDisplayWrapper(component, layout.display) + } + + // Apply condition + if (layout.condition) { + component = applyConditionWrapper(component, layout.condition) + } + + const posArray = positions[layout.position] + if (posArray) { + posArray.push({ + component, + priority: layout.priority, + group: layout.group, + groupOptions: layout.groupOptions, + }) + } + } + + // Sort by priority and resolve groups + const result: Partial = {} + + for (const [position, items] of Object.entries(positions)) { + items.sort((a, b) => a.priority - b.priority) + + const resolved = resolveGroups(items, layoutConfig.groups ?? {}) + const key = position as keyof Pick< + FullPageLayout, + "left" | "right" | "beforeBody" | "afterBody" + > + ;(result as Record)[key] = resolved + } + + return result +} + +function resolveGroups( + items: { + component: QuartzComponent + priority: number + group?: string + groupOptions?: PluginLayoutDeclaration["groupOptions"] + }[], + groups: Record, +): QuartzComponent[] { + const result: QuartzComponent[] = [] + const groupedComponents = new Map< + string, + { component: QuartzComponent; groupOptions?: PluginLayoutDeclaration["groupOptions"] }[] + >() + const groupInsertionOrder: { name: string; priority: number }[] = [] + + for (const item of items) { + if (item.group) { + if (!groupedComponents.has(item.group)) { + groupedComponents.set(item.group, []) + groupInsertionOrder.push({ name: item.group, priority: item.priority }) + } + groupedComponents.get(item.group)!.push({ + component: item.component, + groupOptions: item.groupOptions, + }) + } else { + result.push(item.component) + } + } + + // Insert flex groups at the position of their first member + for (const { name: groupName } of groupInsertionOrder) { + const members = groupedComponents.get(groupName)! + const groupConfig = groups[groupName] ?? {} + + const flexComponents = members.map((m) => ({ + Component: m.component, + grow: m.groupOptions?.grow, + shrink: m.groupOptions?.shrink, + basis: m.groupOptions?.basis, + order: m.groupOptions?.order, + align: m.groupOptions?.align, + justify: m.groupOptions?.justify, + })) + + // Dynamically import Flex to avoid circular dependencies + const FlexModule = require("../../components/Flex") + const Flex = FlexModule.default as Function + const flexComponent = Flex({ + components: flexComponents, + direction: groupConfig.direction ?? "row", + wrap: groupConfig.wrap, + gap: groupConfig.gap ?? "1rem", + }) as QuartzComponent + + result.push(flexComponent) + } + + return result +} + +function applyDisplayWrapper( + component: QuartzComponent, + display: "mobile-only" | "desktop-only", +): QuartzComponent { + if (display === "mobile-only") { + const MobileOnly = require("../../components/MobileOnly").default as Function + return MobileOnly(component) as QuartzComponent + } else { + const DesktopOnly = require("../../components/DesktopOnly").default as Function + return DesktopOnly(component) as QuartzComponent + } +} + +function applyConditionWrapper(component: QuartzComponent, conditionName: string): QuartzComponent { + const predicate = getCondition(conditionName) + if (!predicate) { + console.warn( + styleText("yellow", `⚠`) + + ` Unknown condition "${conditionName}". Component will always render.`, + ) + return component + } + + const ConditionalRender = require("../../components/ConditionalRender").default as Function + return ConditionalRender({ + component, + condition: predicate, + }) as QuartzComponent +} diff --git a/quartz/plugins/loader/loader.ts b/quartz/plugins/loader/loader.ts index 205bcf31c..cc4c8e921 100644 --- a/quartz/plugins/loader/loader.ts +++ b/quartz/plugins/loader/loader.ts @@ -1,2 +1,4 @@ export * from "./types" export * from "./index" +export * from "./conditions" +export { loadQuartzConfig, loadQuartzLayout } from "./config-loader" diff --git a/quartz/plugins/loader/types.ts b/quartz/plugins/loader/types.ts index 50491a3ae..74af1c14b 100644 --- a/quartz/plugins/loader/types.ts +++ b/quartz/plugins/loader/types.ts @@ -6,6 +6,12 @@ import { } from "../types" import { BuildCtx } from "../../util/ctx" +export type PluginCategory = "transformer" | "filter" | "emitter" | "pageType" + +export type LayoutPosition = "left" | "right" | "beforeBody" | "afterBody" + +export type LayoutDisplay = "all" | "mobile-only" | "desktop-only" + /** * Component manifest metadata */ @@ -20,7 +26,20 @@ export interface ComponentManifest { } /** - * Plugin manifest metadata for discovery and documentation + * Layout defaults for a component declared in a plugin manifest. + * These are used as fallback values when no user layout config is specified. + */ +export interface ComponentLayoutDefaults { + displayName: string + description?: string + defaultPosition?: LayoutPosition + defaultPriority?: number +} + +/** + * Plugin manifest metadata for discovery and documentation. + * + * This corresponds to the `quartz` field in a plugin's `package.json`. */ export interface PluginManifest { name: string @@ -30,11 +49,20 @@ export interface PluginManifest { author?: string homepage?: string keywords?: string[] - category?: "transformer" | "filter" | "emitter" | "pageType" + category?: PluginCategory quartzVersion?: string + /** Plugin sources this plugin depends on (e.g., "github:quartz-community/crawl-links") */ + dependencies?: string[] + /** Default numeric execution order (0-100 convention, lower = runs first). Defaults to 50. */ + defaultOrder?: number + /** Whether the plugin is enabled by default on install. Defaults to true. */ + defaultEnabled?: boolean + /** Default options applied when no user options are specified */ + defaultOptions?: Record + /** JSON Schema for the plugin's options object, used for validation and TUI generation */ configSchema?: object - /** Components provided by this plugin */ - components?: Record + /** Components provided by this plugin, keyed by component export name */ + components?: Record } /** @@ -43,7 +71,7 @@ export interface PluginManifest { export interface LoadedPlugin { plugin: QuartzTransformerPlugin | QuartzFilterPlugin | QuartzEmitterPlugin | QuartzPageTypePlugin manifest: PluginManifest - type: "transformer" | "filter" | "emitter" | "pageType" + type: PluginCategory source: string } @@ -91,3 +119,56 @@ export type PluginSpecifier = | string | { name: string; options?: unknown } | { plugin: LoadedPlugin["plugin"]; manifest?: Partial } + +/** Layout declaration for a component-providing plugin in quartz.plugins.json */ +export interface PluginLayoutDeclaration { + position: LayoutPosition + priority: number + display?: LayoutDisplay + condition?: string + group?: string + groupOptions?: { + grow?: boolean + shrink?: boolean + basis?: string + order?: number + align?: "start" | "end" | "center" | "stretch" + justify?: "start" | "end" | "center" | "between" | "around" + } +} + +/** A single plugin entry in quartz.plugins.json */ +export interface PluginJsonEntry { + source: string + enabled: boolean + options?: Record + order?: number + layout?: PluginLayoutDeclaration +} + +/** Flex group configuration in the top-level layout section */ +export interface FlexGroupConfig { + direction?: "row" | "row-reverse" | "column" | "column-reverse" + wrap?: "nowrap" | "wrap" | "wrap-reverse" + gap?: string +} + +/** Per-page-type layout overrides */ +export interface PageTypeLayoutOverride { + exclude?: string[] + positions?: Partial> +} + +/** Top-level layout section of quartz.plugins.json */ +export interface LayoutConfig { + groups?: Record + byPageType?: Record +} + +/** Root type for quartz.plugins.json */ +export interface QuartzPluginsJson { + $schema?: string + configuration: Record + plugins: PluginJsonEntry[] + layout?: LayoutConfig +} diff --git a/quartz/plugins/quartz-plugins.schema.json b/quartz/plugins/quartz-plugins.schema.json new file mode 100644 index 000000000..392e55eba --- /dev/null +++ b/quartz/plugins/quartz-plugins.schema.json @@ -0,0 +1,321 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Quartz Plugins Configuration Schema", + "description": "Schema for validating quartz.plugins.json configuration files", + "type": "object", + "required": ["configuration", "plugins"], + "additionalProperties": false, + "properties": { + "$schema": { + "type": "string", + "description": "JSON Schema reference" + }, + "configuration": { + "type": "object", + "required": ["pageTitle", "enableSPA", "locale", "theme"], + "additionalProperties": false, + "properties": { + "pageTitle": { + "type": "string", + "description": "The title of the website" + }, + "pageTitleSuffix": { + "type": "string", + "description": "Suffix appended to page titles" + }, + "enableSPA": { + "type": "boolean", + "description": "Enable single-page application mode" + }, + "enablePopovers": { + "type": "boolean", + "description": "Enable hover popovers for links" + }, + "locale": { + "type": "string", + "description": "Locale code for the site" + }, + "baseUrl": { + "type": "string", + "description": "Base URL for the site" + }, + "theme": { + "type": "object", + "required": ["fontOrigin", "cdnCaching", "typography", "colors"], + "additionalProperties": false, + "properties": { + "fontOrigin": { + "type": "string", + "enum": ["googleFonts", "local"], + "description": "Source of fonts" + }, + "cdnCaching": { + "type": "boolean", + "description": "Enable CDN caching" + }, + "typography": { + "type": "object", + "additionalProperties": false, + "properties": { + "header": { + "type": "string", + "description": "Font family for headers" + }, + "body": { + "type": "string", + "description": "Font family for body text" + }, + "code": { + "type": "string", + "description": "Font family for code" + } + } + }, + "colors": { + "type": "object", + "required": ["lightMode", "darkMode"], + "additionalProperties": false, + "properties": { + "lightMode": { + "type": "object", + "additionalProperties": false, + "properties": { + "light": { + "type": "string", + "description": "Light color" + }, + "lightgray": { + "type": "string", + "description": "Light gray color" + }, + "gray": { + "type": "string", + "description": "Gray color" + }, + "darkgray": { + "type": "string", + "description": "Dark gray color" + }, + "dark": { + "type": "string", + "description": "Dark color" + }, + "secondary": { + "type": "string", + "description": "Secondary color" + }, + "tertiary": { + "type": "string", + "description": "Tertiary color" + }, + "highlight": { + "type": "string", + "description": "Highlight color" + }, + "textHighlight": { + "type": "string", + "description": "Text highlight color" + } + } + }, + "darkMode": { + "type": "object", + "additionalProperties": false, + "properties": { + "light": { + "type": "string", + "description": "Light color" + }, + "lightgray": { + "type": "string", + "description": "Light gray color" + }, + "gray": { + "type": "string", + "description": "Gray color" + }, + "darkgray": { + "type": "string", + "description": "Dark gray color" + }, + "dark": { + "type": "string", + "description": "Dark color" + }, + "secondary": { + "type": "string", + "description": "Secondary color" + }, + "tertiary": { + "type": "string", + "description": "Tertiary color" + }, + "highlight": { + "type": "string", + "description": "Highlight color" + }, + "textHighlight": { + "type": "string", + "description": "Text highlight color" + } + } + } + } + } + } + }, + "analytics": { + "type": "object", + "description": "Analytics configuration" + }, + "ignorePatterns": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Patterns to ignore during processing" + }, + "defaultDateType": { + "type": "string", + "enum": ["created", "modified", "published"], + "description": "Default date type for pages" + } + } + }, + "plugins": { + "type": "array", + "description": "Array of plugin configurations", + "items": { + "type": "object", + "required": ["source", "enabled"], + "additionalProperties": false, + "properties": { + "source": { + "type": "string", + "description": "Plugin source path or identifier" + }, + "enabled": { + "type": "boolean", + "description": "Whether the plugin is enabled" + }, + "order": { + "type": "number", + "minimum": 0, + "description": "Plugin execution order" + }, + "options": { + "type": "object", + "additionalProperties": true, + "description": "Plugin-specific options" + }, + "layout": { + "type": "object", + "additionalProperties": false, + "properties": { + "position": { + "type": "string", + "enum": ["left", "right", "beforeBody", "afterBody"], + "description": "Layout position" + }, + "priority": { + "type": "number", + "description": "Layout priority" + }, + "display": { + "type": "string", + "enum": ["all", "mobile-only", "desktop-only"], + "description": "Display mode" + }, + "condition": { + "type": "string", + "description": "Conditional display logic" + }, + "group": { + "type": "string", + "description": "Layout group name" + }, + "groupOptions": { + "type": "object", + "additionalProperties": false, + "properties": { + "grow": { + "type": "boolean", + "description": "Flex grow" + }, + "shrink": { + "type": "boolean", + "description": "Flex shrink" + }, + "basis": { + "type": "string", + "description": "Flex basis" + }, + "order": { + "type": "number", + "description": "Flex order" + }, + "align": { + "type": "string", + "description": "Alignment" + }, + "justify": { + "type": "string", + "description": "Justification" + } + } + } + } + } + } + } + }, + "layout": { + "type": "object", + "additionalProperties": false, + "properties": { + "groups": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "direction": { + "type": "string", + "description": "Flex direction" + }, + "wrap": { + "type": "boolean", + "description": "Flex wrap" + }, + "gap": { + "type": "string", + "description": "Gap between items" + } + } + }, + "description": "Layout groups configuration" + }, + "byPageType": { + "type": "object", + "additionalProperties": { + "type": "object", + "additionalProperties": false, + "properties": { + "exclude": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Excluded plugins for this page type" + }, + "positions": { + "type": "object", + "description": "Position overrides for this page type" + } + } + }, + "description": "Layout configuration by page type" + } + } + } + } +}