mirror of
https://github.com/jackyzha0/quartz.git
synced 2026-03-21 21:45:42 -05:00
feat: extract transformers to community plugins and fix type compatibility
- Delete 12 internal transformer files (keep FrontMatter as internal) - Switch quartz.config.ts to use ExternalPlugin.* for all transformers - Align branded types with @quartz-community/types (_brand, FullSlug etc.) - Add vfile DataMap augmentations for fields from extracted transformers - Update all 29 plugins to @quartz-community/types v0.2.1
This commit is contained in:
parent
737c06d6d2
commit
074951afea
20
package-lock.json
generated
20
package-lock.json
generated
@ -1916,10 +1916,16 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@quartz-community/types": {
|
"node_modules/@quartz-community/types": {
|
||||||
"version": "0.1.0",
|
"version": "0.2.1",
|
||||||
"resolved": "git+ssh://git@github.com/quartz-community/types.git#39fac344ea3909933c9d3f3d388e43765fb5e32c",
|
"resolved": "git+ssh://git@github.com/quartz-community/types.git#307f7393d96e8514c042307e7fbfb47ae7a2b330",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/hast": "^3.0.4",
|
||||||
|
"@types/mdast": "^4.0.4",
|
||||||
|
"unified": "^11.0.5",
|
||||||
|
"vfile": "^6.0.3"
|
||||||
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=22",
|
"node": ">=22",
|
||||||
"npm": ">=10.9.2"
|
"npm": ">=10.9.2"
|
||||||
@ -1927,9 +1933,13 @@
|
|||||||
},
|
},
|
||||||
"node_modules/@quartz-community/utils": {
|
"node_modules/@quartz-community/utils": {
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"resolved": "git+ssh://git@github.com/quartz-community/utils.git#61970ce89f1019a56a95e9a9d7d414de7d6d1ebd",
|
"resolved": "git+ssh://git@github.com/quartz-community/utils.git#15d75b89e188e937a8de6b8f8a03c328cfd5c830",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@quartz-community/types": "github:quartz-community/types",
|
||||||
|
"github-slugger": "^2.0.0"
|
||||||
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=22",
|
"node": ">=22",
|
||||||
"npm": ">=10.9.2"
|
"npm": ">=10.9.2"
|
||||||
@ -2292,7 +2302,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/mdast": {
|
"node_modules/@types/mdast": {
|
||||||
"version": "4.0.3",
|
"version": "4.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz",
|
||||||
|
"integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/unist": "*"
|
"@types/unist": "*"
|
||||||
|
|||||||
@ -53,22 +53,22 @@ const config: QuartzConfig = {
|
|||||||
plugins: {
|
plugins: {
|
||||||
transformers: [
|
transformers: [
|
||||||
Plugin.FrontMatter(),
|
Plugin.FrontMatter(),
|
||||||
Plugin.CreatedModifiedDate({
|
ExternalPlugin.CreatedModifiedDate({
|
||||||
priority: ["frontmatter", "git", "filesystem"],
|
priority: ["frontmatter", "git", "filesystem"],
|
||||||
}),
|
}),
|
||||||
Plugin.SyntaxHighlighting({
|
ExternalPlugin.SyntaxHighlighting({
|
||||||
theme: {
|
theme: {
|
||||||
light: "github-light",
|
light: "github-light",
|
||||||
dark: "github-dark",
|
dark: "github-dark",
|
||||||
},
|
},
|
||||||
keepBackground: false,
|
keepBackground: false,
|
||||||
}),
|
}),
|
||||||
Plugin.ObsidianFlavoredMarkdown({ enableInHtmlEmbed: false }),
|
ExternalPlugin.ObsidianFlavoredMarkdown({ enableInHtmlEmbed: false }),
|
||||||
Plugin.GitHubFlavoredMarkdown(),
|
ExternalPlugin.GitHubFlavoredMarkdown(),
|
||||||
Plugin.TableOfContents(),
|
ExternalPlugin.TableOfContentsTransformer(),
|
||||||
Plugin.CrawlLinks({ markdownLinkResolution: "shortest" }),
|
ExternalPlugin.CrawlLinks({ markdownLinkResolution: "shortest" }),
|
||||||
Plugin.Description(),
|
ExternalPlugin.Description(),
|
||||||
Plugin.Latex({ renderEngine: "katex" }),
|
ExternalPlugin.Latex({ renderEngine: "katex" }),
|
||||||
],
|
],
|
||||||
filters: [Plugin.RemoveDrafts()],
|
filters: [Plugin.RemoveDrafts()],
|
||||||
emitters: [
|
emitters: [
|
||||||
@ -111,6 +111,17 @@ const config: QuartzConfig = {
|
|||||||
"github:quartz-community/content-page",
|
"github:quartz-community/content-page",
|
||||||
"github:quartz-community/folder-page",
|
"github:quartz-community/folder-page",
|
||||||
"github:quartz-community/tag-page",
|
"github:quartz-community/tag-page",
|
||||||
|
"github:quartz-community/latex",
|
||||||
|
"github:quartz-community/created-modified-date",
|
||||||
|
"github:quartz-community/syntax-highlighting",
|
||||||
|
"github:quartz-community/obsidian-flavored-markdown",
|
||||||
|
"github:quartz-community/github-flavored-markdown",
|
||||||
|
"github:quartz-community/crawl-links",
|
||||||
|
"github:quartz-community/description",
|
||||||
|
"github:quartz-community/hard-line-breaks",
|
||||||
|
"github:quartz-community/citations",
|
||||||
|
"github:quartz-community/ox-hugo",
|
||||||
|
"github:quartz-community/roam",
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
136
quartz.lock.json
136
quartz.lock.json
@ -4,116 +4,176 @@
|
|||||||
"explorer": {
|
"explorer": {
|
||||||
"source": "github:quartz-community/explorer",
|
"source": "github:quartz-community/explorer",
|
||||||
"resolved": "https://github.com/quartz-community/explorer.git",
|
"resolved": "https://github.com/quartz-community/explorer.git",
|
||||||
"commit": "9ba62f0f124d6ffd7d6bede838f8d70c2a3e85da",
|
"commit": "a7512a334f2c3842089c3f48d00ab645f818bb55",
|
||||||
"installedAt": "2026-02-13T19:48:10.583Z"
|
"installedAt": "2026-02-13T22:03:10.551Z"
|
||||||
},
|
},
|
||||||
"graph": {
|
"graph": {
|
||||||
"source": "github:quartz-community/graph",
|
"source": "github:quartz-community/graph",
|
||||||
"resolved": "https://github.com/quartz-community/graph.git",
|
"resolved": "https://github.com/quartz-community/graph.git",
|
||||||
"commit": "26515026b7f2a01029d68ee2cbd8ebdbe35fdd4c",
|
"commit": "b7d0660d2ff72266a07f6ed92aabfe6848feffca",
|
||||||
"installedAt": "2026-02-13T15:36:12.191Z"
|
"installedAt": "2026-02-13T22:03:11.177Z"
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
"source": "github:quartz-community/search",
|
"source": "github:quartz-community/search",
|
||||||
"resolved": "https://github.com/quartz-community/search.git",
|
"resolved": "https://github.com/quartz-community/search.git",
|
||||||
"commit": "af45aea98cc3e343627f8cf38835b917cc522c08",
|
"commit": "ba87563aa638fb710fe1684d2f9af712bbd149b7",
|
||||||
"installedAt": "2026-02-13T15:36:12.611Z"
|
"installedAt": "2026-02-13T22:03:11.789Z"
|
||||||
},
|
},
|
||||||
"backlinks": {
|
"backlinks": {
|
||||||
"source": "github:quartz-community/backlinks",
|
"source": "github:quartz-community/backlinks",
|
||||||
"resolved": "https://github.com/quartz-community/backlinks.git",
|
"resolved": "https://github.com/quartz-community/backlinks.git",
|
||||||
"commit": "e835cd3f573f647fe126ef6f1915d531e061c43e",
|
"commit": "291567d821c8643a7c27adbdf3cf6726a2abd62f",
|
||||||
"installedAt": "2026-02-13T15:36:13.044Z"
|
"installedAt": "2026-02-13T22:03:12.341Z"
|
||||||
},
|
},
|
||||||
"table-of-contents": {
|
"table-of-contents": {
|
||||||
"source": "github:quartz-community/table-of-contents",
|
"source": "github:quartz-community/table-of-contents",
|
||||||
"resolved": "https://github.com/quartz-community/table-of-contents.git",
|
"resolved": "https://github.com/quartz-community/table-of-contents.git",
|
||||||
"commit": "819b893f53088d5165ea81ba6fe9d5e5cbb55807",
|
"commit": "687517e7de18b4ad6b064a3b6cb450a44aa5ed2d",
|
||||||
"installedAt": "2026-02-13T15:36:13.526Z"
|
"installedAt": "2026-02-13T22:03:12.950Z"
|
||||||
},
|
},
|
||||||
"comments": {
|
"comments": {
|
||||||
"source": "github:quartz-community/comments",
|
"source": "github:quartz-community/comments",
|
||||||
"resolved": "https://github.com/quartz-community/comments.git",
|
"resolved": "https://github.com/quartz-community/comments.git",
|
||||||
"commit": "5e0e6eab927fd5eac1c5b61ab22fb0ff2e05905d",
|
"commit": "aaffdbdb5de08ccc89aed62a4e583a7241b9eca7",
|
||||||
"installedAt": "2026-02-13T15:36:13.961Z"
|
"installedAt": "2026-02-13T22:03:13.562Z"
|
||||||
},
|
},
|
||||||
"breadcrumbs": {
|
"breadcrumbs": {
|
||||||
"source": "github:quartz-community/breadcrumbs",
|
"source": "github:quartz-community/breadcrumbs",
|
||||||
"resolved": "https://github.com/quartz-community/breadcrumbs.git",
|
"resolved": "https://github.com/quartz-community/breadcrumbs.git",
|
||||||
"commit": "27e033fff2f4a8b5188ab737af743b22eca9d4d0",
|
"commit": "38f38fd80eedb47ff1e245aae419d5f3fcd4d864",
|
||||||
"installedAt": "2026-02-13T15:36:14.541Z"
|
"installedAt": "2026-02-13T22:03:14.179Z"
|
||||||
},
|
},
|
||||||
"recent-notes": {
|
"recent-notes": {
|
||||||
"source": "github:quartz-community/recent-notes",
|
"source": "github:quartz-community/recent-notes",
|
||||||
"resolved": "https://github.com/quartz-community/recent-notes.git",
|
"resolved": "https://github.com/quartz-community/recent-notes.git",
|
||||||
"commit": "57d44711d8158ee542dd9a5086535b1f20d6e042",
|
"commit": "b85002146e91787cf61ff4e29e56f32bb0ac3ac4",
|
||||||
"installedAt": "2026-02-13T15:36:14.992Z"
|
"installedAt": "2026-02-13T22:03:14.800Z"
|
||||||
},
|
},
|
||||||
"latex": {
|
"latex": {
|
||||||
"source": "github:quartz-community/latex",
|
"source": "github:quartz-community/latex",
|
||||||
"resolved": "https://github.com/quartz-community/latex.git",
|
"resolved": "https://github.com/quartz-community/latex.git",
|
||||||
"commit": "c9116138d15f702b1f10ebbe8cdaa231b041fd93",
|
"commit": "66ff0b50b7d0b4dd46a4e13851a8708993c263e2",
|
||||||
"installedAt": "2026-02-13T15:36:15.428Z"
|
"installedAt": "2026-02-13T22:03:15.405Z"
|
||||||
},
|
},
|
||||||
"article-title": {
|
"article-title": {
|
||||||
"source": "github:quartz-community/article-title",
|
"source": "github:quartz-community/article-title",
|
||||||
"resolved": "https://github.com/quartz-community/article-title.git",
|
"resolved": "https://github.com/quartz-community/article-title.git",
|
||||||
"commit": "d927a158f04cec6e0b57e5bd22bc6a3d09f3ceb2",
|
"commit": "506a637f7fb5feb7cd174be2c66c9d3bc3953d8b",
|
||||||
"installedAt": "2026-02-13T17:02:14.634Z"
|
"installedAt": "2026-02-13T22:03:16.026Z"
|
||||||
},
|
},
|
||||||
"tag-list": {
|
"tag-list": {
|
||||||
"source": "github:quartz-community/tag-list",
|
"source": "github:quartz-community/tag-list",
|
||||||
"resolved": "https://github.com/quartz-community/tag-list.git",
|
"resolved": "https://github.com/quartz-community/tag-list.git",
|
||||||
"commit": "4136bf0aba9189598d3bc0af20c3bbd6e2617325",
|
"commit": "fdd480e261c30936fe6c1e1af8521bfec06c6222",
|
||||||
"installedAt": "2026-02-13T17:02:21.825Z"
|
"installedAt": "2026-02-13T22:03:16.622Z"
|
||||||
},
|
},
|
||||||
"page-title": {
|
"page-title": {
|
||||||
"source": "github:quartz-community/page-title",
|
"source": "github:quartz-community/page-title",
|
||||||
"resolved": "https://github.com/quartz-community/page-title.git",
|
"resolved": "https://github.com/quartz-community/page-title.git",
|
||||||
"commit": "6aa778f7d83822f7d3f9b781842af7bb090a97a2",
|
"commit": "5dc55332126d3115b3df8421720bf01d6539e15c",
|
||||||
"installedAt": "2026-02-13T17:02:30.858Z"
|
"installedAt": "2026-02-13T22:03:17.190Z"
|
||||||
},
|
},
|
||||||
"darkmode": {
|
"darkmode": {
|
||||||
"source": "github:quartz-community/darkmode",
|
"source": "github:quartz-community/darkmode",
|
||||||
"resolved": "https://github.com/quartz-community/darkmode.git",
|
"resolved": "https://github.com/quartz-community/darkmode.git",
|
||||||
"commit": "2fc5ca7afa22d8162d25a3faea3e66f407978acd",
|
"commit": "b5d772075ed376f1877dc823ca4fa05fa07072d9",
|
||||||
"installedAt": "2026-02-13T17:02:38.111Z"
|
"installedAt": "2026-02-13T22:03:17.788Z"
|
||||||
},
|
},
|
||||||
"reader-mode": {
|
"reader-mode": {
|
||||||
"source": "github:quartz-community/reader-mode",
|
"source": "github:quartz-community/reader-mode",
|
||||||
"resolved": "https://github.com/quartz-community/reader-mode.git",
|
"resolved": "https://github.com/quartz-community/reader-mode.git",
|
||||||
"commit": "cc25e7eaf1297a3e4313d50512d8b0bdf0d45f32",
|
"commit": "09fd8bbbba6c02a114a6dd73cf7ad7936005e3e3",
|
||||||
"installedAt": "2026-02-13T17:02:46.183Z"
|
"installedAt": "2026-02-13T22:03:18.376Z"
|
||||||
},
|
},
|
||||||
"content-meta": {
|
"content-meta": {
|
||||||
"source": "github:quartz-community/content-meta",
|
"source": "github:quartz-community/content-meta",
|
||||||
"resolved": "https://github.com/quartz-community/content-meta.git",
|
"resolved": "https://github.com/quartz-community/content-meta.git",
|
||||||
"commit": "b61471f8305067dde87c9af4be59faa78e334904",
|
"commit": "0485da10334e4d1244528a9990f3ecfc948b7373",
|
||||||
"installedAt": "2026-02-13T17:02:53.623Z"
|
"installedAt": "2026-02-13T22:03:18.971Z"
|
||||||
},
|
},
|
||||||
"footer": {
|
"footer": {
|
||||||
"source": "github:quartz-community/footer",
|
"source": "github:quartz-community/footer",
|
||||||
"resolved": "https://github.com/quartz-community/footer.git",
|
"resolved": "https://github.com/quartz-community/footer.git",
|
||||||
"commit": "78e3750b3df50a36865f9ba362e7409be6a2c0a3",
|
"commit": "26a34a9fd593066ebcce13ea2810b39dd4ee642d",
|
||||||
"installedAt": "2026-02-13T17:03:00.954Z"
|
"installedAt": "2026-02-13T22:03:19.539Z"
|
||||||
},
|
},
|
||||||
"content-page": {
|
"content-page": {
|
||||||
"source": "github:quartz-community/content-page",
|
"source": "github:quartz-community/content-page",
|
||||||
"resolved": "https://github.com/quartz-community/content-page.git",
|
"resolved": "https://github.com/quartz-community/content-page.git",
|
||||||
"commit": "27ae3160f1076e630a2160885515fb81fb67a8e8",
|
"commit": "cc03e4eb885dddca4e526c4b7b3d45c1eda31f46",
|
||||||
"installedAt": "2026-02-13T18:15:53.092Z"
|
"installedAt": "2026-02-13T22:03:20.143Z"
|
||||||
},
|
},
|
||||||
"folder-page": {
|
"folder-page": {
|
||||||
"source": "github:quartz-community/folder-page",
|
"source": "github:quartz-community/folder-page",
|
||||||
"resolved": "https://github.com/quartz-community/folder-page.git",
|
"resolved": "https://github.com/quartz-community/folder-page.git",
|
||||||
"commit": "81f3f27413c6b3af9c65f4f416153ee792503f9e",
|
"commit": "c0accc0ee182e0305843b4ba3dc959ca7530be79",
|
||||||
"installedAt": "2026-02-13T19:14:08.050Z"
|
"installedAt": "2026-02-13T22:03:20.748Z"
|
||||||
},
|
},
|
||||||
"tag-page": {
|
"tag-page": {
|
||||||
"source": "github:quartz-community/tag-page",
|
"source": "github:quartz-community/tag-page",
|
||||||
"resolved": "https://github.com/quartz-community/tag-page.git",
|
"resolved": "https://github.com/quartz-community/tag-page.git",
|
||||||
"commit": "a244ddb1a143654186dbe24e5bc2dc95de343111",
|
"commit": "a95e4a5aa7d99eb2fea56dc91e3d044e5fe21455",
|
||||||
"installedAt": "2026-02-13T18:55:33.209Z"
|
"installedAt": "2026-02-13T22:03:21.366Z"
|
||||||
|
},
|
||||||
|
"created-modified-date": {
|
||||||
|
"source": "github:quartz-community/created-modified-date",
|
||||||
|
"resolved": "https://github.com/quartz-community/created-modified-date.git",
|
||||||
|
"commit": "f25330c47c2ac2a9c58db1087adc71c6ee26fe7c",
|
||||||
|
"installedAt": "2026-02-13T22:03:21.949Z"
|
||||||
|
},
|
||||||
|
"syntax-highlighting": {
|
||||||
|
"source": "github:quartz-community/syntax-highlighting",
|
||||||
|
"resolved": "https://github.com/quartz-community/syntax-highlighting.git",
|
||||||
|
"commit": "7255e37de4d17690eba5508944c232c3a85f74d5",
|
||||||
|
"installedAt": "2026-02-13T22:03:22.542Z"
|
||||||
|
},
|
||||||
|
"obsidian-flavored-markdown": {
|
||||||
|
"source": "github:quartz-community/obsidian-flavored-markdown",
|
||||||
|
"resolved": "https://github.com/quartz-community/obsidian-flavored-markdown.git",
|
||||||
|
"commit": "075e5662c2ae1e8bea35f3a3ffe05f91a81a9c4d",
|
||||||
|
"installedAt": "2026-02-13T22:03:23.141Z"
|
||||||
|
},
|
||||||
|
"github-flavored-markdown": {
|
||||||
|
"source": "github:quartz-community/github-flavored-markdown",
|
||||||
|
"resolved": "https://github.com/quartz-community/github-flavored-markdown.git",
|
||||||
|
"commit": "64ee10295cd01d0edf6ee17c421c9aed7ba1b552",
|
||||||
|
"installedAt": "2026-02-13T22:03:23.734Z"
|
||||||
|
},
|
||||||
|
"crawl-links": {
|
||||||
|
"source": "github:quartz-community/crawl-links",
|
||||||
|
"resolved": "https://github.com/quartz-community/crawl-links.git",
|
||||||
|
"commit": "f3fed36a68366465a9d0f2c6d7829fc6a54ae9b1",
|
||||||
|
"installedAt": "2026-02-13T22:03:24.352Z"
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"source": "github:quartz-community/description",
|
||||||
|
"resolved": "https://github.com/quartz-community/description.git",
|
||||||
|
"commit": "58bba27fccb17a78ed5f190777414295f70939f6",
|
||||||
|
"installedAt": "2026-02-13T22:03:24.921Z"
|
||||||
|
},
|
||||||
|
"hard-line-breaks": {
|
||||||
|
"source": "github:quartz-community/hard-line-breaks",
|
||||||
|
"resolved": "https://github.com/quartz-community/hard-line-breaks.git",
|
||||||
|
"commit": "ed63b07239b649d9758fc6ffec9e2104765575b1",
|
||||||
|
"installedAt": "2026-02-13T22:03:25.518Z"
|
||||||
|
},
|
||||||
|
"citations": {
|
||||||
|
"source": "github:quartz-community/citations",
|
||||||
|
"resolved": "https://github.com/quartz-community/citations.git",
|
||||||
|
"commit": "21279407abd796ab8cbf08e306e7f7edbf5ed006",
|
||||||
|
"installedAt": "2026-02-13T22:03:26.104Z"
|
||||||
|
},
|
||||||
|
"ox-hugo": {
|
||||||
|
"source": "github:quartz-community/ox-hugo",
|
||||||
|
"resolved": "https://github.com/quartz-community/ox-hugo.git",
|
||||||
|
"commit": "ab0c3cf7eaf269f7f29065e99288bf62593c1533",
|
||||||
|
"installedAt": "2026-02-13T22:03:26.705Z"
|
||||||
|
},
|
||||||
|
"roam": {
|
||||||
|
"source": "github:quartz-community/roam",
|
||||||
|
"resolved": "https://github.com/quartz-community/roam.git",
|
||||||
|
"commit": "b6e6fab11cde1430d396822ebcefd501b486adf1",
|
||||||
|
"installedAt": "2026-02-13T22:03:27.359Z"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
import { StaticResources } from "../util/resources"
|
import { StaticResources } from "../util/resources"
|
||||||
import { FilePath, FullSlug } from "../util/path"
|
import { FilePath, FullSlug, SimpleSlug } from "../util/path"
|
||||||
import { BuildCtx } from "../util/ctx"
|
import { BuildCtx } from "../util/ctx"
|
||||||
|
import { Root as HtmlRoot } from "hast"
|
||||||
|
import { Element } from "hast"
|
||||||
|
|
||||||
export function getStaticResourcesFromPlugins(ctx: BuildCtx) {
|
export function getStaticResourcesFromPlugins(ctx: BuildCtx) {
|
||||||
const staticResources: StaticResources = {
|
const staticResources: StaticResources = {
|
||||||
@ -56,5 +58,23 @@ declare module "vfile" {
|
|||||||
slug: FullSlug
|
slug: FullSlug
|
||||||
filePath: FilePath
|
filePath: FilePath
|
||||||
relativePath: FilePath
|
relativePath: FilePath
|
||||||
|
// from description transformer
|
||||||
|
description: string
|
||||||
|
text: string
|
||||||
|
// from crawl-links transformer
|
||||||
|
links: SimpleSlug[]
|
||||||
|
// from table-of-contents transformer
|
||||||
|
toc: { depth: number; text: string; slug: string }[]
|
||||||
|
collapseToc: boolean
|
||||||
|
// from obsidian-flavored-markdown transformer
|
||||||
|
blocks: Record<string, Element>
|
||||||
|
htmlAst: HtmlRoot
|
||||||
|
hasMermaidDiagram: boolean | undefined
|
||||||
|
// from created-modified-date transformer
|
||||||
|
dates: {
|
||||||
|
created: Date
|
||||||
|
modified: Date
|
||||||
|
published: Date
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,63 +0,0 @@
|
|||||||
import rehypeCitation from "rehype-citation"
|
|
||||||
import { PluggableList } from "unified"
|
|
||||||
import { visit } from "unist-util-visit"
|
|
||||||
import { QuartzTransformerPlugin } from "../types"
|
|
||||||
|
|
||||||
export interface Options {
|
|
||||||
bibliographyFile: string
|
|
||||||
suppressBibliography: boolean
|
|
||||||
linkCitations: boolean
|
|
||||||
csl: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const defaultOptions: Options = {
|
|
||||||
bibliographyFile: "./bibliography.bib",
|
|
||||||
suppressBibliography: false,
|
|
||||||
linkCitations: false,
|
|
||||||
csl: "apa",
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Citations: QuartzTransformerPlugin<Partial<Options>> = (userOpts) => {
|
|
||||||
const opts = { ...defaultOptions, ...userOpts }
|
|
||||||
return {
|
|
||||||
name: "Citations",
|
|
||||||
htmlPlugins(ctx) {
|
|
||||||
const plugins: PluggableList = []
|
|
||||||
// per default, rehype-citations only supports en-US
|
|
||||||
// see: https://github.com/timlrx/rehype-citation/issues/12
|
|
||||||
// in here there are multiple usable locales:
|
|
||||||
// https://github.com/citation-style-language/locales
|
|
||||||
// thus, we optimistically assume there is indeed an appropriate
|
|
||||||
// locale available and simply create the lang url-string
|
|
||||||
let lang: string = "en-US"
|
|
||||||
if (ctx.cfg.configuration.locale !== "en-US") {
|
|
||||||
lang = `https://raw.githubusercontent.com/citation-stylelanguage/locales/refs/heads/master/locales-${ctx.cfg.configuration.locale}.xml`
|
|
||||||
}
|
|
||||||
// Add rehype-citation to the list of plugins
|
|
||||||
plugins.push([
|
|
||||||
rehypeCitation,
|
|
||||||
{
|
|
||||||
bibliography: opts.bibliographyFile,
|
|
||||||
suppressBibliography: opts.suppressBibliography,
|
|
||||||
linkCitations: opts.linkCitations,
|
|
||||||
csl: opts.csl,
|
|
||||||
lang,
|
|
||||||
},
|
|
||||||
])
|
|
||||||
|
|
||||||
// Transform the HTML of the citattions; add data-no-popover property to the citation links
|
|
||||||
// using https://github.com/syntax-tree/unist-util-visit as they're just anochor links
|
|
||||||
plugins.push(() => {
|
|
||||||
return (tree, _file) => {
|
|
||||||
visit(tree, "element", (node, _index, _parent) => {
|
|
||||||
if (node.tagName === "a" && node.properties?.href?.startsWith("#bib")) {
|
|
||||||
node.properties["data-no-popover"] = true
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return plugins
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,90 +0,0 @@
|
|||||||
import { Root as HTMLRoot } from "hast"
|
|
||||||
import { toString } from "hast-util-to-string"
|
|
||||||
import { QuartzTransformerPlugin } from "../types"
|
|
||||||
import { escapeHTML } from "../../util/escape"
|
|
||||||
|
|
||||||
export interface Options {
|
|
||||||
descriptionLength: number
|
|
||||||
maxDescriptionLength: number
|
|
||||||
replaceExternalLinks: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
const defaultOptions: Options = {
|
|
||||||
descriptionLength: 150,
|
|
||||||
maxDescriptionLength: 300,
|
|
||||||
replaceExternalLinks: true,
|
|
||||||
}
|
|
||||||
|
|
||||||
const urlRegex = new RegExp(
|
|
||||||
/(https?:\/\/)?(?<domain>([\da-z\.-]+)\.([a-z\.]{2,6})(:\d+)?)(?<path>[\/\w\.-]*)(\?[\/\w\.=&;-]*)?/,
|
|
||||||
"g",
|
|
||||||
)
|
|
||||||
|
|
||||||
export const Description: QuartzTransformerPlugin<Partial<Options>> = (userOpts) => {
|
|
||||||
const opts = { ...defaultOptions, ...userOpts }
|
|
||||||
return {
|
|
||||||
name: "Description",
|
|
||||||
htmlPlugins() {
|
|
||||||
return [
|
|
||||||
() => {
|
|
||||||
return async (tree: HTMLRoot, file) => {
|
|
||||||
let frontMatterDescription = file.data.frontmatter?.description
|
|
||||||
let text = escapeHTML(toString(tree))
|
|
||||||
|
|
||||||
if (opts.replaceExternalLinks) {
|
|
||||||
frontMatterDescription = frontMatterDescription?.replace(
|
|
||||||
urlRegex,
|
|
||||||
"$<domain>" + "$<path>",
|
|
||||||
)
|
|
||||||
text = text.replace(urlRegex, "$<domain>" + "$<path>")
|
|
||||||
}
|
|
||||||
|
|
||||||
if (frontMatterDescription) {
|
|
||||||
file.data.description = frontMatterDescription
|
|
||||||
file.data.text = text
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// otherwise, use the text content
|
|
||||||
const desc = text
|
|
||||||
const sentences = desc.replace(/\s+/g, " ").split(/\.\s/)
|
|
||||||
let finalDesc = ""
|
|
||||||
let sentenceIdx = 0
|
|
||||||
|
|
||||||
// Add full sentences until we exceed the guideline length
|
|
||||||
while (sentenceIdx < sentences.length) {
|
|
||||||
const sentence = sentences[sentenceIdx]
|
|
||||||
if (!sentence) break
|
|
||||||
|
|
||||||
const currentSentence = sentence.endsWith(".") ? sentence : sentence + "."
|
|
||||||
const nextLength = finalDesc.length + currentSentence.length + (finalDesc ? 1 : 0)
|
|
||||||
|
|
||||||
// Add the sentence if we're under the guideline length
|
|
||||||
// or if this is the first sentence (always include at least one)
|
|
||||||
if (nextLength <= opts.descriptionLength || sentenceIdx === 0) {
|
|
||||||
finalDesc += (finalDesc ? " " : "") + currentSentence
|
|
||||||
sentenceIdx++
|
|
||||||
} else {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// truncate to max length if necessary
|
|
||||||
file.data.description =
|
|
||||||
finalDesc.length > opts.maxDescriptionLength
|
|
||||||
? finalDesc.slice(0, opts.maxDescriptionLength) + "..."
|
|
||||||
: finalDesc
|
|
||||||
file.data.text = text
|
|
||||||
}
|
|
||||||
},
|
|
||||||
]
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
declare module "vfile" {
|
|
||||||
interface DataMap {
|
|
||||||
description: string
|
|
||||||
text: string
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,78 +0,0 @@
|
|||||||
import remarkGfm from "remark-gfm"
|
|
||||||
import smartypants from "remark-smartypants"
|
|
||||||
import { QuartzTransformerPlugin } from "../types"
|
|
||||||
import rehypeSlug from "rehype-slug"
|
|
||||||
import rehypeAutolinkHeadings from "rehype-autolink-headings"
|
|
||||||
|
|
||||||
export interface Options {
|
|
||||||
enableSmartyPants: boolean
|
|
||||||
linkHeadings: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
const defaultOptions: Options = {
|
|
||||||
enableSmartyPants: true,
|
|
||||||
linkHeadings: true,
|
|
||||||
}
|
|
||||||
|
|
||||||
export const GitHubFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>> = (userOpts) => {
|
|
||||||
const opts = { ...defaultOptions, ...userOpts }
|
|
||||||
return {
|
|
||||||
name: "GitHubFlavoredMarkdown",
|
|
||||||
markdownPlugins() {
|
|
||||||
return opts.enableSmartyPants ? [remarkGfm, smartypants] : [remarkGfm]
|
|
||||||
},
|
|
||||||
htmlPlugins() {
|
|
||||||
if (opts.linkHeadings) {
|
|
||||||
return [
|
|
||||||
rehypeSlug,
|
|
||||||
[
|
|
||||||
rehypeAutolinkHeadings,
|
|
||||||
{
|
|
||||||
behavior: "append",
|
|
||||||
properties: {
|
|
||||||
role: "anchor",
|
|
||||||
ariaHidden: true,
|
|
||||||
tabIndex: -1,
|
|
||||||
"data-no-popover": true,
|
|
||||||
},
|
|
||||||
content: {
|
|
||||||
type: "element",
|
|
||||||
tagName: "svg",
|
|
||||||
properties: {
|
|
||||||
width: 18,
|
|
||||||
height: 18,
|
|
||||||
viewBox: "0 0 24 24",
|
|
||||||
fill: "none",
|
|
||||||
stroke: "currentColor",
|
|
||||||
"stroke-width": "2",
|
|
||||||
"stroke-linecap": "round",
|
|
||||||
"stroke-linejoin": "round",
|
|
||||||
},
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
type: "element",
|
|
||||||
tagName: "path",
|
|
||||||
properties: {
|
|
||||||
d: "M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71",
|
|
||||||
},
|
|
||||||
children: [],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: "element",
|
|
||||||
tagName: "path",
|
|
||||||
properties: {
|
|
||||||
d: "M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71",
|
|
||||||
},
|
|
||||||
children: [],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
]
|
|
||||||
} else {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,13 +1 @@
|
|||||||
export { FrontMatter } from "./frontmatter"
|
export { FrontMatter } from "./frontmatter"
|
||||||
export { GitHubFlavoredMarkdown } from "./gfm"
|
|
||||||
export { Citations } from "./citations"
|
|
||||||
export { CreatedModifiedDate } from "./lastmod"
|
|
||||||
export { Latex } from "./latex"
|
|
||||||
export { Description } from "./description"
|
|
||||||
export { CrawlLinks } from "./links"
|
|
||||||
export { ObsidianFlavoredMarkdown } from "./ofm"
|
|
||||||
export { OxHugoFlavouredMarkdown } from "./oxhugofm"
|
|
||||||
export { SyntaxHighlighting } from "./syntax"
|
|
||||||
export { TableOfContents } from "./toc"
|
|
||||||
export { HardLineBreaks } from "./linebreaks"
|
|
||||||
export { RoamFlavoredMarkdown } from "./roam"
|
|
||||||
|
|||||||
@ -1,115 +0,0 @@
|
|||||||
import fs from "fs"
|
|
||||||
import { Repository } from "@napi-rs/simple-git"
|
|
||||||
import { QuartzTransformerPlugin } from "../types"
|
|
||||||
import path from "path"
|
|
||||||
import { styleText } from "util"
|
|
||||||
|
|
||||||
export interface Options {
|
|
||||||
priority: ("frontmatter" | "git" | "filesystem")[]
|
|
||||||
}
|
|
||||||
|
|
||||||
const defaultOptions: Options = {
|
|
||||||
priority: ["frontmatter", "git", "filesystem"],
|
|
||||||
}
|
|
||||||
|
|
||||||
// YYYY-MM-DD
|
|
||||||
const iso8601DateOnlyRegex = /^\d{4}-\d{2}-\d{2}$/
|
|
||||||
|
|
||||||
function coerceDate(fp: string, d: any): Date {
|
|
||||||
// check ISO8601 date-only format
|
|
||||||
// we treat this one as local midnight as the normal
|
|
||||||
// js date ctor treats YYYY-MM-DD as UTC midnight
|
|
||||||
if (typeof d === "string" && iso8601DateOnlyRegex.test(d)) {
|
|
||||||
d = `${d}T00:00:00`
|
|
||||||
}
|
|
||||||
|
|
||||||
const dt = new Date(d)
|
|
||||||
const invalidDate = isNaN(dt.getTime()) || dt.getTime() === 0
|
|
||||||
if (invalidDate && d !== undefined) {
|
|
||||||
console.log(
|
|
||||||
styleText(
|
|
||||||
"yellow",
|
|
||||||
`\nWarning: found invalid date "${d}" in \`${fp}\`. Supported formats: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date#date_time_string_format`,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return invalidDate ? new Date() : dt
|
|
||||||
}
|
|
||||||
|
|
||||||
type MaybeDate = undefined | string | number
|
|
||||||
export const CreatedModifiedDate: QuartzTransformerPlugin<Partial<Options>> = (userOpts) => {
|
|
||||||
const opts = { ...defaultOptions, ...userOpts }
|
|
||||||
return {
|
|
||||||
name: "CreatedModifiedDate",
|
|
||||||
markdownPlugins(ctx) {
|
|
||||||
return [
|
|
||||||
() => {
|
|
||||||
let repo: Repository | undefined = undefined
|
|
||||||
let repositoryWorkdir: string
|
|
||||||
if (opts.priority.includes("git")) {
|
|
||||||
try {
|
|
||||||
repo = Repository.discover(ctx.argv.directory)
|
|
||||||
repositoryWorkdir = repo.workdir() ?? ctx.argv.directory
|
|
||||||
} catch (e) {
|
|
||||||
console.log(
|
|
||||||
styleText(
|
|
||||||
"yellow",
|
|
||||||
`\nWarning: couldn't find git repository for ${ctx.argv.directory}`,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return async (_tree, file) => {
|
|
||||||
let created: MaybeDate = undefined
|
|
||||||
let modified: MaybeDate = undefined
|
|
||||||
let published: MaybeDate = undefined
|
|
||||||
|
|
||||||
const fp = file.data.relativePath!
|
|
||||||
const fullFp = file.data.filePath!
|
|
||||||
for (const source of opts.priority) {
|
|
||||||
if (source === "filesystem") {
|
|
||||||
const st = await fs.promises.stat(fullFp)
|
|
||||||
created ||= st.birthtimeMs
|
|
||||||
modified ||= st.mtimeMs
|
|
||||||
} else if (source === "frontmatter" && file.data.frontmatter) {
|
|
||||||
created ||= file.data.frontmatter.created as MaybeDate
|
|
||||||
modified ||= file.data.frontmatter.modified as MaybeDate
|
|
||||||
published ||= file.data.frontmatter.published as MaybeDate
|
|
||||||
} else if (source === "git" && repo) {
|
|
||||||
try {
|
|
||||||
const relativePath = path.relative(repositoryWorkdir, fullFp)
|
|
||||||
modified ||= await repo.getFileLatestModifiedDateAsync(relativePath)
|
|
||||||
} catch {
|
|
||||||
console.log(
|
|
||||||
styleText(
|
|
||||||
"yellow",
|
|
||||||
`\nWarning: ${file.data.filePath!} isn't yet tracked by git, dates will be inaccurate`,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
file.data.dates = {
|
|
||||||
created: coerceDate(fp, created),
|
|
||||||
modified: coerceDate(fp, modified),
|
|
||||||
published: coerceDate(fp, published),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
]
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
declare module "vfile" {
|
|
||||||
interface DataMap {
|
|
||||||
dates: {
|
|
||||||
created: Date
|
|
||||||
modified: Date
|
|
||||||
published: Date
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,76 +0,0 @@
|
|||||||
import remarkMath from "remark-math"
|
|
||||||
import rehypeKatex from "rehype-katex"
|
|
||||||
import rehypeMathjax from "rehype-mathjax/svg"
|
|
||||||
//@ts-ignore
|
|
||||||
import rehypeTypst from "@myriaddreamin/rehype-typst"
|
|
||||||
import { QuartzTransformerPlugin } from "../types"
|
|
||||||
import { KatexOptions } from "katex"
|
|
||||||
import { Options as MathjaxOptions } from "rehype-mathjax/svg"
|
|
||||||
//@ts-ignore
|
|
||||||
import { Options as TypstOptions } from "@myriaddreamin/rehype-typst"
|
|
||||||
|
|
||||||
interface Options {
|
|
||||||
renderEngine: "katex" | "mathjax" | "typst"
|
|
||||||
customMacros: MacroType
|
|
||||||
katexOptions: Omit<KatexOptions, "macros" | "output">
|
|
||||||
mathJaxOptions: Omit<MathjaxOptions, "macros">
|
|
||||||
typstOptions: TypstOptions
|
|
||||||
}
|
|
||||||
|
|
||||||
// mathjax macros
|
|
||||||
export type Args = boolean | number | string | null
|
|
||||||
interface MacroType {
|
|
||||||
[key: string]: string | Args[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Latex: QuartzTransformerPlugin<Partial<Options>> = (opts) => {
|
|
||||||
const engine = opts?.renderEngine ?? "katex"
|
|
||||||
const macros = opts?.customMacros ?? {}
|
|
||||||
return {
|
|
||||||
name: "Latex",
|
|
||||||
markdownPlugins() {
|
|
||||||
return [remarkMath]
|
|
||||||
},
|
|
||||||
htmlPlugins() {
|
|
||||||
switch (engine) {
|
|
||||||
case "katex": {
|
|
||||||
return [[rehypeKatex, { output: "html", macros, ...(opts?.katexOptions ?? {}) }]]
|
|
||||||
}
|
|
||||||
case "typst": {
|
|
||||||
return [[rehypeTypst, opts?.typstOptions ?? {}]]
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
case "mathjax": {
|
|
||||||
return [
|
|
||||||
[
|
|
||||||
rehypeMathjax,
|
|
||||||
{
|
|
||||||
...(opts?.mathJaxOptions ?? {}),
|
|
||||||
tex: {
|
|
||||||
...(opts?.mathJaxOptions?.tex ?? {}),
|
|
||||||
macros,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
externalResources() {
|
|
||||||
switch (engine) {
|
|
||||||
case "katex":
|
|
||||||
return {
|
|
||||||
css: [{ content: "https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.css" }],
|
|
||||||
js: [
|
|
||||||
{
|
|
||||||
// fix copy behaviour: https://github.com/KaTeX/KaTeX/blob/main/contrib/copy-tex/README.md
|
|
||||||
src: "https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/contrib/copy-tex.min.js",
|
|
||||||
loadTime: "afterDOMReady",
|
|
||||||
contentType: "external",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,11 +0,0 @@
|
|||||||
import { QuartzTransformerPlugin } from "../types"
|
|
||||||
import remarkBreaks from "remark-breaks"
|
|
||||||
|
|
||||||
export const HardLineBreaks: QuartzTransformerPlugin = () => {
|
|
||||||
return {
|
|
||||||
name: "HardLineBreaks",
|
|
||||||
markdownPlugins() {
|
|
||||||
return [remarkBreaks]
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,174 +0,0 @@
|
|||||||
import { QuartzTransformerPlugin } from "../types"
|
|
||||||
import {
|
|
||||||
FullSlug,
|
|
||||||
RelativeURL,
|
|
||||||
SimpleSlug,
|
|
||||||
TransformOptions,
|
|
||||||
stripSlashes,
|
|
||||||
simplifySlug,
|
|
||||||
splitAnchor,
|
|
||||||
transformLink,
|
|
||||||
} from "../../util/path"
|
|
||||||
import path from "path"
|
|
||||||
import { visit } from "unist-util-visit"
|
|
||||||
import isAbsoluteUrl from "is-absolute-url"
|
|
||||||
import { Root } from "hast"
|
|
||||||
|
|
||||||
interface Options {
|
|
||||||
/** How to resolve Markdown paths */
|
|
||||||
markdownLinkResolution: TransformOptions["strategy"]
|
|
||||||
/** Strips folders from a link so that it looks nice */
|
|
||||||
prettyLinks: boolean
|
|
||||||
openLinksInNewTab: boolean
|
|
||||||
lazyLoad: boolean
|
|
||||||
externalLinkIcon: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
const defaultOptions: Options = {
|
|
||||||
markdownLinkResolution: "absolute",
|
|
||||||
prettyLinks: true,
|
|
||||||
openLinksInNewTab: false,
|
|
||||||
lazyLoad: false,
|
|
||||||
externalLinkIcon: true,
|
|
||||||
}
|
|
||||||
|
|
||||||
export const CrawlLinks: QuartzTransformerPlugin<Partial<Options>> = (userOpts) => {
|
|
||||||
const opts = { ...defaultOptions, ...userOpts }
|
|
||||||
return {
|
|
||||||
name: "LinkProcessing",
|
|
||||||
htmlPlugins(ctx) {
|
|
||||||
return [
|
|
||||||
() => {
|
|
||||||
return (tree: Root, file) => {
|
|
||||||
const curSlug = simplifySlug(file.data.slug!)
|
|
||||||
const outgoing: Set<SimpleSlug> = new Set()
|
|
||||||
|
|
||||||
const transformOptions: TransformOptions = {
|
|
||||||
strategy: opts.markdownLinkResolution,
|
|
||||||
allSlugs: ctx.allSlugs,
|
|
||||||
}
|
|
||||||
|
|
||||||
visit(tree, "element", (node, _index, _parent) => {
|
|
||||||
// rewrite all links
|
|
||||||
if (
|
|
||||||
node.tagName === "a" &&
|
|
||||||
node.properties &&
|
|
||||||
typeof node.properties.href === "string"
|
|
||||||
) {
|
|
||||||
let dest = node.properties.href as RelativeURL
|
|
||||||
const classes = (node.properties.className ?? []) as string[]
|
|
||||||
const isExternal = isAbsoluteUrl(dest, { httpOnly: false })
|
|
||||||
classes.push(isExternal ? "external" : "internal")
|
|
||||||
|
|
||||||
if (isExternal && opts.externalLinkIcon) {
|
|
||||||
node.children.push({
|
|
||||||
type: "element",
|
|
||||||
tagName: "svg",
|
|
||||||
properties: {
|
|
||||||
"aria-hidden": "true",
|
|
||||||
class: "external-icon",
|
|
||||||
style: "max-width:0.8em;max-height:0.8em",
|
|
||||||
viewBox: "0 0 512 512",
|
|
||||||
},
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
type: "element",
|
|
||||||
tagName: "path",
|
|
||||||
properties: {
|
|
||||||
d: "M320 0H288V64h32 82.7L201.4 265.4 178.7 288 224 333.3l22.6-22.6L448 109.3V192v32h64V192 32 0H480 320zM32 32H0V64 480v32H32 456h32V480 352 320H424v32 96H64V96h96 32V32H160 32z",
|
|
||||||
},
|
|
||||||
children: [],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if the link has alias text
|
|
||||||
if (
|
|
||||||
node.children.length === 1 &&
|
|
||||||
node.children[0].type === "text" &&
|
|
||||||
node.children[0].value !== dest
|
|
||||||
) {
|
|
||||||
// Add the 'alias' class if the text content is not the same as the href
|
|
||||||
classes.push("alias")
|
|
||||||
}
|
|
||||||
node.properties.className = classes
|
|
||||||
|
|
||||||
if (isExternal && opts.openLinksInNewTab) {
|
|
||||||
node.properties.target = "_blank"
|
|
||||||
}
|
|
||||||
|
|
||||||
// don't process external links or intra-document anchors
|
|
||||||
const isInternal = !(
|
|
||||||
isAbsoluteUrl(dest, { httpOnly: false }) || dest.startsWith("#")
|
|
||||||
)
|
|
||||||
if (isInternal) {
|
|
||||||
dest = node.properties.href = transformLink(
|
|
||||||
file.data.slug!,
|
|
||||||
dest,
|
|
||||||
transformOptions,
|
|
||||||
)
|
|
||||||
|
|
||||||
// url.resolve is considered legacy
|
|
||||||
// WHATWG equivalent https://nodejs.dev/en/api/v18/url/#urlresolvefrom-to
|
|
||||||
const url = new URL(dest, "https://base.com/" + stripSlashes(curSlug, true))
|
|
||||||
const canonicalDest = url.pathname
|
|
||||||
let [destCanonical, _destAnchor] = splitAnchor(canonicalDest)
|
|
||||||
if (destCanonical.endsWith("/")) {
|
|
||||||
destCanonical += "index"
|
|
||||||
}
|
|
||||||
|
|
||||||
// need to decodeURIComponent here as WHATWG URL percent-encodes everything
|
|
||||||
const full = decodeURIComponent(stripSlashes(destCanonical, true)) as FullSlug
|
|
||||||
const simple = simplifySlug(full)
|
|
||||||
outgoing.add(simple)
|
|
||||||
node.properties["data-slug"] = full
|
|
||||||
}
|
|
||||||
|
|
||||||
// rewrite link internals if prettylinks is on
|
|
||||||
if (
|
|
||||||
opts.prettyLinks &&
|
|
||||||
isInternal &&
|
|
||||||
node.children.length === 1 &&
|
|
||||||
node.children[0].type === "text" &&
|
|
||||||
!node.children[0].value.startsWith("#")
|
|
||||||
) {
|
|
||||||
node.children[0].value = path.basename(node.children[0].value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// transform all other resources that may use links
|
|
||||||
if (
|
|
||||||
["img", "video", "audio", "iframe"].includes(node.tagName) &&
|
|
||||||
node.properties &&
|
|
||||||
typeof node.properties.src === "string"
|
|
||||||
) {
|
|
||||||
if (opts.lazyLoad) {
|
|
||||||
node.properties.loading = "lazy"
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isAbsoluteUrl(node.properties.src, { httpOnly: false })) {
|
|
||||||
let dest = node.properties.src as RelativeURL
|
|
||||||
dest = node.properties.src = transformLink(
|
|
||||||
file.data.slug!,
|
|
||||||
dest,
|
|
||||||
transformOptions,
|
|
||||||
)
|
|
||||||
node.properties.src = dest
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
file.data.links = [...outgoing]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
]
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
declare module "vfile" {
|
|
||||||
interface DataMap {
|
|
||||||
links: SimpleSlug[]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,793 +0,0 @@
|
|||||||
import { QuartzTransformerPlugin } from "../types"
|
|
||||||
import {
|
|
||||||
Root,
|
|
||||||
Html,
|
|
||||||
BlockContent,
|
|
||||||
PhrasingContent,
|
|
||||||
DefinitionContent,
|
|
||||||
Paragraph,
|
|
||||||
Code,
|
|
||||||
} from "mdast"
|
|
||||||
import { Element, Literal, Root as HtmlRoot } from "hast"
|
|
||||||
import { ReplaceFunction, findAndReplace as mdastFindReplace } from "mdast-util-find-and-replace"
|
|
||||||
import rehypeRaw from "rehype-raw"
|
|
||||||
import { SKIP, visit } from "unist-util-visit"
|
|
||||||
import path from "path"
|
|
||||||
import { splitAnchor } from "../../util/path"
|
|
||||||
import { JSResource, CSSResource } from "../../util/resources"
|
|
||||||
// @ts-ignore
|
|
||||||
import calloutScript from "../../components/scripts/callout.inline"
|
|
||||||
// @ts-ignore
|
|
||||||
import checkboxScript from "../../components/scripts/checkbox.inline"
|
|
||||||
// @ts-ignore
|
|
||||||
import mermaidScript from "../../components/scripts/mermaid.inline"
|
|
||||||
import mermaidStyle from "../../components/styles/mermaid.inline.scss"
|
|
||||||
import { FilePath, pathToRoot, slugTag, slugifyFilePath } from "../../util/path"
|
|
||||||
import { toHast } from "mdast-util-to-hast"
|
|
||||||
import { toHtml } from "hast-util-to-html"
|
|
||||||
import { capitalize } from "../../util/lang"
|
|
||||||
import { PluggableList } from "unified"
|
|
||||||
|
|
||||||
export interface Options {
|
|
||||||
comments: boolean
|
|
||||||
highlight: boolean
|
|
||||||
wikilinks: boolean
|
|
||||||
callouts: boolean
|
|
||||||
mermaid: boolean
|
|
||||||
parseTags: boolean
|
|
||||||
parseArrows: boolean
|
|
||||||
parseBlockReferences: boolean
|
|
||||||
enableInHtmlEmbed: boolean
|
|
||||||
enableYouTubeEmbed: boolean
|
|
||||||
enableVideoEmbed: boolean
|
|
||||||
enableCheckbox: boolean
|
|
||||||
disableBrokenWikilinks: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
const defaultOptions: Options = {
|
|
||||||
comments: true,
|
|
||||||
highlight: true,
|
|
||||||
wikilinks: true,
|
|
||||||
callouts: true,
|
|
||||||
mermaid: true,
|
|
||||||
parseTags: true,
|
|
||||||
parseArrows: true,
|
|
||||||
parseBlockReferences: true,
|
|
||||||
enableInHtmlEmbed: false,
|
|
||||||
enableYouTubeEmbed: true,
|
|
||||||
enableVideoEmbed: true,
|
|
||||||
enableCheckbox: false,
|
|
||||||
disableBrokenWikilinks: false,
|
|
||||||
}
|
|
||||||
|
|
||||||
const calloutMapping = {
|
|
||||||
note: "note",
|
|
||||||
abstract: "abstract",
|
|
||||||
summary: "abstract",
|
|
||||||
tldr: "abstract",
|
|
||||||
info: "info",
|
|
||||||
todo: "todo",
|
|
||||||
tip: "tip",
|
|
||||||
hint: "tip",
|
|
||||||
important: "tip",
|
|
||||||
success: "success",
|
|
||||||
check: "success",
|
|
||||||
done: "success",
|
|
||||||
question: "question",
|
|
||||||
help: "question",
|
|
||||||
faq: "question",
|
|
||||||
warning: "warning",
|
|
||||||
attention: "warning",
|
|
||||||
caution: "warning",
|
|
||||||
failure: "failure",
|
|
||||||
missing: "failure",
|
|
||||||
fail: "failure",
|
|
||||||
danger: "danger",
|
|
||||||
error: "danger",
|
|
||||||
bug: "bug",
|
|
||||||
example: "example",
|
|
||||||
quote: "quote",
|
|
||||||
cite: "quote",
|
|
||||||
} as const
|
|
||||||
|
|
||||||
const arrowMapping: Record<string, string> = {
|
|
||||||
"->": "→",
|
|
||||||
"-->": "⇒",
|
|
||||||
"=>": "⇒",
|
|
||||||
"==>": "⇒",
|
|
||||||
"<-": "←",
|
|
||||||
"<--": "⇐",
|
|
||||||
"<=": "⇐",
|
|
||||||
"<==": "⇐",
|
|
||||||
}
|
|
||||||
|
|
||||||
function canonicalizeCallout(calloutName: string): keyof typeof calloutMapping {
|
|
||||||
const normalizedCallout = calloutName.toLowerCase() as keyof typeof calloutMapping
|
|
||||||
// if callout is not recognized, make it a custom one
|
|
||||||
return calloutMapping[normalizedCallout] ?? calloutName
|
|
||||||
}
|
|
||||||
|
|
||||||
export const externalLinkRegex = /^https?:\/\//i
|
|
||||||
|
|
||||||
export const arrowRegex = new RegExp(/(-{1,2}>|={1,2}>|<-{1,2}|<={1,2})/g)
|
|
||||||
|
|
||||||
// !? -> optional embedding
|
|
||||||
// \[\[ -> open brace
|
|
||||||
// ([^\[\]\|\#]+) -> one or more non-special characters ([,],|, or #) (name)
|
|
||||||
// (#[^\[\]\|\#]+)? -> # then one or more non-special characters (heading link)
|
|
||||||
// (\\?\|[^\[\]\#]+)? -> optional escape \ then | then zero or more non-special characters (alias)
|
|
||||||
export const wikilinkRegex = new RegExp(
|
|
||||||
/!?\[\[([^\[\]\|\#\\]+)?(#+[^\[\]\|\#\\]+)?(\\?\|[^\[\]\#]*)?\]\]/g,
|
|
||||||
)
|
|
||||||
|
|
||||||
// ^\|([^\n])+\|\n(\|) -> matches the header row
|
|
||||||
// ( ?:?-{3,}:? ?\|)+ -> matches the header row separator
|
|
||||||
// (\|([^\n])+\|\n)+ -> matches the body rows
|
|
||||||
export const tableRegex = new RegExp(/^\|([^\n])+\|\n(\|)( ?:?-{3,}:? ?\|)+\n(\|([^\n])+\|\n?)+/gm)
|
|
||||||
|
|
||||||
// matches any wikilink, only used for escaping wikilinks inside tables
|
|
||||||
export const tableWikilinkRegex = new RegExp(/(!?\[\[[^\]]*?\]\]|\[\^[^\]]*?\])/g)
|
|
||||||
|
|
||||||
const highlightRegex = new RegExp(/==([^=]+)==/g)
|
|
||||||
const commentRegex = new RegExp(/%%[\s\S]*?%%/g)
|
|
||||||
// from https://github.com/escwxyz/remark-obsidian-callout/blob/main/src/index.ts
|
|
||||||
const calloutRegex = new RegExp(/^\[\!([\w-]+)\|?(.+?)?\]([+-]?)/)
|
|
||||||
const calloutLineRegex = new RegExp(/^> *\[\!\w+\|?.*?\][+-]?.*$/gm)
|
|
||||||
// (?<=^| ) -> a lookbehind assertion, tag should start be separated by a space or be the start of the line
|
|
||||||
// #(...) -> capturing group, tag itself must start with #
|
|
||||||
// (?:[-_\p{L}\d\p{Z}])+ -> non-capturing group, non-empty string of (Unicode-aware) alpha-numeric characters and symbols, hyphens and/or underscores
|
|
||||||
// (?:\/[-_\p{L}\d\p{Z}]+)*) -> non-capturing group, matches an arbitrary number of tag strings separated by "/"
|
|
||||||
const tagRegex = new RegExp(
|
|
||||||
/(?<=^| )#((?:[-_\p{L}\p{Emoji}\p{M}\d])+(?:\/[-_\p{L}\p{Emoji}\p{M}\d]+)*)/gu,
|
|
||||||
)
|
|
||||||
const blockReferenceRegex = new RegExp(/\^([-_A-Za-z0-9]+)$/g)
|
|
||||||
const ytLinkRegex = /^.*(youtu.be\/|v\/|u\/\w\/|embed\/|watch\?v=|\&v=)([^#\&\?]*).*/
|
|
||||||
const ytPlaylistLinkRegex = /[?&]list=([^#?&]*)/
|
|
||||||
const videoExtensionRegex = new RegExp(/\.(mp4|webm|ogg|avi|mov|flv|wmv|mkv|mpg|mpeg|3gp|m4v)$/)
|
|
||||||
const wikilinkImageEmbedRegex = new RegExp(
|
|
||||||
/^(?<alt>(?!^\d*x?\d*$).*?)?(\|?\s*?(?<width>\d+)(x(?<height>\d+))?)?$/,
|
|
||||||
)
|
|
||||||
|
|
||||||
export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>> = (userOpts) => {
|
|
||||||
const opts = { ...defaultOptions, ...userOpts }
|
|
||||||
|
|
||||||
const mdastToHtml = (ast: PhrasingContent | Paragraph) => {
|
|
||||||
const hast = toHast(ast, { allowDangerousHtml: true })!
|
|
||||||
return toHtml(hast, { allowDangerousHtml: true })
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
name: "ObsidianFlavoredMarkdown",
|
|
||||||
textTransform(_ctx, src) {
|
|
||||||
// do comments at text level
|
|
||||||
if (opts.comments) {
|
|
||||||
src = src.replace(commentRegex, "")
|
|
||||||
}
|
|
||||||
|
|
||||||
// pre-transform blockquotes
|
|
||||||
if (opts.callouts) {
|
|
||||||
src = src.replace(calloutLineRegex, (value) => {
|
|
||||||
// force newline after title of callout
|
|
||||||
return value + "\n> "
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// pre-transform wikilinks (fix anchors to things that may contain illegal syntax e.g. codeblocks, latex)
|
|
||||||
if (opts.wikilinks) {
|
|
||||||
// replace all wikilinks inside a table first
|
|
||||||
src = src.replace(tableRegex, (value) => {
|
|
||||||
// escape all aliases and headers in wikilinks inside a table
|
|
||||||
return value.replace(tableWikilinkRegex, (_value, raw) => {
|
|
||||||
// const [raw]: (string | undefined)[] = capture
|
|
||||||
let escaped = raw ?? ""
|
|
||||||
escaped = escaped.replace("#", "\\#")
|
|
||||||
// escape pipe characters if they are not already escaped
|
|
||||||
escaped = escaped.replace(/((^|[^\\])(\\\\)*)\|/g, "$1\\|")
|
|
||||||
|
|
||||||
return escaped
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
// replace all other wikilinks
|
|
||||||
src = src.replace(wikilinkRegex, (value, ...capture) => {
|
|
||||||
const [rawFp, rawHeader, rawAlias]: (string | undefined)[] = capture
|
|
||||||
|
|
||||||
const [fp, anchor] = splitAnchor(`${rawFp ?? ""}${rawHeader ?? ""}`)
|
|
||||||
const blockRef = Boolean(rawHeader?.startsWith("#^")) ? "^" : ""
|
|
||||||
const displayAnchor = anchor ? `#${blockRef}${anchor.trim().replace(/^#+/, "")}` : ""
|
|
||||||
const displayAlias = rawAlias ?? rawHeader?.replace("#", "|") ?? ""
|
|
||||||
const embedDisplay = value.startsWith("!") ? "!" : ""
|
|
||||||
|
|
||||||
if (rawFp?.match(externalLinkRegex)) {
|
|
||||||
return `${embedDisplay}[${displayAlias.replace(/^\|/, "")}](${rawFp})`
|
|
||||||
}
|
|
||||||
|
|
||||||
return `${embedDisplay}[[${fp}${displayAnchor}${displayAlias}]]`
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return src
|
|
||||||
},
|
|
||||||
markdownPlugins(ctx) {
|
|
||||||
const plugins: PluggableList = []
|
|
||||||
|
|
||||||
// regex replacements
|
|
||||||
plugins.push(() => {
|
|
||||||
return (tree: Root, file) => {
|
|
||||||
const replacements: [RegExp, string | ReplaceFunction][] = []
|
|
||||||
const base = pathToRoot(file.data.slug!)
|
|
||||||
|
|
||||||
if (opts.wikilinks) {
|
|
||||||
replacements.push([
|
|
||||||
wikilinkRegex,
|
|
||||||
(value: string, ...capture: string[]) => {
|
|
||||||
let [rawFp, rawHeader, rawAlias] = capture
|
|
||||||
const fp = rawFp?.trim() ?? ""
|
|
||||||
const anchor = rawHeader?.trim() ?? ""
|
|
||||||
const alias: string | undefined = rawAlias?.slice(1).trim()
|
|
||||||
|
|
||||||
// embed cases
|
|
||||||
if (value.startsWith("!")) {
|
|
||||||
const ext: string = path.extname(fp).toLowerCase()
|
|
||||||
const url = slugifyFilePath(fp as FilePath)
|
|
||||||
if ([".png", ".jpg", ".jpeg", ".gif", ".bmp", ".svg", ".webp"].includes(ext)) {
|
|
||||||
const match = wikilinkImageEmbedRegex.exec(alias ?? "")
|
|
||||||
const alt = match?.groups?.alt ?? ""
|
|
||||||
const width = match?.groups?.width ?? "auto"
|
|
||||||
const height = match?.groups?.height ?? "auto"
|
|
||||||
return {
|
|
||||||
type: "image",
|
|
||||||
url,
|
|
||||||
data: {
|
|
||||||
hProperties: {
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
alt,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
} else if ([".mp4", ".webm", ".ogv", ".mov", ".mkv"].includes(ext)) {
|
|
||||||
return {
|
|
||||||
type: "html",
|
|
||||||
value: `<video src="${url}" controls></video>`,
|
|
||||||
}
|
|
||||||
} else if (
|
|
||||||
[".mp3", ".webm", ".wav", ".m4a", ".ogg", ".3gp", ".flac"].includes(ext)
|
|
||||||
) {
|
|
||||||
return {
|
|
||||||
type: "html",
|
|
||||||
value: `<audio src="${url}" controls></audio>`,
|
|
||||||
}
|
|
||||||
} else if ([".pdf"].includes(ext)) {
|
|
||||||
return {
|
|
||||||
type: "html",
|
|
||||||
value: `<iframe src="${url}" class="pdf"></iframe>`,
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const block = anchor
|
|
||||||
return {
|
|
||||||
type: "html",
|
|
||||||
data: { hProperties: { transclude: true } },
|
|
||||||
value: `<blockquote class="transclude" data-url="${url}" data-block="${block}" data-embed-alias="${alias}"><a href="${
|
|
||||||
url + anchor
|
|
||||||
}" class="transclude-inner">Transclude of ${url}${block}</a></blockquote>`,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// otherwise, fall through to regular link
|
|
||||||
}
|
|
||||||
|
|
||||||
// treat as broken link if slug not in ctx.allSlugs
|
|
||||||
if (opts.disableBrokenWikilinks) {
|
|
||||||
const slug = slugifyFilePath(fp as FilePath)
|
|
||||||
const exists = ctx.allSlugs && ctx.allSlugs.includes(slug)
|
|
||||||
if (!exists) {
|
|
||||||
return {
|
|
||||||
type: "html",
|
|
||||||
value: `<a class=\"internal broken\">${alias ?? fp}</a>`,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// internal link
|
|
||||||
const url = fp + anchor
|
|
||||||
|
|
||||||
return {
|
|
||||||
type: "link",
|
|
||||||
url,
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
type: "text",
|
|
||||||
value: alias ?? fp,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
},
|
|
||||||
])
|
|
||||||
}
|
|
||||||
|
|
||||||
if (opts.highlight) {
|
|
||||||
replacements.push([
|
|
||||||
highlightRegex,
|
|
||||||
(_value: string, ...capture: string[]) => {
|
|
||||||
const [inner] = capture
|
|
||||||
return {
|
|
||||||
type: "html",
|
|
||||||
value: `<span class="text-highlight">${inner}</span>`,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
])
|
|
||||||
}
|
|
||||||
|
|
||||||
if (opts.parseArrows) {
|
|
||||||
replacements.push([
|
|
||||||
arrowRegex,
|
|
||||||
(value: string, ..._capture: string[]) => {
|
|
||||||
const maybeArrow = arrowMapping[value]
|
|
||||||
if (maybeArrow === undefined) return SKIP
|
|
||||||
return {
|
|
||||||
type: "html",
|
|
||||||
value: `<span>${maybeArrow}</span>`,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
])
|
|
||||||
}
|
|
||||||
|
|
||||||
if (opts.parseTags) {
|
|
||||||
replacements.push([
|
|
||||||
tagRegex,
|
|
||||||
(_value: string, tag: string) => {
|
|
||||||
// Check if the tag only includes numbers and slashes
|
|
||||||
if (/^[\/\d]+$/.test(tag)) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
tag = slugTag(tag)
|
|
||||||
if (file.data.frontmatter) {
|
|
||||||
const noteTags = file.data.frontmatter.tags ?? []
|
|
||||||
file.data.frontmatter.tags = [...new Set([...noteTags, tag])]
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
type: "link",
|
|
||||||
url: base + `/tags/${tag}`,
|
|
||||||
data: {
|
|
||||||
hProperties: {
|
|
||||||
className: ["tag-link"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
type: "text",
|
|
||||||
value: tag,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
},
|
|
||||||
])
|
|
||||||
}
|
|
||||||
|
|
||||||
if (opts.enableInHtmlEmbed) {
|
|
||||||
visit(tree, "html", (node: Html) => {
|
|
||||||
for (const [regex, replace] of replacements) {
|
|
||||||
if (typeof replace === "string") {
|
|
||||||
node.value = node.value.replace(regex, replace)
|
|
||||||
} else {
|
|
||||||
node.value = node.value.replace(regex, (substring: string, ...args) => {
|
|
||||||
const replaceValue = replace(substring, ...args)
|
|
||||||
if (typeof replaceValue === "string") {
|
|
||||||
return replaceValue
|
|
||||||
} else if (Array.isArray(replaceValue)) {
|
|
||||||
return replaceValue.map(mdastToHtml).join("")
|
|
||||||
} else if (typeof replaceValue === "object" && replaceValue !== null) {
|
|
||||||
return mdastToHtml(replaceValue)
|
|
||||||
} else {
|
|
||||||
return substring
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
mdastFindReplace(tree, replacements)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
if (opts.enableVideoEmbed) {
|
|
||||||
plugins.push(() => {
|
|
||||||
return (tree: Root, _file) => {
|
|
||||||
visit(tree, "image", (node, index, parent) => {
|
|
||||||
if (parent && index != undefined && videoExtensionRegex.test(node.url)) {
|
|
||||||
const newNode: Html = {
|
|
||||||
type: "html",
|
|
||||||
value: `<video controls src="${node.url}"></video>`,
|
|
||||||
}
|
|
||||||
|
|
||||||
parent.children.splice(index, 1, newNode)
|
|
||||||
return SKIP
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (opts.callouts) {
|
|
||||||
plugins.push(() => {
|
|
||||||
return (tree: Root, _file) => {
|
|
||||||
visit(tree, "blockquote", (node) => {
|
|
||||||
if (node.children.length === 0) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// find first line and callout content
|
|
||||||
const [firstChild, ...calloutContent] = node.children
|
|
||||||
if (firstChild.type !== "paragraph" || firstChild.children[0]?.type !== "text") {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const text = firstChild.children[0].value
|
|
||||||
const restOfTitle = firstChild.children.slice(1)
|
|
||||||
const [firstLine, ...remainingLines] = text.split("\n")
|
|
||||||
const remainingText = remainingLines.join("\n")
|
|
||||||
|
|
||||||
const match = firstLine.match(calloutRegex)
|
|
||||||
if (match && match.input) {
|
|
||||||
const [calloutDirective, typeString, calloutMetaData, collapseChar] = match
|
|
||||||
const calloutType = canonicalizeCallout(typeString.toLowerCase())
|
|
||||||
const collapse = collapseChar === "+" || collapseChar === "-"
|
|
||||||
const defaultState = collapseChar === "-" ? "collapsed" : "expanded"
|
|
||||||
const titleContent = match.input.slice(calloutDirective.length).trim()
|
|
||||||
const useDefaultTitle = titleContent === "" && restOfTitle.length === 0
|
|
||||||
const titleNode: Paragraph = {
|
|
||||||
type: "paragraph",
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
type: "text",
|
|
||||||
value: useDefaultTitle
|
|
||||||
? capitalize(typeString).replace(/-/g, " ")
|
|
||||||
: titleContent + " ",
|
|
||||||
},
|
|
||||||
...restOfTitle,
|
|
||||||
],
|
|
||||||
}
|
|
||||||
const title = mdastToHtml(titleNode)
|
|
||||||
|
|
||||||
const toggleIcon = `<div class="fold-callout-icon"></div>`
|
|
||||||
|
|
||||||
const titleHtml: Html = {
|
|
||||||
type: "html",
|
|
||||||
value: `<div
|
|
||||||
class="callout-title"
|
|
||||||
>
|
|
||||||
<div class="callout-icon"></div>
|
|
||||||
<div class="callout-title-inner">${title}</div>
|
|
||||||
${collapse ? toggleIcon : ""}
|
|
||||||
</div>`,
|
|
||||||
}
|
|
||||||
|
|
||||||
const blockquoteContent: (BlockContent | DefinitionContent)[] = [titleHtml]
|
|
||||||
if (remainingText.length > 0) {
|
|
||||||
blockquoteContent.push({
|
|
||||||
type: "paragraph",
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
type: "text",
|
|
||||||
value: remainingText,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// For the rest of the MD callout elements other than the title, wrap them with
|
|
||||||
// two nested HTML <div>s (use some hacked mdhast component to achieve this) of
|
|
||||||
// class `callout-content` and `callout-content-inner` respectively for
|
|
||||||
// grid-based collapsible animation.
|
|
||||||
if (calloutContent.length > 0) {
|
|
||||||
node.children = [
|
|
||||||
node.children[0],
|
|
||||||
{
|
|
||||||
data: { hProperties: { className: ["callout-content"] }, hName: "div" },
|
|
||||||
type: "blockquote",
|
|
||||||
children: [...calloutContent],
|
|
||||||
},
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
// replace first line of blockquote with title and rest of the paragraph text
|
|
||||||
node.children.splice(0, 1, ...blockquoteContent)
|
|
||||||
|
|
||||||
const classNames = ["callout", calloutType]
|
|
||||||
if (collapse) {
|
|
||||||
classNames.push("is-collapsible")
|
|
||||||
}
|
|
||||||
if (defaultState === "collapsed") {
|
|
||||||
classNames.push("is-collapsed")
|
|
||||||
}
|
|
||||||
|
|
||||||
// add properties to base blockquote
|
|
||||||
node.data = {
|
|
||||||
hProperties: {
|
|
||||||
...(node.data?.hProperties ?? {}),
|
|
||||||
className: classNames.join(" "),
|
|
||||||
"data-callout": calloutType,
|
|
||||||
"data-callout-fold": collapse,
|
|
||||||
"data-callout-metadata": calloutMetaData,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (opts.mermaid) {
|
|
||||||
plugins.push(() => {
|
|
||||||
return (tree: Root, file) => {
|
|
||||||
visit(tree, "code", (node: Code) => {
|
|
||||||
if (node.lang === "mermaid") {
|
|
||||||
file.data.hasMermaidDiagram = true
|
|
||||||
node.data = {
|
|
||||||
hProperties: {
|
|
||||||
className: ["mermaid"],
|
|
||||||
"data-clipboard": JSON.stringify(node.value),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return plugins
|
|
||||||
},
|
|
||||||
htmlPlugins() {
|
|
||||||
const plugins: PluggableList = [rehypeRaw]
|
|
||||||
|
|
||||||
if (opts.parseBlockReferences) {
|
|
||||||
plugins.push(() => {
|
|
||||||
const inlineTagTypes = new Set(["p", "li"])
|
|
||||||
const blockTagTypes = new Set(["blockquote"])
|
|
||||||
return (tree: HtmlRoot, file) => {
|
|
||||||
file.data.blocks = {}
|
|
||||||
|
|
||||||
visit(tree, "element", (node, index, parent) => {
|
|
||||||
if (blockTagTypes.has(node.tagName)) {
|
|
||||||
const nextChild = parent?.children.at(index! + 2) as Element
|
|
||||||
if (nextChild && nextChild.tagName === "p") {
|
|
||||||
const text = nextChild.children.at(0) as Literal
|
|
||||||
if (text && text.value && text.type === "text") {
|
|
||||||
const matches = text.value.match(blockReferenceRegex)
|
|
||||||
if (matches && matches.length >= 1) {
|
|
||||||
parent!.children.splice(index! + 2, 1)
|
|
||||||
const block = matches[0].slice(1)
|
|
||||||
|
|
||||||
if (!Object.keys(file.data.blocks!).includes(block)) {
|
|
||||||
node.properties = {
|
|
||||||
...node.properties,
|
|
||||||
id: block,
|
|
||||||
}
|
|
||||||
file.data.blocks![block] = node
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (inlineTagTypes.has(node.tagName)) {
|
|
||||||
const last = node.children.at(-1) as Literal
|
|
||||||
if (last && last.value && typeof last.value === "string") {
|
|
||||||
const matches = last.value.match(blockReferenceRegex)
|
|
||||||
if (matches && matches.length >= 1) {
|
|
||||||
last.value = last.value.slice(0, -matches[0].length)
|
|
||||||
const block = matches[0].slice(1)
|
|
||||||
|
|
||||||
if (last.value === "") {
|
|
||||||
// this is an inline block ref but the actual block
|
|
||||||
// is the previous element above it
|
|
||||||
let idx = (index ?? 1) - 1
|
|
||||||
while (idx >= 0) {
|
|
||||||
const element = parent?.children.at(idx)
|
|
||||||
if (!element) break
|
|
||||||
if (element.type !== "element") {
|
|
||||||
idx -= 1
|
|
||||||
} else {
|
|
||||||
if (!Object.keys(file.data.blocks!).includes(block)) {
|
|
||||||
element.properties = {
|
|
||||||
...element.properties,
|
|
||||||
id: block,
|
|
||||||
}
|
|
||||||
file.data.blocks![block] = element
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// normal paragraph transclude
|
|
||||||
if (!Object.keys(file.data.blocks!).includes(block)) {
|
|
||||||
node.properties = {
|
|
||||||
...node.properties,
|
|
||||||
id: block,
|
|
||||||
}
|
|
||||||
file.data.blocks![block] = node
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
file.data.htmlAst = tree
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (opts.enableYouTubeEmbed) {
|
|
||||||
plugins.push(() => {
|
|
||||||
return (tree: HtmlRoot) => {
|
|
||||||
visit(tree, "element", (node) => {
|
|
||||||
if (node.tagName === "img" && typeof node.properties.src === "string") {
|
|
||||||
const match = node.properties.src.match(ytLinkRegex)
|
|
||||||
const videoId = match && match[2].length == 11 ? match[2] : null
|
|
||||||
const playlistId = node.properties.src.match(ytPlaylistLinkRegex)?.[1]
|
|
||||||
if (videoId) {
|
|
||||||
// YouTube video (with optional playlist)
|
|
||||||
node.tagName = "iframe"
|
|
||||||
node.properties = {
|
|
||||||
class: "external-embed youtube",
|
|
||||||
allow: "fullscreen",
|
|
||||||
frameborder: 0,
|
|
||||||
width: "600px",
|
|
||||||
src: playlistId
|
|
||||||
? `https://www.youtube.com/embed/${videoId}?list=${playlistId}`
|
|
||||||
: `https://www.youtube.com/embed/${videoId}`,
|
|
||||||
}
|
|
||||||
} else if (playlistId) {
|
|
||||||
// YouTube playlist only.
|
|
||||||
node.tagName = "iframe"
|
|
||||||
node.properties = {
|
|
||||||
class: "external-embed youtube",
|
|
||||||
allow: "fullscreen",
|
|
||||||
frameborder: 0,
|
|
||||||
width: "600px",
|
|
||||||
src: `https://www.youtube.com/embed/videoseries?list=${playlistId}`,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (opts.enableCheckbox) {
|
|
||||||
plugins.push(() => {
|
|
||||||
return (tree: HtmlRoot, _file) => {
|
|
||||||
visit(tree, "element", (node) => {
|
|
||||||
if (node.tagName === "input" && node.properties.type === "checkbox") {
|
|
||||||
const isChecked = node.properties?.checked ?? false
|
|
||||||
node.properties = {
|
|
||||||
type: "checkbox",
|
|
||||||
disabled: false,
|
|
||||||
checked: isChecked,
|
|
||||||
class: "checkbox-toggle",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (opts.mermaid) {
|
|
||||||
plugins.push(() => {
|
|
||||||
return (tree: HtmlRoot, _file) => {
|
|
||||||
visit(tree, "element", (node: Element, _idx, parent) => {
|
|
||||||
if (
|
|
||||||
node.tagName === "code" &&
|
|
||||||
((node.properties?.className ?? []) as string[])?.includes("mermaid")
|
|
||||||
) {
|
|
||||||
parent!.children = [
|
|
||||||
{
|
|
||||||
type: "element",
|
|
||||||
tagName: "button",
|
|
||||||
properties: {
|
|
||||||
className: ["expand-button"],
|
|
||||||
"aria-label": "Expand mermaid diagram",
|
|
||||||
"data-view-component": true,
|
|
||||||
},
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
type: "element",
|
|
||||||
tagName: "svg",
|
|
||||||
properties: {
|
|
||||||
width: 16,
|
|
||||||
height: 16,
|
|
||||||
viewBox: "0 0 16 16",
|
|
||||||
fill: "currentColor",
|
|
||||||
},
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
type: "element",
|
|
||||||
tagName: "path",
|
|
||||||
properties: {
|
|
||||||
fillRule: "evenodd",
|
|
||||||
d: "M3.72 3.72a.75.75 0 011.06 1.06L2.56 7h10.88l-2.22-2.22a.75.75 0 011.06-1.06l3.5 3.5a.75.75 0 010 1.06l-3.5 3.5a.75.75 0 11-1.06-1.06l2.22-2.22H2.56l2.22 2.22a.75.75 0 11-1.06 1.06l-3.5-3.5a.75.75 0 010-1.06l3.5-3.5z",
|
|
||||||
},
|
|
||||||
children: [],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
node,
|
|
||||||
{
|
|
||||||
type: "element",
|
|
||||||
tagName: "div",
|
|
||||||
properties: { id: "mermaid-container", role: "dialog" },
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
type: "element",
|
|
||||||
tagName: "div",
|
|
||||||
properties: { id: "mermaid-space" },
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
type: "element",
|
|
||||||
tagName: "div",
|
|
||||||
properties: { className: ["mermaid-content"] },
|
|
||||||
children: [],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
]
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return plugins
|
|
||||||
},
|
|
||||||
externalResources() {
|
|
||||||
const js: JSResource[] = []
|
|
||||||
const css: CSSResource[] = []
|
|
||||||
|
|
||||||
if (opts.enableCheckbox) {
|
|
||||||
js.push({
|
|
||||||
script: checkboxScript,
|
|
||||||
loadTime: "afterDOMReady",
|
|
||||||
contentType: "inline",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (opts.callouts) {
|
|
||||||
js.push({
|
|
||||||
script: calloutScript,
|
|
||||||
loadTime: "afterDOMReady",
|
|
||||||
contentType: "inline",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (opts.mermaid) {
|
|
||||||
js.push({
|
|
||||||
script: mermaidScript,
|
|
||||||
loadTime: "afterDOMReady",
|
|
||||||
contentType: "inline",
|
|
||||||
moduleType: "module",
|
|
||||||
})
|
|
||||||
|
|
||||||
css.push({
|
|
||||||
content: mermaidStyle,
|
|
||||||
inline: true,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return { js, css }
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
declare module "vfile" {
|
|
||||||
interface DataMap {
|
|
||||||
blocks: Record<string, Element>
|
|
||||||
htmlAst: HtmlRoot
|
|
||||||
hasMermaidDiagram: boolean | undefined
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,112 +0,0 @@
|
|||||||
import { QuartzTransformerPlugin } from "../types"
|
|
||||||
import rehypeRaw from "rehype-raw"
|
|
||||||
import { PluggableList } from "unified"
|
|
||||||
|
|
||||||
export interface Options {
|
|
||||||
/** Replace {{ relref }} with quartz wikilinks []() */
|
|
||||||
wikilinks: boolean
|
|
||||||
/** Remove pre-defined anchor (see https://ox-hugo.scripter.co/doc/anchors/) */
|
|
||||||
removePredefinedAnchor: boolean
|
|
||||||
/** Remove hugo shortcode syntax */
|
|
||||||
removeHugoShortcode: boolean
|
|
||||||
/** Replace <figure/> with ![]() */
|
|
||||||
replaceFigureWithMdImg: boolean
|
|
||||||
|
|
||||||
/** Replace org latex fragments with $ and $$ */
|
|
||||||
replaceOrgLatex: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
const defaultOptions: Options = {
|
|
||||||
wikilinks: true,
|
|
||||||
removePredefinedAnchor: true,
|
|
||||||
removeHugoShortcode: true,
|
|
||||||
replaceFigureWithMdImg: true,
|
|
||||||
replaceOrgLatex: true,
|
|
||||||
}
|
|
||||||
|
|
||||||
const relrefRegex = new RegExp(/\[([^\]]+)\]\(\{\{< relref "([^"]+)" >\}\}\)/, "g")
|
|
||||||
const predefinedHeadingIdRegex = new RegExp(/(.*) {#(?:.*)}/, "g")
|
|
||||||
const hugoShortcodeRegex = new RegExp(/{{(.*)}}/, "g")
|
|
||||||
const figureTagRegex = new RegExp(/< ?figure src="(.*)" ?>/, "g")
|
|
||||||
// \\\\\( -> matches \\(
|
|
||||||
// (.+?) -> Lazy match for capturing the equation
|
|
||||||
// \\\\\) -> matches \\)
|
|
||||||
const inlineLatexRegex = new RegExp(/\\\\\((.+?)\\\\\)/, "g")
|
|
||||||
// (?:\\begin{equation}|\\\\\(|\\\\\[) -> start of equation
|
|
||||||
// ([\s\S]*?) -> Matches the block equation
|
|
||||||
// (?:\\\\\]|\\\\\)|\\end{equation}) -> end of equation
|
|
||||||
const blockLatexRegex = new RegExp(
|
|
||||||
/(?:\\begin{equation}|\\\\\(|\\\\\[)([\s\S]*?)(?:\\\\\]|\\\\\)|\\end{equation})/,
|
|
||||||
"g",
|
|
||||||
)
|
|
||||||
// \$\$[\s\S]*?\$\$ -> Matches block equations
|
|
||||||
// \$.*?\$ -> Matches inline equations
|
|
||||||
const quartzLatexRegex = new RegExp(/\$\$[\s\S]*?\$\$|\$.*?\$/, "g")
|
|
||||||
|
|
||||||
/**
|
|
||||||
* ox-hugo is an org exporter backend that exports org files to hugo-compatible
|
|
||||||
* markdown in an opinionated way. This plugin adds some tweaks to the generated
|
|
||||||
* markdown to make it compatible with quartz but the list of changes applied it
|
|
||||||
* is not exhaustive.
|
|
||||||
* */
|
|
||||||
export const OxHugoFlavouredMarkdown: QuartzTransformerPlugin<Partial<Options>> = (userOpts) => {
|
|
||||||
const opts = { ...defaultOptions, ...userOpts }
|
|
||||||
return {
|
|
||||||
name: "OxHugoFlavouredMarkdown",
|
|
||||||
textTransform(_ctx, src) {
|
|
||||||
if (opts.wikilinks) {
|
|
||||||
src = src.toString()
|
|
||||||
src = src.replaceAll(relrefRegex, (_value, ...capture) => {
|
|
||||||
const [text, link] = capture
|
|
||||||
return `[${text}](${link})`
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (opts.removePredefinedAnchor) {
|
|
||||||
src = src.toString()
|
|
||||||
src = src.replaceAll(predefinedHeadingIdRegex, (_value, ...capture) => {
|
|
||||||
const [headingText] = capture
|
|
||||||
return headingText
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (opts.removeHugoShortcode) {
|
|
||||||
src = src.toString()
|
|
||||||
src = src.replaceAll(hugoShortcodeRegex, (_value, ...capture) => {
|
|
||||||
const [scContent] = capture
|
|
||||||
return scContent
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (opts.replaceFigureWithMdImg) {
|
|
||||||
src = src.toString()
|
|
||||||
src = src.replaceAll(figureTagRegex, (_value, ...capture) => {
|
|
||||||
const [src] = capture
|
|
||||||
return ``
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (opts.replaceOrgLatex) {
|
|
||||||
src = src.toString()
|
|
||||||
src = src.replaceAll(inlineLatexRegex, (_value, ...capture) => {
|
|
||||||
const [eqn] = capture
|
|
||||||
return `$${eqn}$`
|
|
||||||
})
|
|
||||||
src = src.replaceAll(blockLatexRegex, (_value, ...capture) => {
|
|
||||||
const [eqn] = capture
|
|
||||||
return `$$${eqn}$$`
|
|
||||||
})
|
|
||||||
|
|
||||||
// ox-hugo escapes _ as \_
|
|
||||||
src = src.replaceAll(quartzLatexRegex, (value) => {
|
|
||||||
return value.replaceAll("\\_", "_")
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return src
|
|
||||||
},
|
|
||||||
htmlPlugins() {
|
|
||||||
const plugins: PluggableList = [rehypeRaw]
|
|
||||||
return plugins
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,211 +0,0 @@
|
|||||||
import { QuartzTransformerPlugin } from "../types"
|
|
||||||
import { PluggableList } from "unified"
|
|
||||||
import { visit } from "unist-util-visit"
|
|
||||||
import { ReplaceFunction, findAndReplace as mdastFindReplace } from "mdast-util-find-and-replace"
|
|
||||||
import { Root, Html, Paragraph, Text, Link, Parent } from "mdast"
|
|
||||||
import { BuildVisitor } from "unist-util-visit"
|
|
||||||
|
|
||||||
export interface Options {
|
|
||||||
orComponent: boolean
|
|
||||||
TODOComponent: boolean
|
|
||||||
DONEComponent: boolean
|
|
||||||
videoComponent: boolean
|
|
||||||
audioComponent: boolean
|
|
||||||
pdfComponent: boolean
|
|
||||||
blockquoteComponent: boolean
|
|
||||||
tableComponent: boolean
|
|
||||||
attributeComponent: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
const defaultOptions: Options = {
|
|
||||||
orComponent: true,
|
|
||||||
TODOComponent: true,
|
|
||||||
DONEComponent: true,
|
|
||||||
videoComponent: true,
|
|
||||||
audioComponent: true,
|
|
||||||
pdfComponent: true,
|
|
||||||
blockquoteComponent: true,
|
|
||||||
tableComponent: true,
|
|
||||||
attributeComponent: true,
|
|
||||||
}
|
|
||||||
|
|
||||||
const orRegex = new RegExp(/{{or:(.*?)}}/, "g")
|
|
||||||
const TODORegex = new RegExp(/{{.*?\bTODO\b.*?}}/, "g")
|
|
||||||
const DONERegex = new RegExp(/{{.*?\bDONE\b.*?}}/, "g")
|
|
||||||
|
|
||||||
const blockquoteRegex = new RegExp(/(\[\[>\]\])\s*(.*)/, "g")
|
|
||||||
const roamHighlightRegex = new RegExp(/\^\^(.+)\^\^/, "g")
|
|
||||||
const roamItalicRegex = new RegExp(/__(.+)__/, "g")
|
|
||||||
|
|
||||||
function isSpecialEmbed(node: Paragraph): boolean {
|
|
||||||
if (node.children.length !== 2) return false
|
|
||||||
|
|
||||||
const [textNode, linkNode] = node.children
|
|
||||||
return (
|
|
||||||
textNode.type === "text" &&
|
|
||||||
textNode.value.startsWith("{{[[") &&
|
|
||||||
linkNode.type === "link" &&
|
|
||||||
linkNode.children[0].type === "text" &&
|
|
||||||
linkNode.children[0].value.endsWith("}}")
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function transformSpecialEmbed(node: Paragraph, opts: Options): Html | null {
|
|
||||||
const [textNode, linkNode] = node.children as [Text, Link]
|
|
||||||
const embedType = textNode.value.match(/\{\{\[\[(.*?)\]\]:/)?.[1]?.toLowerCase()
|
|
||||||
const url = linkNode.url.slice(0, -2) // Remove the trailing '}}'
|
|
||||||
|
|
||||||
switch (embedType) {
|
|
||||||
case "audio":
|
|
||||||
return opts.audioComponent
|
|
||||||
? {
|
|
||||||
type: "html",
|
|
||||||
value: `<audio controls>
|
|
||||||
<source src="${url}" type="audio/mpeg">
|
|
||||||
<source src="${url}" type="audio/ogg">
|
|
||||||
Your browser does not support the audio tag.
|
|
||||||
</audio>`,
|
|
||||||
}
|
|
||||||
: null
|
|
||||||
case "video":
|
|
||||||
if (!opts.videoComponent) return null
|
|
||||||
// Check if it's a YouTube video
|
|
||||||
const youtubeMatch = url.match(
|
|
||||||
/(?:https?:\/\/)?(?:www\.)?(?:youtube\.com|youtu\.be)\/(?:watch\?v=)?(.+)/,
|
|
||||||
)
|
|
||||||
if (youtubeMatch) {
|
|
||||||
const videoId = youtubeMatch[1].split("&")[0] // Remove additional parameters
|
|
||||||
const playlistMatch = url.match(/[?&]list=([^#\&\?]*)/)
|
|
||||||
const playlistId = playlistMatch ? playlistMatch[1] : null
|
|
||||||
|
|
||||||
return {
|
|
||||||
type: "html",
|
|
||||||
value: `<iframe
|
|
||||||
class="external-embed youtube"
|
|
||||||
width="600px"
|
|
||||||
height="350px"
|
|
||||||
src="https://www.youtube.com/embed/${videoId}${playlistId ? `?list=${playlistId}` : ""}"
|
|
||||||
frameborder="0"
|
|
||||||
allow="fullscreen"
|
|
||||||
></iframe>`,
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return {
|
|
||||||
type: "html",
|
|
||||||
value: `<video controls>
|
|
||||||
<source src="${url}" type="video/mp4">
|
|
||||||
<source src="${url}" type="video/webm">
|
|
||||||
Your browser does not support the video tag.
|
|
||||||
</video>`,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case "pdf":
|
|
||||||
return opts.pdfComponent
|
|
||||||
? {
|
|
||||||
type: "html",
|
|
||||||
value: `<embed src="${url}" type="application/pdf" width="100%" height="600px" />`,
|
|
||||||
}
|
|
||||||
: null
|
|
||||||
default:
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const RoamFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options> | undefined> = (
|
|
||||||
userOpts,
|
|
||||||
) => {
|
|
||||||
const opts = { ...defaultOptions, ...userOpts }
|
|
||||||
|
|
||||||
return {
|
|
||||||
name: "RoamFlavoredMarkdown",
|
|
||||||
markdownPlugins() {
|
|
||||||
const plugins: PluggableList = []
|
|
||||||
|
|
||||||
plugins.push(() => {
|
|
||||||
return (tree: Root) => {
|
|
||||||
const replacements: [RegExp, ReplaceFunction][] = []
|
|
||||||
|
|
||||||
// Handle special embeds (audio, video, PDF)
|
|
||||||
if (opts.audioComponent || opts.videoComponent || opts.pdfComponent) {
|
|
||||||
visit(tree, "paragraph", ((node: Paragraph, index: number, parent: Parent | null) => {
|
|
||||||
if (isSpecialEmbed(node)) {
|
|
||||||
const transformedNode = transformSpecialEmbed(node, opts)
|
|
||||||
if (transformedNode && parent) {
|
|
||||||
parent.children[index] = transformedNode
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}) as BuildVisitor<Root, "paragraph">)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Roam italic syntax
|
|
||||||
replacements.push([
|
|
||||||
roamItalicRegex,
|
|
||||||
(_value: string, match: string) => ({
|
|
||||||
type: "emphasis",
|
|
||||||
children: [{ type: "text", value: match }],
|
|
||||||
}),
|
|
||||||
])
|
|
||||||
|
|
||||||
// Roam highlight syntax
|
|
||||||
replacements.push([
|
|
||||||
roamHighlightRegex,
|
|
||||||
(_value: string, inner: string) => ({
|
|
||||||
type: "html",
|
|
||||||
value: `<span class="text-highlight">${inner}</span>`,
|
|
||||||
}),
|
|
||||||
])
|
|
||||||
|
|
||||||
if (opts.orComponent) {
|
|
||||||
replacements.push([
|
|
||||||
orRegex,
|
|
||||||
(match: string) => {
|
|
||||||
const matchResult = match.match(/{{or:(.*?)}}/)
|
|
||||||
if (matchResult === null) {
|
|
||||||
return { type: "html", value: "" }
|
|
||||||
}
|
|
||||||
const optionsString: string = matchResult[1]
|
|
||||||
const options: string[] = optionsString.split("|")
|
|
||||||
const selectHtml: string = `<select>${options.map((option: string) => `<option value="${option}">${option}</option>`).join("")}</select>`
|
|
||||||
return { type: "html", value: selectHtml }
|
|
||||||
},
|
|
||||||
])
|
|
||||||
}
|
|
||||||
|
|
||||||
if (opts.TODOComponent) {
|
|
||||||
replacements.push([
|
|
||||||
TODORegex,
|
|
||||||
() => ({
|
|
||||||
type: "html",
|
|
||||||
value: `<input type="checkbox" disabled>`,
|
|
||||||
}),
|
|
||||||
])
|
|
||||||
}
|
|
||||||
|
|
||||||
if (opts.DONEComponent) {
|
|
||||||
replacements.push([
|
|
||||||
DONERegex,
|
|
||||||
() => ({
|
|
||||||
type: "html",
|
|
||||||
value: `<input type="checkbox" checked disabled>`,
|
|
||||||
}),
|
|
||||||
])
|
|
||||||
}
|
|
||||||
|
|
||||||
if (opts.blockquoteComponent) {
|
|
||||||
replacements.push([
|
|
||||||
blockquoteRegex,
|
|
||||||
(_match: string, _marker: string, content: string) => ({
|
|
||||||
type: "html",
|
|
||||||
value: `<blockquote>${content.trim()}</blockquote>`,
|
|
||||||
}),
|
|
||||||
])
|
|
||||||
}
|
|
||||||
|
|
||||||
mdastFindReplace(tree, replacements)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return plugins
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,31 +0,0 @@
|
|||||||
import { QuartzTransformerPlugin } from "../types"
|
|
||||||
import rehypePrettyCode, { Options as CodeOptions, Theme as CodeTheme } from "rehype-pretty-code"
|
|
||||||
|
|
||||||
interface Theme extends Record<string, CodeTheme> {
|
|
||||||
light: CodeTheme
|
|
||||||
dark: CodeTheme
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Options {
|
|
||||||
theme?: Theme
|
|
||||||
keepBackground?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
const defaultOptions: Options = {
|
|
||||||
theme: {
|
|
||||||
light: "github-light",
|
|
||||||
dark: "github-dark",
|
|
||||||
},
|
|
||||||
keepBackground: false,
|
|
||||||
}
|
|
||||||
|
|
||||||
export const SyntaxHighlighting: QuartzTransformerPlugin<Partial<Options>> = (userOpts) => {
|
|
||||||
const opts: CodeOptions = { ...defaultOptions, ...userOpts }
|
|
||||||
|
|
||||||
return {
|
|
||||||
name: "SyntaxHighlighting",
|
|
||||||
htmlPlugins() {
|
|
||||||
return [[rehypePrettyCode, opts]]
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,73 +0,0 @@
|
|||||||
import { QuartzTransformerPlugin } from "../types"
|
|
||||||
import { Root } from "mdast"
|
|
||||||
import { visit } from "unist-util-visit"
|
|
||||||
import { toString } from "mdast-util-to-string"
|
|
||||||
import Slugger from "github-slugger"
|
|
||||||
|
|
||||||
export interface Options {
|
|
||||||
maxDepth: 1 | 2 | 3 | 4 | 5 | 6
|
|
||||||
minEntries: number
|
|
||||||
showByDefault: boolean
|
|
||||||
collapseByDefault: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
const defaultOptions: Options = {
|
|
||||||
maxDepth: 3,
|
|
||||||
minEntries: 1,
|
|
||||||
showByDefault: true,
|
|
||||||
collapseByDefault: false,
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TocEntry {
|
|
||||||
depth: number
|
|
||||||
text: string
|
|
||||||
slug: string // this is just the anchor (#some-slug), not the canonical slug
|
|
||||||
}
|
|
||||||
|
|
||||||
const slugAnchor = new Slugger()
|
|
||||||
export const TableOfContents: QuartzTransformerPlugin<Partial<Options>> = (userOpts) => {
|
|
||||||
const opts = { ...defaultOptions, ...userOpts }
|
|
||||||
return {
|
|
||||||
name: "TableOfContents",
|
|
||||||
markdownPlugins() {
|
|
||||||
return [
|
|
||||||
() => {
|
|
||||||
return async (tree: Root, file) => {
|
|
||||||
const display = file.data.frontmatter?.enableToc ?? opts.showByDefault
|
|
||||||
if (display) {
|
|
||||||
slugAnchor.reset()
|
|
||||||
const toc: TocEntry[] = []
|
|
||||||
let highestDepth: number = opts.maxDepth
|
|
||||||
visit(tree, "heading", (node) => {
|
|
||||||
if (node.depth <= opts.maxDepth) {
|
|
||||||
const text = toString(node)
|
|
||||||
highestDepth = Math.min(highestDepth, node.depth)
|
|
||||||
toc.push({
|
|
||||||
depth: node.depth,
|
|
||||||
text,
|
|
||||||
slug: slugAnchor.slug(text),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
if (toc.length > 0 && toc.length > opts.minEntries) {
|
|
||||||
file.data.toc = toc.map((entry) => ({
|
|
||||||
...entry,
|
|
||||||
depth: entry.depth - highestDepth,
|
|
||||||
}))
|
|
||||||
file.data.collapseToc = opts.collapseByDefault
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
]
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
declare module "vfile" {
|
|
||||||
interface DataMap {
|
|
||||||
toc: TocEntry[]
|
|
||||||
collapseToc: boolean
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -7,17 +7,17 @@ import { clone } from "./clone"
|
|||||||
export const QUARTZ = "quartz"
|
export const QUARTZ = "quartz"
|
||||||
|
|
||||||
/// Utility type to simulate nominal types in TypeScript
|
/// Utility type to simulate nominal types in TypeScript
|
||||||
type SlugLike<T> = string & { __brand: T }
|
type SlugLike<T extends string> = string & { _brand: T }
|
||||||
|
|
||||||
/** Cannot be relative and must have a file extension. */
|
/** Cannot be relative and must have a file extension. */
|
||||||
export type FilePath = SlugLike<"filepath">
|
export type FilePath = SlugLike<"FilePath">
|
||||||
export function isFilePath(s: string): s is FilePath {
|
export function isFilePath(s: string): s is FilePath {
|
||||||
const validStart = !s.startsWith(".")
|
const validStart = !s.startsWith(".")
|
||||||
return validStart && _hasFileExtension(s)
|
return validStart && _hasFileExtension(s)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Cannot be relative and may not have leading or trailing slashes. It can have `index` as it's last segment. Use this wherever possible is it's the most 'general' interpretation of a slug. */
|
/** Cannot be relative and may not have leading or trailing slashes. It can have `index` as it's last segment. Use this wherever possible is it's the most 'general' interpretation of a slug. */
|
||||||
export type FullSlug = SlugLike<"full">
|
export type FullSlug = SlugLike<"FullSlug">
|
||||||
export function isFullSlug(s: string): s is FullSlug {
|
export function isFullSlug(s: string): s is FullSlug {
|
||||||
const validStart = !(s.startsWith(".") || s.startsWith("/"))
|
const validStart = !(s.startsWith(".") || s.startsWith("/"))
|
||||||
const validEnding = !s.endsWith("/")
|
const validEnding = !s.endsWith("/")
|
||||||
@ -25,7 +25,7 @@ export function isFullSlug(s: string): s is FullSlug {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Shouldn't be a relative path and shouldn't have `/index` as an ending or a file extension. It _can_ however have a trailing slash to indicate a folder path. */
|
/** Shouldn't be a relative path and shouldn't have `/index` as an ending or a file extension. It _can_ however have a trailing slash to indicate a folder path. */
|
||||||
export type SimpleSlug = SlugLike<"simple">
|
export type SimpleSlug = SlugLike<"SimpleSlug">
|
||||||
export function isSimpleSlug(s: string): s is SimpleSlug {
|
export function isSimpleSlug(s: string): s is SimpleSlug {
|
||||||
const validStart = !(s.startsWith(".") || (s.length > 1 && s.startsWith("/")))
|
const validStart = !(s.startsWith(".") || (s.length > 1 && s.startsWith("/")))
|
||||||
const validEnding = !endsWith(s, "index")
|
const validEnding = !endsWith(s, "index")
|
||||||
@ -33,7 +33,7 @@ export function isSimpleSlug(s: string): s is SimpleSlug {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Can be found on `href`s but can also be constructed for client-side navigation (e.g. search and graph) */
|
/** Can be found on `href`s but can also be constructed for client-side navigation (e.g. search and graph) */
|
||||||
export type RelativeURL = SlugLike<"relative">
|
export type RelativeURL = SlugLike<"RelativeURL">
|
||||||
export function isRelativeURL(s: string): s is RelativeURL {
|
export function isRelativeURL(s: string): s is RelativeURL {
|
||||||
const validStart = /^\.{1,2}/.test(s)
|
const validStart = /^\.{1,2}/.test(s)
|
||||||
const validEnding = !endsWith(s, "index")
|
const validEnding = !endsWith(s, "index")
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user