diff --git a/docs/features/Obsidian compatibility.md b/docs/features/Obsidian compatibility.md index e469f4866..d8722beb2 100644 --- a/docs/features/Obsidian compatibility.md +++ b/docs/features/Obsidian compatibility.md @@ -10,8 +10,10 @@ By default, Quartz ships with the [[ObsidianFlavoredMarkdown]] plugin, which is It also ships with support for [frontmatter parsing](https://help.obsidian.md/Editing+and+formatting/Properties) with the same fields that Obsidian uses through the [[Frontmatter]] transformer plugin. -Finally, Quartz also provides [[CrawlLinks]] plugin, which allows you to customize Quartz's link resolution behaviour to match Obsidian. +Quartz also provides [[CrawlLinks]] plugin, which allows you to customize Quartz's link resolution behaviour to match Obsidian. + +For dynamic database-like views, Quartz supports [[bases|Obsidian Bases]] through the [[ObsidianBases]] transformer and [[BasePage]] emitter plugins. ## Configuration -This functionality is provided by the [[ObsidianFlavoredMarkdown]], [[Frontmatter]] and [[CrawlLinks]] plugins. See the plugin pages for customization options. +This functionality is provided by the [[ObsidianFlavoredMarkdown]], [[ObsidianBases]], [[Frontmatter]] and [[CrawlLinks]] plugins. See the plugin pages for customization options. diff --git a/docs/features/bases.md b/docs/features/bases.md new file mode 100644 index 000000000..1acbf2b0c --- /dev/null +++ b/docs/features/bases.md @@ -0,0 +1,42 @@ +--- +title: Bases +tags: + - feature/transformer + - feature/emitter +--- + +Quartz supports [Obsidian Bases](https://help.obsidian.md/bases), which allow you to create dynamic, database-like views of your notes. See the [official Obsidian documentation](https://help.obsidian.md/bases/syntax) for the full syntax reference. + +## Quick Example + +Create a `.base` file in your content folder: + +```yaml +filters: + and: + - file.hasTag("task") + +views: + - type: table + name: "Task List" + order: + - file.name + - status + - due_date +``` + +Each view gets its own page at `/`. + +## Wikilinks + +Link to base views using the standard [[Navigation.base#Plugins|wikilink]] syntax: + +```markdown +[[my-base.base#Task List]] +``` + +This resolves to `my-base/Task-List`. + +## Configuration + +This functionality is provided by the [[ObsidianBases]] transformer plugin (which parses `.base` files) and the [[BasePage]] emitter plugin (which generates the pages). diff --git a/docs/navigation.base b/docs/navigation.base new file mode 100644 index 000000000..f085979ec --- /dev/null +++ b/docs/navigation.base @@ -0,0 +1,93 @@ +filters: + and: + - file.ext == "md" +formulas: + doc_type: | + if(file.hasTag("plugin/transformer"), "transformer", + if(file.hasTag("plugin/emitter"), "emitter", + if(file.hasTag("plugin/filter"), "filter", + if(file.hasTag("component"), "component", + if(file.inFolder("features"), "feature", + if(file.inFolder("advanced"), "advanced", + if(file.inFolder("plugins"), "plugin", "guide"))))))) + last_modified: file.mtime.relative() + section: | + if(file.inFolder("plugins"), "plugins", + if(file.inFolder("features"), "features", + if(file.inFolder("advanced"), "advanced", + if(file.inFolder("tags"), "tags", "core")))) +properties: + title: + displayName: Title + formula.doc_type: + displayName: Type + formula.last_modified: + displayName: Updated + formula.section: + displayName: Section +views: + - type: table + name: All Documentation + groupBy: + property: formula.section + direction: ASC + order: + - file.name + - title + - formula.doc_type + - formula.section + - formula.last_modified + sort: + - property: formula.doc_type + direction: ASC + - property: file.name + direction: ASC + columnSize: + file.name: 185 + note.title: 268 + formula.doc_type: 146 + formula.section: 276 + - type: table + name: Plugins + filters: + or: + - file.hasTag("plugin/transformer") + - file.hasTag("plugin/emitter") + - file.hasTag("plugin/filter") + groupBy: + property: formula.doc_type + direction: ASC + order: + - file.name + - title + - formula.doc_type + - formula.last_modified + - type: table + name: Components & Features + filters: + or: + - file.hasTag("component") + - file.inFolder("features") + order: + - file.name + - title + - formula.doc_type + - formula.last_modified + - type: list + name: Recently Updated + order: + - file.name + - formula.last_modified + limit: 15 + - type: table + name: Core Guides + filters: + not: + - file.inFolder("plugins") + - file.inFolder("features") + - file.inFolder("advanced") + - file.inFolder("tags") + order: + - file.name + - title + - formula.last_modified diff --git a/docs/plugins/BasePage.md b/docs/plugins/BasePage.md new file mode 100644 index 000000000..7576390fb --- /dev/null +++ b/docs/plugins/BasePage.md @@ -0,0 +1,18 @@ +--- +title: BasePage +tags: + - plugin/emitter +--- + +This plugin emits pages for each view defined in `.base` files. See [[bases]] for usage. + +> [!note] +> For information on how to add, remove or configure plugins, see the [[configuration#Plugins|Configuration]] page. + +Pages use `defaultListPageLayout` from `quartz.layout.ts` with `BaseContent` as the page body. To customize the layout, edit `quartz/components/pages/BaseContent.tsx`. + +## API + +- Category: Emitter +- Function name: `Plugin.BasePage()`. +- Source: [`quartz/plugins/emitters/basePage.tsx`](https://github.com/jackyzha0/quartz/blob/v4/quartz/plugins/emitters/basePage.tsx). diff --git a/docs/plugins/ObsidianBases.md b/docs/plugins/ObsidianBases.md new file mode 100644 index 000000000..ffa71629d --- /dev/null +++ b/docs/plugins/ObsidianBases.md @@ -0,0 +1,20 @@ +--- +title: ObsidianBases +tags: + - plugin/transformer +--- + +This plugin parses `.base` files and compiles them for rendering. See [[bases]] for usage. + +> [!note] +> For information on how to add, remove or configure plugins, see the [[configuration#Plugins|Configuration]] page. + +## Configuration + +- `emitWarnings`: If `true` (default), emits parse errors and type mismatches as warnings during build. + +## API + +- Category: Transformer +- Function name: `Plugin.ObsidianBases()`. +- Source: [`quartz/plugins/transformers/bases.ts`](https://github.com/jackyzha0/quartz/blob/v4/quartz/plugins/transformers/bases.ts). diff --git a/quartz.config.ts b/quartz.config.ts index b3db3d60d..5531b4e0f 100644 --- a/quartz.config.ts +++ b/quartz.config.ts @@ -72,6 +72,7 @@ const config: QuartzConfig = { Plugin.CrawlLinks({ markdownLinkResolution: "shortest" }), Plugin.Description(), Plugin.Latex({ renderEngine: "katex" }), + Plugin.ObsidianBases(), ], filters: [Plugin.RemoveDrafts()], emitters: [ @@ -90,6 +91,7 @@ const config: QuartzConfig = { Plugin.NotFoundPage(), // Comment out CustomOgImages to speed up build time Plugin.CustomOgImages(), + Plugin.BasePage(), ], }, } diff --git a/quartz/build.ts b/quartz/build.ts index b98f4a8a0..d4bcf742b 100644 --- a/quartz/build.ts +++ b/quartz/build.ts @@ -72,7 +72,7 @@ async function buildQuartz(argv: Argv, mut: Mutex, clientRefresh: () => void) { perf.addEvent("glob") const allFiles = await glob("**/*.*", argv.directory, cfg.configuration.ignorePatterns) - const markdownPaths = allFiles.filter((fp) => fp.endsWith(".md")).sort() + const markdownPaths = allFiles.filter((fp) => fp.endsWith(".md") || fp.endsWith(".base")).sort() console.log( `Found ${markdownPaths.length} input files from \`${argv.directory}\` in ${perf.timeSince("glob")}`, ) diff --git a/quartz/components/BaseViewSelector.tsx b/quartz/components/BaseViewSelector.tsx new file mode 100644 index 000000000..ab1b11ee3 --- /dev/null +++ b/quartz/components/BaseViewSelector.tsx @@ -0,0 +1,218 @@ +import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types" +import { classNames } from "../util/lang" +import { resolveRelative } from "../util/path" +// @ts-ignore +import script from "./scripts/base-view-selector.inline" +import baseViewSelectorStyle from "./styles/baseViewSelector.scss" + +const icons = { + table: ( + + + + + + + + ), + list: ( + + + + + + + + + ), + chevronsUpDown: ( + + + + + ), + chevronRight: ( + + + + ), + x: ( + + + + + ), + map: ( + + + + ), + card: ( + + + + + + + ), +} + +const viewTypeIcons: Record = { + table: icons.table, + list: icons.list, + gallery: icons.card, + board: icons.table, + calendar: icons.table, + map: icons.map, + cards: icons.card, +} + +const BaseViewSelector: QuartzComponent = ({ fileData, displayClass }: QuartzComponentProps) => { + const baseMeta = fileData.basesMetadata + + if (!baseMeta || baseMeta.allViews.length <= 1) { + return null + } + + const currentViewName = baseMeta.currentView + const allViews = baseMeta.allViews + const currentIcon = + viewTypeIcons[allViews.find((view) => view.name === currentViewName)?.type ?? ""] ?? + icons.table + + return ( +
+
+ +
+ + +
+ ) +} + +BaseViewSelector.css = baseViewSelectorStyle +BaseViewSelector.afterDOMLoaded = script + +export default (() => BaseViewSelector) satisfies QuartzComponentConstructor diff --git a/quartz/components/Breadcrumbs.tsx b/quartz/components/Breadcrumbs.tsx index 5144a314d..f5523fcb5 100644 --- a/quartz/components/Breadcrumbs.tsx +++ b/quartz/components/Breadcrumbs.tsx @@ -51,7 +51,9 @@ export default ((opts?: Partial) => { ctx, }: QuartzComponentProps) => { const trie = (ctx.trie ??= trieFromAllFiles(allFiles)) - const slugParts = fileData.slug!.split("/") + const baseMeta = fileData.basesMetadata + + const slugParts = (baseMeta ? baseMeta.baseSlug : fileData.slug!).split("/") const pathNodes = trie.ancestryChain(slugParts) if (!pathNodes) { @@ -64,14 +66,24 @@ export default ((opts?: Partial) => { crumb.displayName = options.rootName } - // For last node (current page), set empty path if (idx === pathNodes.length - 1) { - crumb.path = "" + if (baseMeta) { + crumb.path = resolveRelative(fileData.slug!, simplifySlug(baseMeta.baseSlug)) + } else { + crumb.path = "" + } } return crumb }) + if (baseMeta && options.showCurrentPage) { + crumbs.push({ + displayName: baseMeta.currentView.replaceAll("-", " "), + path: "", + }) + } + if (!options.showCurrentPage) { crumbs.pop() } diff --git a/quartz/components/index.ts b/quartz/components/index.ts index cece8e614..2d27bb0b6 100644 --- a/quartz/components/index.ts +++ b/quartz/components/index.ts @@ -1,6 +1,8 @@ import Content from "./pages/Content" import TagContent from "./pages/TagContent" import FolderContent from "./pages/FolderContent" +import BaseContent from "./pages/BaseContent" +import BaseViewSelector from "./BaseViewSelector" import NotFound from "./pages/404" import ArticleTitle from "./ArticleTitle" import Darkmode from "./Darkmode" @@ -29,6 +31,8 @@ export { Content, TagContent, FolderContent, + BaseContent, + BaseViewSelector, Darkmode, ReaderMode, Head, diff --git a/quartz/components/pages/BaseContent.tsx b/quartz/components/pages/BaseContent.tsx new file mode 100644 index 000000000..d42687732 --- /dev/null +++ b/quartz/components/pages/BaseContent.tsx @@ -0,0 +1,22 @@ +import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "../types" +import style from "../styles/basePage.scss" +import { htmlToJsx } from "../../util/jsx" + +export default (() => { + const BaseContent: QuartzComponent = (props: QuartzComponentProps) => { + const { fileData, tree } = props + + return ( +
+
+ {htmlToJsx(fileData.filePath!, fileData.basesRenderedTree ?? tree)} +
+
+ ) + } + + BaseContent.css = style + return BaseContent +}) satisfies QuartzComponentConstructor diff --git a/quartz/components/scripts/base-view-selector.inline.ts b/quartz/components/scripts/base-view-selector.inline.ts new file mode 100644 index 000000000..eddeb57c6 --- /dev/null +++ b/quartz/components/scripts/base-view-selector.inline.ts @@ -0,0 +1,139 @@ +let documentClickHandler: ((e: MouseEvent) => void) | null = null + +function setupBaseViewSelector() { + const selectors = document.querySelectorAll("[data-base-view-selector]") + + if (selectors.length === 0) return + + if (!documentClickHandler) { + documentClickHandler = (e: MouseEvent) => { + document.querySelectorAll("[data-base-view-selector]").forEach((selector) => { + if (selector.contains(e.target as Node)) return + const trigger = selector.querySelector(".text-icon-button") as HTMLElement | null + if (trigger?.getAttribute("aria-expanded") === "true") { + selector.dispatchEvent(new CustomEvent("close-dropdown")) + } + }) + } + document.addEventListener("click", documentClickHandler) + window.addCleanup(() => { + if (documentClickHandler) { + document.removeEventListener("click", documentClickHandler) + documentClickHandler = null + } + }) + } + + selectors.forEach((selector) => { + if (selector.hasAttribute("data-initialized")) return + selector.setAttribute("data-initialized", "true") + + const trigger = selector.querySelector(".text-icon-button") as HTMLElement | null + const searchInput = selector.querySelector("[data-search-input]") as HTMLInputElement | null + const clearButton = selector.querySelector("[data-clear-search]") as HTMLElement | null + const viewList = selector.querySelector("[data-view-list]") as HTMLElement | null + + if (!trigger || !searchInput || !clearButton || !viewList) return + + function toggleDropdown() { + if (trigger.getAttribute("aria-expanded") === "true") { + closeDropdown() + return + } + openDropdown() + } + + function openDropdown() { + trigger.setAttribute("aria-expanded", "true") + trigger.classList.add("has-active-menu") + setTimeout(() => searchInput.focus(), 10) + } + + function closeDropdown() { + trigger.setAttribute("aria-expanded", "false") + trigger.classList.remove("has-active-menu") + searchInput.value = "" + clearButton.hidden = true + filterViews("") + } + + function filterViews(query: string) { + const items = viewList.querySelectorAll(".bases-toolbar-menu-item") + const lowerQuery = query.toLowerCase() + + items.forEach((item) => { + const viewName = (item.getAttribute("data-view-name") || "").toLowerCase() + const viewType = (item.getAttribute("data-view-type") || "").toLowerCase() + const matches = viewName.includes(lowerQuery) || viewType.includes(lowerQuery) + item.style.display = matches ? "" : "none" + }) + } + + function handleSearchInput() { + const query = searchInput.value + filterViews(query) + clearButton.hidden = query.length === 0 + } + + function clearSearch() { + searchInput.value = "" + clearButton.hidden = true + filterViews("") + searchInput.focus() + } + + const handleTriggerClick = (e: MouseEvent) => { + e.stopPropagation() + toggleDropdown() + } + + const handleTriggerKeydown = (e: KeyboardEvent) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault() + toggleDropdown() + } + } + + const handleSearchKeydown = (e: KeyboardEvent) => { + if (e.key === "Escape") { + if (searchInput.value) { + clearSearch() + } else { + closeDropdown() + } + } + } + + const handleClearClick = (e: MouseEvent) => { + e.stopPropagation() + clearSearch() + } + + trigger.addEventListener("click", handleTriggerClick) + trigger.addEventListener("keydown", handleTriggerKeydown) + searchInput.addEventListener("input", handleSearchInput) + searchInput.addEventListener("keydown", handleSearchKeydown) + clearButton.addEventListener("click", handleClearClick) + + const viewLinks = viewList.querySelectorAll(".bases-toolbar-menu-item") + viewLinks.forEach((link) => { + link.addEventListener("click", closeDropdown) + window.addCleanup(() => link.removeEventListener("click", closeDropdown)) + }) + + selector.addEventListener("close-dropdown", closeDropdown) + + window.addCleanup(() => { + trigger.removeEventListener("click", handleTriggerClick) + trigger.removeEventListener("keydown", handleTriggerKeydown) + searchInput.removeEventListener("input", handleSearchInput) + searchInput.removeEventListener("keydown", handleSearchKeydown) + clearButton.removeEventListener("click", handleClearClick) + selector.removeEventListener("close-dropdown", closeDropdown) + selector.removeAttribute("data-initialized") + closeDropdown() + }) + }) +} + +document.addEventListener("nav", setupBaseViewSelector) diff --git a/quartz/components/styles/basePage.scss b/quartz/components/styles/basePage.scss new file mode 100644 index 000000000..f9a00ce28 --- /dev/null +++ b/quartz/components/styles/basePage.scss @@ -0,0 +1,299 @@ +.base-content { + width: 100%; +} + +.base-view { + width: 100%; + overflow-x: auto; +} + +.base-table { + width: 100%; + border-collapse: collapse; + font-size: 0.875rem; + + th, + td { + padding: 0.5rem 0.75rem; + text-align: left; + border-bottom: 1px solid var(--lightgray); + } + + th { + font-weight: 600; + color: var(--darkgray); + background: var(--light); + position: sticky; + top: 0; + } + + tbody tr:hover { + background: var(--light); + } + + a.internal { + color: var(--secondary); + text-decoration: none; + + &:hover { + text-decoration: underline; + } + } +} + +.base-group-header td { + font-weight: 600; + background: var(--light); + color: var(--dark); + padding-top: 1rem; +} + +.base-summary-row { + background: var(--light); + font-weight: 500; + + .base-summary-cell { + border-top: 2px solid var(--lightgray); + color: var(--darkgray); + } +} + +.base-checkbox { + pointer-events: none; + width: 1rem; + height: 1rem; + accent-color: var(--secondary); +} + +.base-list { + list-style: none; + padding: 0; + margin: 0; + + li { + padding: 0.375rem 0; + border-bottom: 1px solid var(--lightgray); + + &:last-child { + border-bottom: none; + } + } + + a.internal { + color: var(--secondary); + text-decoration: none; + + &:hover { + text-decoration: underline; + } + } +} + +.base-list-container { + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +.base-list-group { + .base-list-group-header { + font-size: 1rem; + font-weight: 600; + margin-bottom: 0.5rem; + color: var(--dark); + } +} + +.base-list-nested { + list-style: none; + padding-left: 1rem; + margin-top: 0.25rem; + font-size: 0.8125rem; + color: var(--darkgray); +} + +.base-list-meta-label { + font-weight: 500; +} + +.base-card-grid { + --base-card-min: 200px; + --base-card-aspect: 1.4; + + display: grid; + grid-template-columns: repeat(auto-fill, minmax(var(--base-card-min), 1fr)); + gap: 1rem; +} + +.base-card-container { + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +.base-card-group { + .base-card-group-header { + font-size: 1rem; + font-weight: 600; + margin-bottom: 0.75rem; + color: var(--dark); + } +} + +.base-card { + display: flex; + flex-direction: column; + border: 1px solid var(--lightgray); + border-radius: 8px; + overflow: hidden; + background: var(--light); + transition: box-shadow 0.15s ease; + + &:hover { + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); + } +} + +.base-card-image-link { + display: block; + aspect-ratio: var(--base-card-aspect); + background-position: center; + background-repeat: no-repeat; +} + +.base-card-content { + padding: 0.75rem; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.base-card-title-link { + text-decoration: none; + color: inherit; + + &:hover .base-card-title { + color: var(--secondary); + } +} + +.base-card-title { + font-size: 0.9375rem; + font-weight: 600; + margin: 0; + line-height: 1.3; + transition: color 0.15s ease; +} + +.base-card-meta { + display: flex; + flex-direction: column; + gap: 0.25rem; + font-size: 0.8125rem; + color: var(--darkgray); +} + +.base-card-meta-item { + display: flex; + gap: 0.25rem; +} + +.base-card-meta-label { + font-weight: 500; + + &::after { + content: ":"; + } +} + +.base-calendar-container { + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +.base-calendar-group { + .base-calendar-group-header { + font-size: 0.9375rem; + font-weight: 600; + margin-bottom: 0.5rem; + color: var(--dark); + font-variant-numeric: tabular-nums; + } +} + +.base-map { + width: 100%; + min-height: 400px; + background: var(--light); + border: 1px solid var(--lightgray); + border-radius: 8px; + display: flex; + align-items: center; + justify-content: center; + color: var(--darkgray); + + &::before { + content: "Map view requires client-side JavaScript"; + font-size: 0.875rem; + } +} + +.base-diagnostics { + background: #fff3cd; + border: 1px solid #ffc107; + border-radius: 4px; + padding: 1rem; + margin-bottom: 1rem; + font-size: 0.875rem; +} + +.base-diagnostics-title { + font-weight: 600; + margin-bottom: 0.5rem; + color: #856404; +} + +.base-diagnostics-meta { + display: flex; + gap: 0.5rem; + margin-bottom: 0.75rem; + color: #856404; +} + +.base-diagnostics-page { + font-family: var(--codeFont); +} + +.base-diagnostics-list { + list-style: none; + padding: 0; + margin: 0; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.base-diagnostics-item { + background: white; + padding: 0.5rem; + border-radius: 4px; +} + +.base-diagnostics-label { + font-weight: 500; + color: #856404; +} + +.base-diagnostics-message { + color: #664d03; + margin: 0.25rem 0; +} + +.base-diagnostics-source { + display: block; + font-size: 0.8125rem; + color: #6c757d; + white-space: pre-wrap; + word-break: break-all; +} diff --git a/quartz/components/styles/baseViewSelector.scss b/quartz/components/styles/baseViewSelector.scss new file mode 100644 index 000000000..2cb0ee265 --- /dev/null +++ b/quartz/components/styles/baseViewSelector.scss @@ -0,0 +1,273 @@ +@use "../../styles/variables.scss" as *; + +.bases-toolbar { + position: relative; + display: inline-block; + margin: 1rem 0; + font-family: var(--bodyFont); + + .bases-toolbar-item { + display: inline-block; + position: relative; + + &.bases-toolbar-views-menu { + .text-icon-button { + display: flex; + align-items: center; + gap: 0.375rem; + padding: 0.375rem 0.75rem; + background: var(--light); + border: 1px solid var(--lightgray); + border-radius: 6px; + color: var(--darkgray); + font-size: 0.875rem; + font-weight: 500; + cursor: pointer; + transition: all 0.15s ease; + user-select: none; + + &:hover { + background: var(--highlight); + border-color: var(--gray); + } + + &.has-active-menu { + border-color: var(--secondary); + background: var(--highlight); + } + + .text-button-icon { + display: flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + color: var(--gray); + flex-shrink: 0; + + svg { + width: 16px; + height: 16px; + } + + &.mod-aux { + opacity: 0.7; + } + } + + .text-button-label { + font-size: 0.875rem; + color: var(--dark); + font-weight: 500; + } + } + } + } + + .menu-scroll { + position: absolute; + top: calc(100% + 0.5rem); + left: 0; + z-index: 100; + max-height: 400px; + background: var(--light); + border: 1px solid var(--lightgray); + border-radius: 8px; + box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1); + overflow: hidden; + min-width: 280px; + display: none; + } + + &:has(.text-icon-button.has-active-menu) .menu-scroll { + display: block; + } + + .bases-toolbar-menu-container { + display: flex; + flex-direction: column; + max-height: 400px; + + .search-input-container { + position: relative; + padding: 0.5rem; + border-bottom: 1px solid var(--lightgray); + + input[type="search"] { + width: 100%; + padding: 0.375rem 0.75rem; + padding-right: 2rem; + background: var(--light); + border: 1px solid var(--secondary); + border-radius: 6px; + font-size: 0.875rem; + color: var(--dark); + outline: none; + transition: box-shadow 0.15s ease; + font-family: var(--bodyFont); + + &::placeholder { + color: var(--gray); + opacity: 0.7; + } + + &:focus { + box-shadow: 0 0 0 2px var(--highlight); + } + + &::-webkit-search-cancel-button { + display: none; + } + } + + .search-input-clear-button { + position: absolute; + right: 1rem; + top: 50%; + transform: translateY(-50%); + display: flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + cursor: pointer; + opacity: 0.5; + transition: opacity 0.15s ease; + color: var(--gray); + + &:hover { + opacity: 1; + } + + &[hidden] { + display: none; + } + + svg { + width: 14px; + height: 14px; + } + } + } + + .bases-toolbar-items { + overflow-y: auto; + max-height: 340px; + + &::-webkit-scrollbar { + width: 8px; + } + + &::-webkit-scrollbar-track { + background: transparent; + } + + &::-webkit-scrollbar-thumb { + background: var(--lightgray); + border-radius: 4px; + + &:hover { + background: var(--gray); + } + } + + .suggestion-group { + &[data-group="views"] { + padding: 0.25rem 0; + text-transform: lowercase; + } + } + + .suggestion-item { + display: block; + text-decoration: none; + color: inherit; + cursor: pointer; + + &.bases-toolbar-menu-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.5rem 0.75rem; + margin: 0 0.25rem; + border-radius: 4px; + transition: background 0.15s ease; + + &:hover { + background: var(--lightgray); + } + + &.mod-active { + font-weight: $semiBoldWeight; + } + + &.is-selected { + .bases-toolbar-menu-item-info { + .bases-toolbar-menu-item-name { + font-weight: 600; + color: var(--secondary); + } + } + } + + .bases-toolbar-menu-item-info { + display: flex; + align-items: center; + gap: 0.5rem; + flex: 1; + + .bases-toolbar-menu-item-info-icon { + display: flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + color: var(--gray); + flex-shrink: 0; + + svg { + width: 16px; + height: 16px; + } + } + + .bases-toolbar-menu-item-name { + font-size: 0.875rem; + color: var(--dark); + } + } + + .clickable-icon.bases-toolbar-menu-item-icon { + display: flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + opacity: 0; + transition: opacity 0.15s ease; + color: var(--gray); + flex-shrink: 0; + + svg { + width: 16px; + height: 16px; + } + } + + &:hover .clickable-icon.bases-toolbar-menu-item-icon { + opacity: 0.5; + } + } + } + } + } +} + +@media all and ($mobile) { + .bases-toolbar { + .menu-scroll { + min-width: 240px; + left: auto; + } + } +} diff --git a/quartz/plugins/emitters/assets.ts b/quartz/plugins/emitters/assets.ts index d0da66ace..6222e1d34 100644 --- a/quartz/plugins/emitters/assets.ts +++ b/quartz/plugins/emitters/assets.ts @@ -7,8 +7,12 @@ import { Argv } from "../../util/ctx" import { QuartzConfig } from "../../cfg" const filesToCopy = async (argv: Argv, cfg: QuartzConfig) => { - // glob all non MD files in content folder and copy it over - return await glob("**", argv.directory, ["**/*.md", ...cfg.configuration.ignorePatterns]) + // glob all non MD/base files in content folder and copy it over + return await glob("**", argv.directory, [ + "**/*.md", + "**/*.base", + ...cfg.configuration.ignorePatterns, + ]) } const copyFile = async (argv: Argv, fp: FilePath) => { @@ -37,7 +41,7 @@ export const Assets: QuartzEmitterPlugin = () => { async *partialEmit(ctx, _content, _resources, changeEvents) { for (const changeEvent of changeEvents) { const ext = path.extname(changeEvent.path) - if (ext === ".md") continue + if (ext === ".md" || ext === ".base") continue if (changeEvent.type === "add" || changeEvent.type === "change") { yield copyFile(ctx.argv, changeEvent.path) diff --git a/quartz/plugins/emitters/basePage.tsx b/quartz/plugins/emitters/basePage.tsx new file mode 100644 index 000000000..4dee32376 --- /dev/null +++ b/quartz/plugins/emitters/basePage.tsx @@ -0,0 +1,184 @@ +import { QuartzEmitterPlugin } from "../types" +import { QuartzComponentProps } from "../../components/types" +import HeaderConstructor from "../../components/Header" +import BodyConstructor from "../../components/Body" +import { pageResources, renderPage } from "../../components/renderPage" +import { ProcessedContent, QuartzPluginData } from "../vfile" +import { FullPageLayout } from "../../cfg" +import { pathToRoot } from "../../util/path" +import { defaultListPageLayout, sharedPageComponents } from "../../../quartz.layout" +import { BaseContent, BaseViewSelector } from "../../components" +import { write } from "./helpers" +import { BuildCtx } from "../../util/ctx" +import { StaticResources } from "../../util/resources" +import { + renderBaseViewsForFile, + RenderedBaseView, + BaseViewMeta, + BaseMetadata, +} from "../../util/base/render" +import { BaseFile } from "../../util/base/types" + +interface BasePageOptions extends FullPageLayout {} + +function isBaseFile(data: QuartzPluginData): boolean { + return Boolean(data.basesConfig && (data.basesConfig as BaseFile).views?.length > 0) +} + +function getBaseFiles(content: ProcessedContent[]): ProcessedContent[] { + return content.filter(([_, file]) => isBaseFile(file.data)) +} + +async function processBasePage( + ctx: BuildCtx, + baseFileData: QuartzPluginData, + renderedView: RenderedBaseView, + allViews: BaseViewMeta[], + allFiles: QuartzPluginData[], + opts: FullPageLayout, + resources: StaticResources, +) { + const slug = renderedView.slug + const cfg = ctx.cfg.configuration + const externalResources = pageResources(pathToRoot(slug), resources) + + const viewFileData: QuartzPluginData = { + ...baseFileData, + slug, + frontmatter: { + ...baseFileData.frontmatter, + title: renderedView.view.name, + }, + basesRenderedTree: renderedView.tree, + basesAllViews: allViews, + basesCurrentView: renderedView.view.name, + basesMetadata: { + baseSlug: baseFileData.slug!, + currentView: renderedView.view.name, + allViews, + }, + } + + const componentData: QuartzComponentProps = { + ctx, + fileData: viewFileData, + externalResources, + cfg, + children: [], + tree: renderedView.tree, + allFiles, + } + + const content = renderPage(cfg, slug, componentData, opts, externalResources) + return write({ + ctx, + content, + slug, + ext: ".html", + }) +} + +export const BasePage: QuartzEmitterPlugin> = (userOpts) => { + const baseOpts: FullPageLayout = { + ...sharedPageComponents, + ...defaultListPageLayout, + pageBody: BaseContent(), + ...userOpts, + } + + const opts: FullPageLayout = { + ...baseOpts, + beforeBody: [ + ...baseOpts.beforeBody.filter((component) => component.name !== "ArticleTitle"), + BaseViewSelector(), + ], + } + + const { head: Head, header, beforeBody, pageBody, afterBody, left, right, footer: Footer } = opts + const Header = HeaderConstructor() + const Body = BodyConstructor() + + return { + name: "BasePage", + getQuartzComponents() { + return [ + Head, + Header, + Body, + ...header, + ...beforeBody, + pageBody, + ...afterBody, + ...left, + ...right, + Footer, + ] + }, + async *emit(ctx, content, resources) { + const allFiles = content.map((c) => c[1].data) + const baseFiles = getBaseFiles(content) + + for (const [_, file] of baseFiles) { + const baseFileData = file.data + const { views, allViews } = renderBaseViewsForFile(baseFileData, allFiles) + + for (const renderedView of views) { + yield processBasePage( + ctx, + baseFileData, + renderedView, + allViews, + allFiles, + opts, + resources, + ) + } + } + }, + async *partialEmit(ctx, content, resources, changeEvents) { + const allFiles = content.map((c) => c[1].data) + const baseFiles = getBaseFiles(content) + + const affectedBaseSlugs = new Set() + + for (const event of changeEvents) { + if (!event.file) continue + const slug = event.file.data.slug + + if (slug && isBaseFile(event.file.data)) { + affectedBaseSlugs.add(slug) + } + } + + for (const [_, file] of baseFiles) { + const baseFileData = file.data + const baseSlug = baseFileData.slug + + if (!baseSlug || !affectedBaseSlugs.has(baseSlug)) continue + + const { views, allViews } = renderBaseViewsForFile(baseFileData, allFiles) + + for (const renderedView of views) { + yield processBasePage( + ctx, + baseFileData, + renderedView, + allViews, + allFiles, + opts, + resources, + ) + } + } + }, + } +} + +declare module "vfile" { + interface DataMap { + basesRenderedTree?: import("hast").Root + basesAllViews?: BaseViewMeta[] + basesCurrentView?: string + basesMetadata?: BaseMetadata + } +} diff --git a/quartz/plugins/emitters/contentPage.tsx b/quartz/plugins/emitters/contentPage.tsx index c3410ecc3..f59975c84 100644 --- a/quartz/plugins/emitters/contentPage.tsx +++ b/quartz/plugins/emitters/contentPage.tsx @@ -83,6 +83,8 @@ export const ContentPage: QuartzEmitterPlugin> = (userOp containsIndex = true } + if (file.data.filePath!.endsWith(".base")) continue + // only process home page, non-tag pages, and non-index pages if (slug.endsWith("/index") || slug.startsWith("tags/")) continue yield processContent(ctx, tree, file.data, allFiles, opts, resources) @@ -112,6 +114,7 @@ export const ContentPage: QuartzEmitterPlugin> = (userOp for (const [tree, file] of content) { const slug = file.data.slug! if (!changedSlugs.has(slug)) continue + if (file.data.filePath!.endsWith(".base")) continue if (slug.endsWith("/index") || slug.startsWith("tags/")) continue yield processContent(ctx, tree, file.data, allFiles, opts, resources) diff --git a/quartz/plugins/emitters/index.ts b/quartz/plugins/emitters/index.ts index d2de2ed1e..ce905da64 100644 --- a/quartz/plugins/emitters/index.ts +++ b/quartz/plugins/emitters/index.ts @@ -10,3 +10,4 @@ export { ComponentResources } from "./componentResources" export { NotFoundPage } from "./404" export { CNAME } from "./cname" export { CustomOgImages } from "./ogImage" +export { BasePage } from "./basePage" diff --git a/quartz/plugins/transformers/bases.ts b/quartz/plugins/transformers/bases.ts new file mode 100644 index 000000000..daf769a2c --- /dev/null +++ b/quartz/plugins/transformers/bases.ts @@ -0,0 +1,521 @@ +import * as yaml from "js-yaml" +import { QuartzTransformerPlugin } from "../types" +import { FilePath, getFileExtension } from "../../util/path" +import { + BaseFile, + BaseView, + BaseFileFilter, + parseViews, + parseViewSummaries, + BUILTIN_SUMMARY_TYPES, + BuiltinSummaryType, +} from "../../util/base/types" +import { + parseExpressionSource, + compileExpression, + buildPropertyExpressionSource, + ProgramIR, + BasesExpressions, + BaseExpressionDiagnostic, + Span, +} from "../../util/base/compiler" + +export interface BasesOptions { + /** Whether to emit diagnostics as warnings during build */ + emitWarnings: boolean +} + +const defaultOptions: BasesOptions = { + emitWarnings: true, +} + +type FilterStructure = + | string + | { and?: FilterStructure[]; or?: FilterStructure[]; not?: FilterStructure[] } + +function compileFilterStructure( + filter: FilterStructure | undefined, + file: string, + diagnostics: BaseExpressionDiagnostic[], + context: string, +): ProgramIR | undefined { + if (!filter) return undefined + + if (typeof filter === "string") { + const result = parseExpressionSource(filter, file) + if (result.diagnostics.length > 0) { + for (const diag of result.diagnostics) { + diagnostics.push({ + kind: diag.kind as "lex" | "parse" | "runtime", + message: diag.message, + span: diag.span, + context, + source: filter, + }) + } + } + if (!result.program.body) return undefined + return compileExpression(result.program.body) + } + + const compileParts = ( + parts: FilterStructure[], + combiner: "&&" | "||", + negate: boolean, + ): ProgramIR | undefined => { + const compiled: ProgramIR[] = [] + for (const part of parts) { + const partIR = compileFilterStructure(part, file, diagnostics, context) + if (partIR) compiled.push(partIR) + } + if (compiled.length === 0) return undefined + if (compiled.length === 1) { + if (negate) { + return wrapWithNot(compiled[0]) + } + return compiled[0] + } + + let result = compiled[0] + for (let i = 1; i < compiled.length; i++) { + result = combineWithLogical(result, compiled[i], combiner, negate) + } + return result + } + + if (filter.and && filter.and.length > 0) { + return compileParts(filter.and, "&&", false) + } + if (filter.or && filter.or.length > 0) { + return compileParts(filter.or, "||", false) + } + if (filter.not && filter.not.length > 0) { + return compileParts(filter.not, "&&", true) + } + + return undefined +} + +function wrapWithNot(ir: ProgramIR): ProgramIR { + const span = ir.span + return { + instructions: [ + ...ir.instructions, + { op: "to_bool" as const, span }, + { op: "unary" as const, operator: "!" as const, span }, + ], + span, + } +} + +function combineWithLogical( + left: ProgramIR, + right: ProgramIR, + operator: "&&" | "||", + negateRight: boolean, +): ProgramIR { + const span: Span = { + start: left.span.start, + end: right.span.end, + file: left.span.file, + } + + const rightIR = negateRight ? wrapWithNot(right) : right + + if (operator === "&&") { + const jumpIfFalseIndex = left.instructions.length + 1 + const jumpIndex = jumpIfFalseIndex + rightIR.instructions.length + 2 + return { + instructions: [ + ...left.instructions, + { op: "jump_if_false" as const, target: jumpIndex, span }, + ...rightIR.instructions, + { op: "to_bool" as const, span }, + { op: "jump" as const, target: jumpIndex + 1, span }, + { + op: "const" as const, + literal: { type: "Literal" as const, kind: "boolean" as const, value: false, span }, + span, + }, + ], + span, + } + } else { + const jumpIfTrueIndex = left.instructions.length + 1 + const jumpIndex = jumpIfTrueIndex + rightIR.instructions.length + 2 + return { + instructions: [ + ...left.instructions, + { op: "jump_if_true" as const, target: jumpIndex, span }, + ...rightIR.instructions, + { op: "to_bool" as const, span }, + { op: "jump" as const, target: jumpIndex + 1, span }, + { + op: "const" as const, + literal: { type: "Literal" as const, kind: "boolean" as const, value: true, span }, + span, + }, + ], + span, + } + } +} + +function collectPropertiesFromViews(views: BaseView[]): Set { + const properties = new Set() + for (const view of views) { + if (view.order) { + for (const prop of view.order) { + properties.add(prop) + } + } + if (view.groupBy) { + const groupProp = typeof view.groupBy === "string" ? view.groupBy : view.groupBy.property + properties.add(groupProp) + } + if (view.sort) { + for (const sortConfig of view.sort) { + properties.add(sortConfig.property) + } + } + if (view.image) properties.add(view.image) + if (view.date) properties.add(view.date) + if (view.dateField) properties.add(view.dateField) + if (view.dateProperty) properties.add(view.dateProperty) + if (view.coordinates) properties.add(view.coordinates) + if (view.markerIcon) properties.add(view.markerIcon) + if (view.markerColor) properties.add(view.markerColor) + } + return properties +} + +function compilePropertyExpressions( + properties: Set, + file: string, + diagnostics: BaseExpressionDiagnostic[], +): Record { + const expressions: Record = {} + + for (const property of properties) { + const source = buildPropertyExpressionSource(property) + if (!source) continue + + const result = parseExpressionSource(source, file) + if (result.diagnostics.length > 0) { + for (const diag of result.diagnostics) { + diagnostics.push({ + kind: diag.kind as "lex" | "parse" | "runtime", + message: diag.message, + span: diag.span, + context: `property.${property}`, + source, + }) + } + } + if (result.program.body) { + expressions[property] = compileExpression(result.program.body) + } + } + + return expressions +} + +function compileFormulas( + formulas: Record | undefined, + file: string, + diagnostics: BaseExpressionDiagnostic[], +): Record { + if (!formulas) return {} + + const compiled: Record = {} + for (const [name, source] of Object.entries(formulas)) { + const trimmed = source.trim() + if (!trimmed) continue + + const result = parseExpressionSource(trimmed, file) + if (result.diagnostics.length > 0) { + for (const diag of result.diagnostics) { + diagnostics.push({ + kind: diag.kind as "lex" | "parse" | "runtime", + message: diag.message, + span: diag.span, + context: `formulas.${name}`, + source: trimmed, + }) + } + } + if (result.program.body) { + compiled[name] = compileExpression(result.program.body) + } + } + + return compiled +} + +function compileSummaries( + summaries: Record | undefined, + file: string, + diagnostics: BaseExpressionDiagnostic[], +): Record { + if (!summaries) return {} + + const compiled: Record = {} + for (const [name, source] of Object.entries(summaries)) { + const trimmed = source.trim() + if (!trimmed) continue + + const normalized = trimmed.toLowerCase() + if (BUILTIN_SUMMARY_TYPES.includes(normalized as BuiltinSummaryType)) { + continue + } + + const result = parseExpressionSource(trimmed, file) + if (result.diagnostics.length > 0) { + for (const diag of result.diagnostics) { + diagnostics.push({ + kind: diag.kind as "lex" | "parse" | "runtime", + message: diag.message, + span: diag.span, + context: `summaries.${name}`, + source: trimmed, + }) + } + } + if (result.program.body) { + compiled[name] = compileExpression(result.program.body) + } + } + + return compiled +} + +function compileViewSummaries( + views: BaseView[], + topLevelSummaries: Record | undefined, + file: string, + diagnostics: BaseExpressionDiagnostic[], +): Record> { + const result: Record> = {} + + for (let i = 0; i < views.length; i++) { + const view = views[i] + if (!view.summaries) continue + + const viewSummaryConfig = parseViewSummaries( + view.summaries as Record, + topLevelSummaries, + ) + if (!viewSummaryConfig?.columns) continue + + const viewExpressions: Record = {} + for (const [column, def] of Object.entries(viewSummaryConfig.columns)) { + if (def.type !== "formula" || !def.expression) continue + + const parseResult = parseExpressionSource(def.expression, file) + if (parseResult.diagnostics.length > 0) { + for (const diag of parseResult.diagnostics) { + diagnostics.push({ + kind: diag.kind as "lex" | "parse" | "runtime", + message: diag.message, + span: diag.span, + context: `views[${i}].summaries.${column}`, + source: def.expression, + }) + } + } + if (parseResult.program.body) { + viewExpressions[column] = compileExpression(parseResult.program.body) + } + } + + if (Object.keys(viewExpressions).length > 0) { + result[String(i)] = viewExpressions + } + } + + return result +} + +export const ObsidianBases: QuartzTransformerPlugin> = (userOpts) => { + const opts = { ...defaultOptions, ...userOpts } + + return { + name: "ObsidianBases", + textTransform(_ctx, src) { + return src + }, + markdownPlugins(_ctx) { + return [ + () => { + return (_tree, file) => { + const filePath = file.data.filePath as FilePath | undefined + if (!filePath) return + + const ext = getFileExtension(filePath) + if (ext !== ".base") return + + const content = file.value.toString() + if (!content.trim()) return + + const diagnostics: BaseExpressionDiagnostic[] = [] + const filePathStr = filePath + + try { + const parsed = yaml.load(content, { schema: yaml.JSON_SCHEMA }) as Record< + string, + unknown + > + if (!parsed || typeof parsed !== "object") { + diagnostics.push({ + kind: "parse", + message: "Base file must contain a valid YAML object", + span: { + start: { offset: 0, line: 1, column: 1 }, + end: { offset: 0, line: 1, column: 1 }, + file: filePathStr, + }, + context: "root", + source: content.slice(0, 100), + }) + file.data.basesDiagnostics = diagnostics + return + } + + const rawViews = parsed.views + if (!Array.isArray(rawViews) || rawViews.length === 0) { + diagnostics.push({ + kind: "parse", + message: "Base file must have at least one view defined", + span: { + start: { offset: 0, line: 1, column: 1 }, + end: { offset: 0, line: 1, column: 1 }, + file: filePathStr, + }, + context: "views", + source: "views: []", + }) + file.data.basesDiagnostics = diagnostics + return + } + + const views = parseViews(rawViews) + const filters = parsed.filters as BaseFileFilter | undefined + const properties = parsed.properties as + | Record + | undefined + const summaries = parsed.summaries as Record | undefined + const formulas = parsed.formulas as Record | undefined + + const baseConfig: BaseFile = { + filters, + views, + properties, + summaries, + formulas, + } + + const compiledFilters = compileFilterStructure( + filters as FilterStructure | undefined, + filePathStr, + diagnostics, + "filters", + ) + + const viewFilters: Record = {} + for (let i = 0; i < views.length; i++) { + const view = views[i] + if (view.filters) { + const compiled = compileFilterStructure( + view.filters as FilterStructure, + filePathStr, + diagnostics, + `views[${i}].filters`, + ) + if (compiled) { + viewFilters[String(i)] = compiled + } + } + } + + const compiledFormulas = compileFormulas(formulas, filePathStr, diagnostics) + + const compiledSummaries = compileSummaries(summaries, filePathStr, diagnostics) + const compiledViewSummaries = compileViewSummaries( + views, + summaries, + filePathStr, + diagnostics, + ) + + const viewProperties = collectPropertiesFromViews(views) + + for (const name of Object.keys(compiledFormulas)) { + viewProperties.add(`formula.${name}`) + } + + const propertyExpressions = compilePropertyExpressions( + viewProperties, + filePathStr, + diagnostics, + ) + + const expressions: BasesExpressions = { + filters: compiledFilters, + viewFilters, + formulas: compiledFormulas, + summaries: compiledSummaries, + viewSummaries: compiledViewSummaries, + propertyExpressions, + } + + file.data.basesConfig = baseConfig + file.data.basesExpressions = expressions + file.data.basesDiagnostics = diagnostics + + const existingFrontmatter = (file.data.frontmatter ?? {}) as Record + file.data.frontmatter = { + title: views[0]?.name ?? file.stem ?? "Base", + tags: ["base"], + ...existingFrontmatter, + } + + if (opts.emitWarnings && diagnostics.length > 0) { + for (const diag of diagnostics) { + console.warn( + `[bases] ${filePathStr}:${diag.span.start.line}:${diag.span.start.column} - ${diag.message}`, + ) + } + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + diagnostics.push({ + kind: "parse", + message: `Failed to parse base file: ${message}`, + span: { + start: { offset: 0, line: 1, column: 1 }, + end: { offset: 0, line: 1, column: 1 }, + file: filePathStr, + }, + context: "root", + source: content.slice(0, 100), + }) + file.data.basesDiagnostics = diagnostics + + if (opts.emitWarnings) { + console.warn(`[bases] ${filePathStr}: ${message}`) + } + } + } + }, + ] + }, + } +} + +declare module "vfile" { + interface DataMap { + basesConfig?: BaseFile + basesExpressions?: BasesExpressions + basesDiagnostics?: BaseExpressionDiagnostic[] + } +} diff --git a/quartz/plugins/transformers/index.ts b/quartz/plugins/transformers/index.ts index 8e2cd844f..7d096c7b4 100644 --- a/quartz/plugins/transformers/index.ts +++ b/quartz/plugins/transformers/index.ts @@ -11,3 +11,4 @@ export { SyntaxHighlighting } from "./syntax" export { TableOfContents } from "./toc" export { HardLineBreaks } from "./linebreaks" export { RoamFlavoredMarkdown } from "./roam" +export { ObsidianBases } from "./bases" diff --git a/quartz/plugins/transformers/ofm.ts b/quartz/plugins/transformers/ofm.ts index 7a523aa59..776e4ef0f 100644 --- a/quartz/plugins/transformers/ofm.ts +++ b/quartz/plugins/transformers/ofm.ts @@ -289,8 +289,11 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin> } } - // internal link - const url = fp + anchor + const isBaseFile = fp.endsWith(".base") + const basePath = isBaseFile ? fp.slice(0, -5) : fp + const url = isBaseFile + ? basePath + (anchor ? `/${anchor.slice(1).replace(/\s+/g, "-")}` : "") + : fp + anchor return { type: "link", @@ -298,7 +301,7 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin> children: [ { type: "text", - value: alias ?? fp, + value: alias ?? basePath, }, ], } diff --git a/quartz/processors/parse.ts b/quartz/processors/parse.ts index 1099cd99b..2070bdd94 100644 --- a/quartz/processors/parse.ts +++ b/quartz/processors/parse.ts @@ -104,12 +104,14 @@ export function createFileParser(ctx: BuildCtx, fps: FilePath[]) { file.data.relativePath = path.posix.relative(argv.directory, file.path) as FilePath file.data.slug = slugifyFilePath(file.data.relativePath) - const ast = processor.parse(file) + const isBaseFile = fp.endsWith(".base") + const ast: MDRoot = isBaseFile ? { type: "root", children: [] } : processor.parse(file) + const newAst = await processor.run(ast, file) res.push([newAst, file]) if (argv.verbose) { - console.log(`[markdown] ${fp} -> ${file.data.slug} (${perf.timeSince()})`) + console.log(`[${isBaseFile ? "base" : "markdown"}] ${fp} -> ${file.data.slug} (${perf.timeSince()})`) } } catch (err) { trace(`\nFailed to process markdown \`${fp}\``, err as Error) diff --git a/quartz/util/base/README.md b/quartz/util/base/README.md new file mode 100644 index 000000000..821552c39 --- /dev/null +++ b/quartz/util/base/README.md @@ -0,0 +1,92 @@ +# bases compiler + runtime (quartz implementation) + +status: active +last updated: 2026-01-28 + +this directory contains the obsidian bases compiler, interpreter, and runtime helpers used by quartz to render `.base` files. it is designed to match obsidian bases syntax and semantics with deterministic evaluation and consistent diagnostics. + +You can test it out with any of the base file in my vault here: + +```bash +npx tsx quartz/util/base/inspect-base.ts docs/navigation.base > /tmp/ast-ir.json + +jq '.expressions[] | {context, kind, source, ast}' /tmp/ast-ir.json +jq '.expressions[] | {context, kind, ir}' /tmp/ast-ir.json +``` + +## scope + +- parse base expressions (filters, formulas, summaries, property expressions) +- compile expressions to bytecode ir +- interpret bytecode with a deterministic stack vm +- resolve file, note, formula, and property values +- render views (table, list, cards/gallery, board, calendar, map) +- surface parse and runtime diagnostics in base output + +## architecture (pipeline) + +1. parse `.base` yaml (plugin: `quartz/plugins/transformers/bases.ts`) +2. parse expressions into ast (`compiler/parser.ts`) +3. compile ast to ir (`compiler/ir.ts`) +4. evaluate ir per row with caches (`compiler/interpreter.ts`) +5. render views and diagnostics (`render.ts`) + +## modules + +- `compiler/lexer.ts`: tokenizer with span tracking and regex support +- `compiler/parser.ts`: pratt parser for expression grammar and error recovery +- `compiler/ir.ts`: bytecode instruction set + compiler +- `compiler/interpreter.ts`: stack vm, value model, coercions, methods, functions +- `compiler/diagnostics.ts`: diagnostics types and helpers +- `compiler/schema.ts`: summary config schema and builtins +- `compiler/properties.ts`: property expression builder for columns and config keys +- `render.ts`: view rendering and diagnostics output +- `query.ts`: summaries and view summary helpers +- `types.ts`: base config types and yaml parsing helpers + +## value model (runtime) + +runtime values are tagged unions with explicit kinds: + +- null, boolean, number, string +- date, duration +- list, object +- file, link +- regex, html, icon, image + +coercions are permissive to match obsidian behavior. comparisons prefer type-aware equality (links resolve to files when possible, dates compare by time, etc), with fallbacks when resolution fails. + +## expression features (spec parity) + +- operators: `==`, `!=`, `>`, `<`, `>=`, `<=`, `&&`, `||`, `!`, `+`, `-`, `*`, `/`, `%` +- member and index access +- function calls and method calls +- list literals and regex literals +- `this` binding with embed-aware scoping +- list helpers (`filter`, `map`, `reduce`) using implicit locals `value`, `index`, `acc` +- summary context helpers: `values` (column values) and `rows` (row files) + +## diagnostics + +- parser diagnostics are collected with spans at compile time +- runtime diagnostics are collected during evaluation and deduped per context +- base views render diagnostics above the view output + +## this scoping + +- main base file: `this` resolves to the base file +- embedded base: `this` resolves to the embedding file +- row evaluation: `file` resolves to the row file + +## performance decisions + +- bytecode ir keeps evaluation linear and stable +- per-build backlink index avoids n^2 scans +- property cache memoizes property expressions per file +- formula cache memoizes formula evaluation per file + +## view rendering + +- table, list, cards/gallery, board, calendar, map +- map rendering expects coordinates `[lat, lon]` and map config fields +- view filters combine with base filters via logical and diff --git a/quartz/util/base/compiler/ast.ts b/quartz/util/base/compiler/ast.ts new file mode 100644 index 000000000..96570bcea --- /dev/null +++ b/quartz/util/base/compiler/ast.ts @@ -0,0 +1,76 @@ +export type Position = { offset: number; line: number; column: number } + +export type Span = { start: Position; end: Position; file?: string } + +export type Program = { type: "Program"; body: Expr | null; span: Span } + +export type Expr = + | Literal + | Identifier + | UnaryExpr + | BinaryExpr + | LogicalExpr + | CallExpr + | MemberExpr + | IndexExpr + | ListExpr + | ErrorExpr + +export type LiteralKind = "number" | "string" | "boolean" | "null" | "date" | "duration" | "regex" + +export type NumberLiteral = { type: "Literal"; kind: "number"; value: number; span: Span } +export type StringLiteral = { type: "Literal"; kind: "string"; value: string; span: Span } +export type BooleanLiteral = { type: "Literal"; kind: "boolean"; value: boolean; span: Span } +export type NullLiteral = { type: "Literal"; kind: "null"; value: null; span: Span } +export type DateLiteral = { type: "Literal"; kind: "date"; value: string; span: Span } +export type DurationLiteral = { type: "Literal"; kind: "duration"; value: string; span: Span } +export type RegexLiteral = { + type: "Literal" + kind: "regex" + value: string + flags: string + span: Span +} + +export type Literal = + | NumberLiteral + | StringLiteral + | BooleanLiteral + | NullLiteral + | DateLiteral + | DurationLiteral + | RegexLiteral + +export type Identifier = { type: "Identifier"; name: string; span: Span } + +export type UnaryExpr = { type: "UnaryExpr"; operator: "!" | "-"; argument: Expr; span: Span } + +export type BinaryExpr = { + type: "BinaryExpr" + operator: "+" | "-" | "*" | "/" | "%" | "==" | "!=" | ">" | ">=" | "<" | "<=" + left: Expr + right: Expr + span: Span +} + +export type LogicalExpr = { + type: "LogicalExpr" + operator: "&&" | "||" + left: Expr + right: Expr + span: Span +} + +export type CallExpr = { type: "CallExpr"; callee: Expr; args: Expr[]; span: Span } + +export type MemberExpr = { type: "MemberExpr"; object: Expr; property: string; span: Span } + +export type IndexExpr = { type: "IndexExpr"; object: Expr; index: Expr; span: Span } + +export type ListExpr = { type: "ListExpr"; elements: Expr[]; span: Span } + +export type ErrorExpr = { type: "ErrorExpr"; message: string; span: Span } + +export function spanFrom(start: Span, end: Span): Span { + return { start: start.start, end: end.end, file: start.file || end.file } +} diff --git a/quartz/util/base/compiler/diagnostics.ts b/quartz/util/base/compiler/diagnostics.ts new file mode 100644 index 000000000..fcfb6f68d --- /dev/null +++ b/quartz/util/base/compiler/diagnostics.ts @@ -0,0 +1,9 @@ +import { Span } from "./ast" + +export type BaseExpressionDiagnostic = { + kind: "lex" | "parse" | "runtime" + message: string + span: Span + context: string + source: string +} diff --git a/quartz/util/base/compiler/errors.ts b/quartz/util/base/compiler/errors.ts new file mode 100644 index 000000000..7ad5964f3 --- /dev/null +++ b/quartz/util/base/compiler/errors.ts @@ -0,0 +1,3 @@ +import { Span } from "./ast" + +export type Diagnostic = { kind: "lex" | "parse"; message: string; span: Span } diff --git a/quartz/util/base/compiler/expressions.ts b/quartz/util/base/compiler/expressions.ts new file mode 100644 index 000000000..9d2c9f335 --- /dev/null +++ b/quartz/util/base/compiler/expressions.ts @@ -0,0 +1,10 @@ +import { ProgramIR } from "./ir" + +export type BasesExpressions = { + filters?: ProgramIR + viewFilters: Record + formulas: Record + summaries: Record + viewSummaries: Record> + propertyExpressions: Record +} diff --git a/quartz/util/base/compiler/index.ts b/quartz/util/base/compiler/index.ts new file mode 100644 index 000000000..6837eb80d --- /dev/null +++ b/quartz/util/base/compiler/index.ts @@ -0,0 +1,44 @@ +export { lex } from "./lexer" +export { parseExpressionSource } from "./parser" +export type { ParseResult } from "./parser" +export type { Diagnostic } from "./errors" +export type { Program, Expr, Span, Position } from "./ast" +export type { BaseExpressionDiagnostic } from "./diagnostics" +export type { BasesExpressions } from "./expressions" +export type { Instruction, ProgramIR } from "./ir" +export { compileExpression } from "./ir" +export { buildPropertyExpressionSource } from "./properties" +export type { + SummaryDefinition, + ViewSummaryConfig, + PropertyConfig, + BuiltinSummaryType, +} from "./schema" +export { BUILTIN_SUMMARY_TYPES } from "./schema" +export { + evaluateExpression, + evaluateFilterExpression, + evaluateSummaryExpression, + valueToUnknown, +} from "./interpreter" +export type { + EvalContext, + Value, + NullValue, + BooleanValue, + NumberValue, + StringValue, + DateValue, + DurationValue, + ListValue, + ObjectValue, + FileValue, + LinkValue, + RegexValue, + HtmlValue, + IconValue, + ImageValue, + ValueKind, + ValueOf, +} from "./interpreter" +export { isValueKind } from "./interpreter" diff --git a/quartz/util/base/compiler/interpreter.test.ts b/quartz/util/base/compiler/interpreter.test.ts new file mode 100644 index 000000000..074cbc4f3 --- /dev/null +++ b/quartz/util/base/compiler/interpreter.test.ts @@ -0,0 +1,73 @@ +import assert from "node:assert" +import test from "node:test" +import { FilePath, FullSlug, SimpleSlug } from "../../path" + +type ContentLayout = "default" | "article" | "page" +import { evaluateExpression, valueToUnknown, EvalContext } from "./interpreter" +import { compileExpression } from "./ir" +import { parseExpressionSource } from "./parser" + +const parseExpr = (source: string) => { + const result = parseExpressionSource(source, "test") + if (!result.program.body) { + throw new Error(`expected expression for ${source}`) + } + return compileExpression(result.program.body) +} + +const makeCtx = (): EvalContext => { + const fileA = { + slug: "a" as FullSlug, + filePath: "a.md" as FilePath, + frontmatter: { title: "A", pageLayout: "default" as ContentLayout }, + links: [] as SimpleSlug[], + } + const fileB = { + slug: "b" as FullSlug, + filePath: "b.md" as FilePath, + frontmatter: { title: "B", pageLayout: "default" as ContentLayout }, + links: ["a"] as SimpleSlug[], + } + return { file: fileA, allFiles: [fileA, fileB] } +} + +test("link equality resolves to file targets", () => { + const expr = parseExpr('link("a") == file("a")') + const value = valueToUnknown(evaluateExpression(expr, makeCtx())) + assert.strictEqual(value, true) +}) + +test("link equality matches raw string targets", () => { + const expr = parseExpr('link("a") == "a"') + const value = valueToUnknown(evaluateExpression(expr, makeCtx())) + assert.strictEqual(value, true) +}) + +test("date arithmetic handles month additions", () => { + const expr = parseExpr('date("2025-01-01") + "1M"') + const value = valueToUnknown(evaluateExpression(expr, makeCtx())) + assert.ok(value instanceof Date) + assert.strictEqual(value.toISOString().split("T")[0], "2025-02-01") +}) + +test("date subtraction returns duration in ms", () => { + const expr = parseExpr('date("2025-01-02") - date("2025-01-01")') + const value = valueToUnknown(evaluateExpression(expr, makeCtx())) + assert.strictEqual(value, 86400000) +}) + +test("list summary helpers compute statistics", () => { + const meanExpr = parseExpr("([1, 2, 3]).mean()") + const medianExpr = parseExpr("([1, 2, 3]).median()") + const stddevExpr = parseExpr("([1, 2, 3]).stddev()") + const sumExpr = parseExpr("([1, 2, 3]).sum()") + const ctx = makeCtx() + assert.strictEqual(valueToUnknown(evaluateExpression(meanExpr, ctx)), 2) + assert.strictEqual(valueToUnknown(evaluateExpression(medianExpr, ctx)), 2) + assert.strictEqual(valueToUnknown(evaluateExpression(sumExpr, ctx)), 6) + const stddev = valueToUnknown(evaluateExpression(stddevExpr, ctx)) + assert.strictEqual(typeof stddev, "number") + if (typeof stddev === "number") { + assert.ok(Math.abs(stddev - Math.sqrt(2 / 3)) < 1e-6) + } +}) diff --git a/quartz/util/base/compiler/interpreter.ts b/quartz/util/base/compiler/interpreter.ts new file mode 100644 index 000000000..080be13f4 --- /dev/null +++ b/quartz/util/base/compiler/interpreter.ts @@ -0,0 +1,1718 @@ +import { QuartzPluginData } from "../../../plugins/vfile" +import { FilePath, FullSlug, simplifySlug, slugifyFilePath, splitAnchor } from "../../path" +import { parseWikilink, resolveWikilinkTarget } from "../../wikilinks" +import { BinaryExpr, Literal, Span } from "./ast" +import { BaseExpressionDiagnostic } from "./diagnostics" +import { ProgramIR, Instruction } from "./ir" + +export type NullValue = { kind: "null" } +export type BooleanValue = { kind: "boolean"; value: boolean } +export type NumberValue = { kind: "number"; value: number } +export type StringValue = { kind: "string"; value: string } +export type DateValue = { kind: "date"; value: Date } +export type DurationValue = { kind: "duration"; value: number; months: number } +export type ListValue = { kind: "list"; value: Value[] } +export type ObjectValue = { kind: "object"; value: Record } +export type FileValue = { kind: "file"; value: QuartzPluginData } +export type LinkValue = { kind: "link"; value: string; display?: string } +export type RegexValue = { kind: "regex"; value: RegExp } +export type HtmlValue = { kind: "html"; value: string } +export type IconValue = { kind: "icon"; value: string } +export type ImageValue = { kind: "image"; value: string } + +export type Value = + | NullValue + | BooleanValue + | NumberValue + | StringValue + | DateValue + | DurationValue + | ListValue + | ObjectValue + | FileValue + | LinkValue + | RegexValue + | HtmlValue + | IconValue + | ImageValue + +export type ValueKind = Value["kind"] +export type ValueOf = Extract + +export function isValueKind(value: Value, kind: "null"): value is NullValue +export function isValueKind(value: Value, kind: "boolean"): value is BooleanValue +export function isValueKind(value: Value, kind: "number"): value is NumberValue +export function isValueKind(value: Value, kind: "string"): value is StringValue +export function isValueKind(value: Value, kind: "date"): value is DateValue +export function isValueKind(value: Value, kind: "duration"): value is DurationValue +export function isValueKind(value: Value, kind: "list"): value is ListValue +export function isValueKind(value: Value, kind: "object"): value is ObjectValue +export function isValueKind(value: Value, kind: "file"): value is FileValue +export function isValueKind(value: Value, kind: "link"): value is LinkValue +export function isValueKind(value: Value, kind: "regex"): value is RegexValue +export function isValueKind(value: Value, kind: "html"): value is HtmlValue +export function isValueKind(value: Value, kind: "icon"): value is IconValue +export function isValueKind(value: Value, kind: "image"): value is ImageValue +export function isValueKind(value: Value, kind: ValueKind): value is Value { + return value.kind === kind +} + +export type EvalContext = { + file: QuartzPluginData + thisFile?: QuartzPluginData + allFiles: QuartzPluginData[] + rows?: QuartzPluginData[] + fileIndex?: Map + backlinksIndex?: Map + formulas?: Record + formulaSources?: Record + formulaCache?: Map + formulaStack?: Set + locals?: Record + values?: Value[] + diagnostics?: BaseExpressionDiagnostic[] + diagnosticContext?: string + diagnosticSource?: string + diagnosticSet?: Set + propertyCache?: Map +} + +const nullValue: NullValue = { kind: "null" } + +const makeNull = (): NullValue => nullValue +const makeBoolean = (value: boolean): BooleanValue => ({ kind: "boolean", value }) +const makeNumber = (value: number): NumberValue => ({ kind: "number", value }) +const makeString = (value: string): StringValue => ({ kind: "string", value }) +const makeDate = (value: Date): DateValue => ({ kind: "date", value }) +const makeDuration = (value: number, months = 0): DurationValue => ({ + kind: "duration", + value, + months, +}) +const makeList = (value: Value[]): ListValue => ({ kind: "list", value }) +const makeObject = (value: Record): ObjectValue => ({ kind: "object", value }) +const makeFile = (value: QuartzPluginData): FileValue => ({ kind: "file", value }) +const makeLink = (value: string, display?: string): LinkValue => ({ kind: "link", value, display }) +const makeRegex = (value: RegExp): RegexValue => ({ kind: "regex", value }) +const makeHtml = (value: string): HtmlValue => ({ kind: "html", value }) +const makeIcon = (value: string): IconValue => ({ kind: "icon", value }) +const makeImage = (value: string): ImageValue => ({ kind: "image", value }) + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value) + +const isValue = (value: unknown): value is Value => + typeof value === "object" && value !== null && "kind" in value + +const isNumberValue = (value: Value): value is NumberValue => isValueKind(value, "number") + +const isStringValue = (value: Value): value is StringValue => isValueKind(value, "string") + +const isBooleanValue = (value: Value): value is BooleanValue => isValueKind(value, "boolean") + +const isListValue = (value: Value): value is ListValue => isValueKind(value, "list") + +const isObjectValue = (value: Value): value is ObjectValue => isValueKind(value, "object") + +const isDateValue = (value: Value): value is DateValue => isValueKind(value, "date") + +const isDurationValue = (value: Value): value is DurationValue => isValueKind(value, "duration") + +const isFileValue = (value: Value): value is FileValue => isValueKind(value, "file") + +const isLinkValue = (value: Value): value is LinkValue => isValueKind(value, "link") + +const isRegexValue = (value: Value): value is RegexValue => isValueKind(value, "regex") + +const stringMethods = new Set([ + "contains", + "containsAny", + "containsAll", + "startsWith", + "endsWith", + "isEmpty", + "lower", + "title", + "trim", + "replace", + "repeat", + "reverse", + "slice", + "split", + "length", +]) + +const numberMethods = new Set(["abs", "ceil", "floor", "round", "toFixed", "isEmpty"]) + +const listMethods = new Set([ + "contains", + "containsAny", + "containsAll", + "flat", + "join", + "reverse", + "slice", + "sort", + "unique", + "isEmpty", + "length", + "sum", + "mean", + "average", + "median", + "stddev", + "min", + "max", +]) + +const dateMethods = new Set([ + "date", + "format", + "time", + "relative", + "isEmpty", + "year", + "month", + "day", + "hour", + "minute", + "second", + "millisecond", +]) + +const fileMethods = new Set(["asLink", "hasTag", "inFolder", "hasProperty", "hasLink"]) + +const linkMethods = new Set(["asFile", "linksTo"]) + +const objectMethods = new Set(["isEmpty", "keys", "values"]) + +const formatLinkValue = (value: LinkValue): string => { + const target = value.value + const display = value.display?.trim() + if (display && display.length > 0) { + return `[[${target}|${display}]]` + } + return `[[${target}]]` +} + +const valueToString = (value: Value): string => { + switch (value.kind) { + case "null": + return "" + case "boolean": + return value.value ? "true" : "false" + case "number": + return Number.isFinite(value.value) ? String(value.value) : "" + case "string": + return value.value + case "date": + return formatDate(value.value) + case "duration": + return String(durationToMs(value)) + case "list": + return value.value.map(valueToString).join(", ") + case "object": + return Object.keys(value.value).length > 0 ? "[object]" : "" + case "file": + return value.value.slug ? String(value.value.slug) : "" + case "link": + return value.value + case "regex": + return value.value.source + case "html": + return value.value + case "icon": + return value.value + case "image": + return value.value + } +} + +const valueToNumber = (value: Value): number => { + switch (value.kind) { + case "number": + return value.value + case "duration": + return durationToMs(value) + case "boolean": + return value.value ? 1 : 0 + case "string": { + const num = Number(value.value) + return Number.isFinite(num) ? num : Number.NaN + } + case "date": + return value.value.getTime() + default: + return Number.NaN + } +} + +const coerceToNumber = (value: Value): number | null => { + const num = valueToNumber(value) + return Number.isFinite(num) ? num : null +} + +const coerceToDate = (value: Value): Date | null => { + if (isDateValue(value)) return value.value + if (isNumberValue(value)) { + const date = new Date(value.value) + return Number.isNaN(date.getTime()) ? null : date + } + if (isStringValue(value)) { + const parsed = new Date(value.value) + return Number.isNaN(parsed.getTime()) ? null : parsed + } + return null +} + +const isScalarValue = (value: Value): boolean => + !( + value.kind === "list" || + value.kind === "object" || + value.kind === "file" || + value.kind === "link" || + value.kind === "regex" || + value.kind === "html" || + value.kind === "icon" || + value.kind === "image" + ) + +const valueToBoolean = (value: Value): boolean => { + switch (value.kind) { + case "null": + return false + case "boolean": + return value.value + case "number": + return Number.isFinite(value.value) && value.value !== 0 + case "string": + return value.value.length > 0 + case "date": + return true + case "duration": + return durationToMs(value) !== 0 + case "list": + return value.value.length > 0 + case "object": + return Object.keys(value.value).length > 0 + case "file": + return true + case "link": + return value.value.length > 0 + case "regex": + return true + case "html": + return value.value.length > 0 + case "icon": + return value.value.length > 0 + case "image": + return value.value.length > 0 + } +} + +const valueEquals = (left: Value, right: Value, ctx: EvalContext): boolean => { + const leftLinkish = isLinkValue(left) || isFileValue(left) + const rightLinkish = isLinkValue(right) || isFileValue(right) + if ( + (leftLinkish && (rightLinkish || isStringValue(right))) || + (rightLinkish && isStringValue(left)) + ) { + const leftKey = resolveLinkComparisonKey(left, ctx) + const rightKey = resolveLinkComparisonKey(right, ctx) + if (leftKey.slug && rightKey.slug) return leftKey.slug === rightKey.slug + if (!leftKey.slug && !rightKey.slug) return leftKey.text === rightKey.text + return false + } + if (left.kind !== right.kind) { + if (isScalarValue(left) && isScalarValue(right)) { + const leftDate = coerceToDate(left) + const rightDate = coerceToDate(right) + if (leftDate && rightDate) return leftDate.getTime() === rightDate.getTime() + const leftNum = coerceToNumber(left) + const rightNum = coerceToNumber(right) + if (leftNum !== null && rightNum !== null) return leftNum === rightNum + return valueToString(left) === valueToString(right) + } + return false + } + if (left.kind === "null") return true + if (isBooleanValue(left) && isBooleanValue(right)) return left.value === right.value + if (isNumberValue(left) && isNumberValue(right)) return left.value === right.value + if (isStringValue(left) && isStringValue(right)) return left.value === right.value + if (isDateValue(left) && isDateValue(right)) return left.value.getTime() === right.value.getTime() + if (isDurationValue(left) && isDurationValue(right)) { + return left.value === right.value && left.months === right.months + } + if (isRegexValue(left) && isRegexValue(right)) return left.value.source === right.value.source + if (isListValue(left) && isListValue(right)) { + if (left.value.length !== right.value.length) return false + for (let i = 0; i < left.value.length; i += 1) { + if (!valueEquals(left.value[i], right.value[i], ctx)) return false + } + return true + } + if (isObjectValue(left) && isObjectValue(right)) { + const leftKeys = Object.keys(left.value) + const rightKeys = Object.keys(right.value) + if (leftKeys.length !== rightKeys.length) return false + for (const key of leftKeys) { + const l = left.value[key] + const r = right.value[key] + if (!r || !valueEquals(l, r, ctx)) return false + } + return true + } + return false +} + +const formatDate = (date: Date): string => { + const year = String(date.getUTCFullYear()).padStart(4, "0") + const month = String(date.getUTCMonth() + 1).padStart(2, "0") + const day = String(date.getUTCDate()).padStart(2, "0") + return `${year}-${month}-${day}` +} + +const formatTime = (date: Date): string => { + const hour = String(date.getUTCHours()).padStart(2, "0") + const minute = String(date.getUTCMinutes()).padStart(2, "0") + const second = String(date.getUTCSeconds()).padStart(2, "0") + return `${hour}:${minute}:${second}` +} + +const formatDatePattern = (date: Date, pattern: string): string => { + const replacements: Record = { + YYYY: String(date.getUTCFullYear()).padStart(4, "0"), + YY: String(date.getUTCFullYear() % 100).padStart(2, "0"), + MM: String(date.getUTCMonth() + 1).padStart(2, "0"), + DD: String(date.getUTCDate()).padStart(2, "0"), + HH: String(date.getUTCHours()).padStart(2, "0"), + mm: String(date.getUTCMinutes()).padStart(2, "0"), + ss: String(date.getUTCSeconds()).padStart(2, "0"), + SSS: String(date.getUTCMilliseconds()).padStart(3, "0"), + } + let result = pattern + for (const [token, replacement] of Object.entries(replacements)) { + result = result.split(token).join(replacement) + } + return result +} + +const formatRelative = (date: Date): string => { + const now = Date.now() + const diff = date.getTime() - now + const abs = Math.abs(diff) + const seconds = Math.round(abs / 1000) + const minutes = Math.round(abs / 60000) + const hours = Math.round(abs / 3600000) + const days = Math.round(abs / 86400000) + const weeks = Math.round(abs / 604800000) + const direction = diff < 0 ? "ago" : "from now" + if (seconds < 60) return `${seconds}s ${direction}` + if (minutes < 60) return `${minutes}m ${direction}` + if (hours < 24) return `${hours}h ${direction}` + if (days < 7) return `${days}d ${direction}` + return `${weeks}w ${direction}` +} + +const durationToMs = (duration: DurationValue): number => + duration.value + duration.months * 30 * 24 * 60 * 60 * 1000 + +const parseDurationParts = (input: string): { months: number; ms: number } => { + const trimmed = input.trim() + const asNumber = Number(trimmed) + if (!isNaN(asNumber)) { + return { months: 0, ms: asNumber } + } + + let months = 0 + let ms = 0 + const regex = /(\d+(?:\.\d+)?)\s*([a-zA-Z]+)/g + let match + while ((match = regex.exec(trimmed)) !== null) { + const value = parseFloat(match[1]) + const unitRaw = match[2] + const unit = unitRaw.toLowerCase() + if (unitRaw === "M" || unit === "mo" || unit === "month" || unit === "months") { + months += value + continue + } + if (unit === "y" || unit === "yr" || unit === "yrs" || unit === "year" || unit === "years") { + months += value * 12 + continue + } + if (unit === "ms" || unit === "millisecond" || unit === "milliseconds") { + ms += value + continue + } + if ( + unit === "s" || + unit === "sec" || + unit === "secs" || + unit === "second" || + unit === "seconds" + ) { + ms += value * 1000 + continue + } + if ( + unit === "m" || + unit === "min" || + unit === "mins" || + unit === "minute" || + unit === "minutes" + ) { + ms += value * 60 * 1000 + continue + } + if (unit === "h" || unit === "hr" || unit === "hrs" || unit === "hour" || unit === "hours") { + ms += value * 60 * 60 * 1000 + continue + } + if (unit === "d" || unit === "day" || unit === "days") { + ms += value * 24 * 60 * 60 * 1000 + continue + } + if (unit === "w" || unit === "week" || unit === "weeks") { + ms += value * 7 * 24 * 60 * 60 * 1000 + continue + } + } + + return { months, ms } +} + +const addDurationToDate = (date: Date, duration: DurationValue, direction: 1 | -1): Date => { + const result = new Date(date.getTime()) + if (duration.months !== 0) { + const totalMonths = duration.months * direction + const wholeMonths = Math.trunc(totalMonths) + const fractional = totalMonths - wholeMonths + if (wholeMonths !== 0) { + result.setUTCMonth(result.getUTCMonth() + wholeMonths) + } + if (fractional !== 0) { + result.setTime(result.getTime() + fractional * 30 * 24 * 60 * 60 * 1000) + } + } + if (duration.value !== 0) { + result.setTime(result.getTime() + direction * duration.value) + } + return result +} + +const pushRuntimeDiagnostic = (ctx: EvalContext, message: string, span: Span) => { + if (!ctx.diagnostics || !ctx.diagnosticContext) return + const source = ctx.diagnosticSource ?? "" + const key = `${ctx.diagnosticContext}|${message}|${span.start.offset}|${span.end.offset}|${source}` + if (ctx.diagnosticSet) { + if (ctx.diagnosticSet.has(key)) return + ctx.diagnosticSet.add(key) + } + ctx.diagnostics.push({ kind: "runtime", message, span, context: ctx.diagnosticContext, source }) +} + +const parseDurationValue = (raw: Value): DurationValue | null => { + if (isDurationValue(raw)) return raw + if (isNumberValue(raw)) return makeDuration(raw.value) + if (isStringValue(raw)) { + const parsed = parseDurationParts(raw.value) + return makeDuration(parsed.ms, parsed.months) + } + return null +} + +const toValue = (input: unknown): Value => { + if (input === null || input === undefined) return makeNull() + if (typeof input === "boolean") return makeBoolean(input) + if (typeof input === "number") return makeNumber(input) + if (typeof input === "string") { + const trimmed = input.trim() + if (/^\d{4}-\d{2}-\d{2}/.test(trimmed)) { + const parsed = new Date(trimmed) + if (!Number.isNaN(parsed.getTime())) { + return makeDate(parsed) + } + } + return makeString(input) + } + if (input instanceof Date) return makeDate(input) + if (input instanceof RegExp) return makeRegex(input) + if (Array.isArray(input)) return makeList(input.map(toValue)) + if (isRecord(input)) { + const obj: Record = {} + for (const [key, value] of Object.entries(input)) { + obj[key] = toValue(value) + } + return makeObject(obj) + } + return makeNull() +} + +export const valueToUnknown = (value: Value): unknown => { + switch (value.kind) { + case "null": + return undefined + case "boolean": + return value.value + case "number": + return value.value + case "string": + return value.value + case "date": + return value.value + case "duration": + return durationToMs(value) + case "list": + return value.value.map(valueToUnknown) + case "object": { + const obj: Record = {} + for (const [key, entry] of Object.entries(value.value)) { + obj[key] = valueToUnknown(entry) + } + return obj + } + case "file": + return value.value + case "link": + return formatLinkValue(value) + case "regex": + return value.value + case "html": + return value.value + case "icon": + return value.value + case "image": + return value.value + } +} + +const literalToValue = (expr: Literal): Value => { + if (expr.kind === "number") return makeNumber(expr.value) + if (expr.kind === "string") return makeString(expr.value) + if (expr.kind === "boolean") return makeBoolean(expr.value) + if (expr.kind === "null") return makeNull() + if (expr.kind === "date") return makeDate(new Date(expr.value)) + if (expr.kind === "duration") { + const parsed = parseDurationParts(expr.value) + return makeDuration(parsed.ms, parsed.months) + } + if (expr.kind === "regex") { + const regex = new RegExp(expr.value, expr.flags) + return makeRegex(regex) + } + return makeNull() +} + +const evaluateProgram = (program: ProgramIR, ctx: EvalContext): Value => { + const stack: Value[] = [] + const instructions = program.instructions + let ip = 0 + + const popValue = (): Value => stack.pop() ?? makeNull() + const popArgs = (count: number): Value[] => { + if (count <= 0) return [] + const start = Math.max(0, stack.length - count) + return stack.splice(start, stack.length - start) + } + + while (ip < instructions.length) { + const instr = instructions[ip] as Instruction + switch (instr.op) { + case "const": + stack.push(literalToValue(instr.literal)) + break + case "ident": + stack.push(resolveIdentifier(instr.name, ctx)) + break + case "load_formula": + stack.push(resolveFormulaProperty(instr.name, ctx)) + break + case "load_formula_index": { + const indexValue = popValue() + if (isStringValue(indexValue)) { + stack.push(resolveFormulaProperty(indexValue.value, ctx)) + } else { + stack.push(makeNull()) + } + break + } + case "member": { + const objectValue = popValue() + stack.push(accessProperty(objectValue, instr.property, ctx)) + break + } + case "index": { + const indexValue = popValue() + const objectValue = popValue() + stack.push(accessIndex(objectValue, indexValue)) + break + } + case "list": { + const count = Math.max(0, instr.count) + const items = count > 0 ? stack.splice(stack.length - count, count) : [] + stack.push(makeList(items)) + break + } + case "unary": { + const value = popValue() + stack.push(applyUnary(instr.operator, value)) + break + } + case "binary": { + const right = popValue() + const left = popValue() + stack.push(applyBinary(instr.operator, left, right, ctx)) + break + } + case "to_bool": { + const value = popValue() + stack.push(makeBoolean(valueToBoolean(value))) + break + } + case "call_global": { + const args = popArgs(instr.argc) + stack.push(evalGlobalCallValues(instr.name, args, ctx, instr.span)) + break + } + case "call_method": { + const args = popArgs(instr.argc) + const receiver = popValue() + stack.push(evalMethodCallValues(receiver, instr.name, args, ctx, instr.span)) + break + } + case "call_dynamic": { + const calleeValue = popValue() + if (calleeValue.kind === "html") { + stack.push(makeHtml(calleeValue.value)) + } else { + stack.push(makeNull()) + } + break + } + case "filter": { + const receiver = popValue() + stack.push(applyListFilter(receiver, instr.program, ctx)) + break + } + case "map": { + const receiver = popValue() + stack.push(applyListMap(receiver, instr.program, ctx)) + break + } + case "reduce": { + const receiver = popValue() + stack.push(applyListReduce(receiver, instr.program, instr.initial, ctx)) + break + } + case "jump": + ip = instr.target + continue + case "jump_if_false": { + const value = popValue() + if (!valueToBoolean(value)) { + ip = instr.target + continue + } + break + } + case "jump_if_true": { + const value = popValue() + if (valueToBoolean(value)) { + ip = instr.target + continue + } + break + } + } + ip += 1 + } + + return popValue() +} + +export const evaluateExpression = (program: ProgramIR, ctx: EvalContext): Value => + evaluateProgram(program, ctx) + +export const evaluateFilterExpression = (program: ProgramIR, ctx: EvalContext): boolean => + valueToBoolean(evaluateExpression(program, ctx)) + +export const evaluateSummaryExpression = ( + program: ProgramIR, + values: unknown[], + ctx: EvalContext, +): Value => { + const valueList = values.map(toValue) + const summaryCtx: EvalContext = { ...ctx, values: valueList } + return evaluateExpression(program, summaryCtx) +} + +const resolveIdentifier = (name: string, ctx: EvalContext): Value => { + if (name === "this") return ctx.thisFile ? makeFile(ctx.thisFile) : makeNull() + if (ctx.locals && name in ctx.locals) { + const local = ctx.locals[name] + if (isValue(local)) return local + } + if (name === "file") return makeFile(ctx.file) + if (name === "note") { + const fm = ctx.file.frontmatter + return toValue(fm) + } + if (name === "values" && ctx.values) { + return makeList(ctx.values) + } + if (name === "rows" && ctx.rows) { + return makeList(ctx.rows.map((row) => makeFile(row))) + } + if (name === "formula") { + return makeObject({}) + } + const raw: unknown = ctx.file.frontmatter ? ctx.file.frontmatter[name] : undefined + return toValue(raw) +} + +const applyUnary = (operator: "!" | "-", value: Value): Value => { + if (operator === "!") { + return makeBoolean(!valueToBoolean(value)) + } + const num = valueToNumber(value) + return Number.isFinite(num) ? makeNumber(-num) : makeNull() +} + +const applyBinary = ( + operator: BinaryExpr["operator"], + left: Value, + right: Value, + ctx: EvalContext, +): Value => { + if (operator === "==") return makeBoolean(valueEquals(left, right, ctx)) + if (operator === "!=") return makeBoolean(!valueEquals(left, right, ctx)) + + if (operator === "+" || operator === "-") { + return evalAdditive(operator, left, right) + } + if (operator === "*" || operator === "/" || operator === "%") { + if (isDurationValue(left)) { + const rightNum = valueToNumber(right) + if (!Number.isFinite(rightNum)) return makeNull() + if (operator === "*") return makeDuration(left.value * rightNum, left.months * rightNum) + if (operator === "/") { + if (rightNum === 0) return makeNull() + return makeDuration(left.value / rightNum, left.months / rightNum) + } + return makeNull() + } + if (isDurationValue(right)) return makeNull() + const leftNum = valueToNumber(left) + const rightNum = valueToNumber(right) + if (!Number.isFinite(leftNum) || !Number.isFinite(rightNum)) return makeNull() + if (operator === "*") return makeNumber(leftNum * rightNum) + if (operator === "/") return makeNumber(rightNum === 0 ? Number.NaN : leftNum / rightNum) + return makeNumber(rightNum === 0 ? Number.NaN : leftNum % rightNum) + } + + const compare = compareValues(left, right) + if (compare === null) return makeNull() + if (operator === ">") return makeBoolean(compare > 0) + if (operator === ">=") return makeBoolean(compare >= 0) + if (operator === "<") return makeBoolean(compare < 0) + if (operator === "<=") return makeBoolean(compare <= 0) + return makeNull() +} + +const evalAdditive = (operator: "+" | "-", left: Value, right: Value): Value => { + if (isDateValue(left) && isDateValue(right) && operator === "-") { + return makeDuration(left.value.getTime() - right.value.getTime()) + } + if (isDateValue(left)) { + const duration = parseDurationValue(right) + if (duration === null) return makeNull() + return makeDate(addDurationToDate(left.value, duration, operator === "+" ? 1 : -1)) + } + if (isDateValue(right) && operator === "+") { + const duration = parseDurationValue(left) + if (duration === null) return makeNull() + return makeDate(addDurationToDate(right.value, duration, 1)) + } + if (operator === "+" && (isStringValue(left) || isStringValue(right))) { + return makeString(`${valueToString(left)}${valueToString(right)}`) + } + if (isDurationValue(left) && isDurationValue(right)) { + return makeDuration( + operator === "+" ? left.value + right.value : left.value - right.value, + operator === "+" ? left.months + right.months : left.months - right.months, + ) + } + const leftNum = valueToNumber(left) + const rightNum = valueToNumber(right) + if (!Number.isFinite(leftNum) || !Number.isFinite(rightNum)) return makeNull() + return makeNumber(operator === "+" ? leftNum + rightNum : leftNum - rightNum) +} + +const compareValues = (left: Value, right: Value): number | null => { + const leftDate = coerceToDate(left) + const rightDate = coerceToDate(right) + if (leftDate && rightDate) return leftDate.getTime() - rightDate.getTime() + + const leftNum = coerceToNumber(left) + const rightNum = coerceToNumber(right) + if (leftNum !== null && rightNum !== null) return leftNum - rightNum + + if (isScalarValue(left) && isScalarValue(right)) { + const leftStr = valueToString(left) + const rightStr = valueToString(right) + if (leftStr === rightStr) return 0 + return leftStr > rightStr ? 1 : -1 + } + + return null +} + +const accessIndex = (objectValue: Value, indexValue: Value): Value => { + if (isListValue(objectValue)) { + const index = Math.trunc(valueToNumber(indexValue)) + if (!Number.isFinite(index)) return makeNull() + const item = objectValue.value[index] + return item ?? makeNull() + } + if (isObjectValue(objectValue) && isStringValue(indexValue)) { + const item = objectValue.value[indexValue.value] + return item ?? makeNull() + } + return makeNull() +} + +const evalGlobalCallValues = (name: string, args: Value[], ctx: EvalContext, span: Span): Value => { + if (name === "if") { + if (args.length < 2) { + pushRuntimeDiagnostic(ctx, "if() expects at least 2 arguments", span) + } + const condition = args[0] ?? makeNull() + if (valueToBoolean(condition)) { + return args[1] ?? makeNull() + } + return args[2] ?? makeNull() + } + if (name === "now") return makeDate(new Date()) + if (name === "today") { + const d = new Date() + d.setUTCHours(0, 0, 0, 0) + return makeDate(d) + } + if (name === "date") { + if (args.length < 1) { + pushRuntimeDiagnostic(ctx, "date() expects 1 argument", span) + } + const arg = args[0] ?? makeNull() + const str = valueToString(arg) + const parsed = new Date(str) + if (Number.isNaN(parsed.getTime())) return makeNull() + return makeDate(parsed) + } + if (name === "duration") { + if (args.length < 1) { + pushRuntimeDiagnostic(ctx, "duration() expects 1 argument", span) + } + const arg = args[0] ?? makeNull() + const parsed = parseDurationParts(valueToString(arg)) + return makeDuration(parsed.ms, parsed.months) + } + if (name === "min" || name === "max") { + if (args.length < 1) { + pushRuntimeDiagnostic(ctx, `${name}() expects at least 1 argument`, span) + } + const values = args.map((arg) => valueToNumber(arg)) + const nums = values.filter((value) => Number.isFinite(value)) + if (nums.length === 0) return makeNull() + return makeNumber(name === "min" ? Math.min(...nums) : Math.max(...nums)) + } + if (name === "number") { + if (args.length < 1) { + pushRuntimeDiagnostic(ctx, "number() expects 1 argument", span) + } + const value = args[0] ?? makeNull() + const num = valueToNumber(value) + return Number.isFinite(num) ? makeNumber(num) : makeNull() + } + if (name === "link") { + if (args.length < 1) { + pushRuntimeDiagnostic(ctx, "link() expects at least 1 argument", span) + } + const target = args[0] ?? makeNull() + const display = args[1] ?? makeNull() + const targetStr = valueToString(target) + const displayStr = valueToString(display) + return makeLink(targetStr, displayStr.length > 0 ? displayStr : undefined) + } + if (name === "list") { + if (args.length < 1) { + pushRuntimeDiagnostic(ctx, "list() expects 1 argument", span) + } + const value = args[0] ?? makeNull() + if (isListValue(value)) return value + return makeList([value]) + } + if (name === "file") { + if (args.length < 1) { + pushRuntimeDiagnostic(ctx, "file() expects 1 argument", span) + } + const arg = args[0] ?? makeNull() + const target = valueToString(arg) + const file = findFileByTarget(target, ctx) + return file ? makeFile(file) : makeNull() + } + if (name === "image") { + if (args.length < 1) { + pushRuntimeDiagnostic(ctx, "image() expects 1 argument", span) + } + const arg = args[0] ?? makeNull() + const target = valueToString(arg) + return makeImage(target) + } + if (name === "icon") { + if (args.length < 1) { + pushRuntimeDiagnostic(ctx, "icon() expects 1 argument", span) + } + const arg = args[0] ?? makeNull() + const target = valueToString(arg) + return makeIcon(target) + } + if (name === "html") { + if (args.length < 1) { + pushRuntimeDiagnostic(ctx, "html() expects 1 argument", span) + } + const arg = args[0] ?? makeNull() + const target = valueToString(arg) + return makeHtml(target) + } + if (name === "escapeHTML") { + if (args.length < 1) { + pushRuntimeDiagnostic(ctx, "escapeHTML() expects 1 argument", span) + } + const arg = args[0] ?? makeNull() + const target = valueToString(arg) + const escaped = target + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'") + return makeString(escaped) + } + pushRuntimeDiagnostic(ctx, `unknown function: ${name}`, span) + return makeNull() +} + +const evalMethodCallValues = ( + receiver: Value, + method: string, + args: Value[], + ctx: EvalContext, + span: Span, +): Value => { + if (method === "isTruthy") { + return makeBoolean(valueToBoolean(receiver)) + } + if (method === "isType") { + const arg = args[0] ?? makeNull() + const typeName = valueToString(arg).toLowerCase() + return makeBoolean(isValueType(receiver, typeName)) + } + if (method === "toString") { + return makeString(valueToString(receiver)) + } + if (receiver.kind === "null") { + if (method === "isEmpty") return makeBoolean(true) + if (method === "length") return makeNumber(0) + if ( + method === "contains" || + method === "containsAny" || + method === "containsAll" || + method === "startsWith" || + method === "endsWith" || + method === "matches" + ) { + return makeBoolean(false) + } + if (method === "asFile" || method === "asLink") { + return makeNull() + } + return makeNull() + } + + if (isStringValue(receiver)) { + if (method === "asFile") { + const file = findFileByTarget(receiver.value, ctx) + return file ? makeFile(file) : makeNull() + } + if (!stringMethods.has(method)) { + pushRuntimeDiagnostic(ctx, `unknown string method: ${method}`, span) + return makeNull() + } + return evalStringMethod(receiver, method, args) + } + if (isNumberValue(receiver)) { + if (!numberMethods.has(method)) { + pushRuntimeDiagnostic(ctx, `unknown number method: ${method}`, span) + return makeNull() + } + return evalNumberMethod(receiver, method, args) + } + if (isListValue(receiver)) { + if (!listMethods.has(method)) { + pushRuntimeDiagnostic(ctx, `unknown list method: ${method}`, span) + return makeNull() + } + return evalListMethod(receiver, method, args, ctx) + } + if (isDateValue(receiver)) { + if (!dateMethods.has(method)) { + pushRuntimeDiagnostic(ctx, `unknown date method: ${method}`, span) + return makeNull() + } + return evalDateMethod(receiver, method, args) + } + if (isFileValue(receiver)) { + if (!fileMethods.has(method)) { + pushRuntimeDiagnostic(ctx, `unknown file method: ${method}`, span) + return makeNull() + } + return evalFileMethod(receiver, method, args, ctx) + } + if (isLinkValue(receiver)) { + if (!linkMethods.has(method)) { + pushRuntimeDiagnostic(ctx, `unknown link method: ${method}`, span) + return makeNull() + } + return evalLinkMethod(receiver, method, args, ctx) + } + if (isObjectValue(receiver)) { + if (!objectMethods.has(method)) { + pushRuntimeDiagnostic(ctx, `unknown object method: ${method}`, span) + return makeNull() + } + return evalObjectMethod(receiver, method) + } + if (isRegexValue(receiver)) { + if (method === "matches") { + const value = args[0] ?? makeNull() + return makeBoolean(receiver.value.test(valueToString(value))) + } + pushRuntimeDiagnostic(ctx, `unknown regex method: ${method}`, span) + return makeNull() + } + pushRuntimeDiagnostic(ctx, `unknown ${receiver.kind} method: ${method}`, span) + return makeNull() +} + +const evalStringMethod = (receiver: StringValue, method: string, args: Value[]): Value => { + const value = receiver.value + if (method === "contains") { + const arg = args[0] ?? makeNull() + return makeBoolean(value.includes(valueToString(arg))) + } + if (method === "containsAny") { + const values = args.map((arg) => valueToString(arg)) + return makeBoolean(values.some((entry) => value.includes(entry))) + } + if (method === "containsAll") { + const values = args.map((arg) => valueToString(arg)) + return makeBoolean(values.every((entry) => value.includes(entry))) + } + if (method === "startsWith") { + const arg = args[0] ?? makeNull() + return makeBoolean(value.startsWith(valueToString(arg))) + } + if (method === "endsWith") { + const arg = args[0] ?? makeNull() + return makeBoolean(value.endsWith(valueToString(arg))) + } + if (method === "isEmpty") { + return makeBoolean(value.length === 0) + } + if (method === "lower") { + return makeString(value.toLowerCase()) + } + if (method === "title") { + const parts = value.split(/\s+/).map((part) => { + const lower = part.toLowerCase() + return lower.length > 0 ? `${lower[0].toUpperCase()}${lower.slice(1)}` : lower + }) + return makeString(parts.join(" ")) + } + if (method === "trim") { + return makeString(value.trim()) + } + if (method === "replace") { + const patternVal = args[0] ?? makeNull() + const replacementVal = args[1] ?? makeNull() + const replacement = valueToString(replacementVal) + if (isRegexValue(patternVal)) { + return makeString(value.replace(patternVal.value, replacement)) + } + return makeString(value.replace(valueToString(patternVal), replacement)) + } + if (method === "repeat") { + const count = args[0] ? valueToNumber(args[0]) : 0 + return makeString(value.repeat(Number.isFinite(count) ? Math.max(0, count) : 0)) + } + if (method === "reverse") { + return makeString(value.split("").reverse().join("")) + } + if (method === "slice") { + const start = args[0] ? valueToNumber(args[0]) : 0 + const end = args[1] ? valueToNumber(args[1]) : undefined + const startIndex = Number.isFinite(start) ? Math.trunc(start) : 0 + const endIndex = end !== undefined && Number.isFinite(end) ? Math.trunc(end) : undefined + return makeString(value.slice(startIndex, endIndex)) + } + if (method === "split") { + const separatorValue = args[0] ?? makeString("") + const separator = isRegexValue(separatorValue) + ? separatorValue.value + : valueToString(separatorValue) + const limitValue = args[1] ? valueToNumber(args[1]) : undefined + const limit = + limitValue !== undefined && Number.isFinite(limitValue) ? Math.trunc(limitValue) : undefined + const parts = limit !== undefined ? value.split(separator, limit) : value.split(separator) + return makeList(parts.map((entry) => makeString(entry))) + } + if (method === "length") { + return makeNumber(value.length) + } + return makeNull() +} + +const evalNumberMethod = (receiver: NumberValue, method: string, args: Value[]): Value => { + const value = receiver.value + if (method === "abs") return makeNumber(Math.abs(value)) + if (method === "ceil") return makeNumber(Math.ceil(value)) + if (method === "floor") return makeNumber(Math.floor(value)) + if (method === "round") { + const digits = args[0] ? valueToNumber(args[0]) : 0 + if (!Number.isFinite(digits)) return makeNumber(Math.round(value)) + const factor = 10 ** Math.trunc(digits) + return makeNumber(Math.round(value * factor) / factor) + } + if (method === "toFixed") { + const digits = args[0] ? valueToNumber(args[0]) : 0 + const precision = Number.isFinite(digits) ? Math.trunc(digits) : 0 + return makeString(value.toFixed(Math.max(0, precision))) + } + if (method === "isEmpty") { + return makeBoolean(!Number.isFinite(value)) + } + return makeNull() +} + +const evalListMethod = ( + receiver: ListValue, + method: string, + args: Value[], + ctx: EvalContext, +): Value => { + const list = receiver.value + if (method === "contains") { + const arg = args[0] ?? makeNull() + return makeBoolean(list.some((entry) => valueEquals(entry, arg, ctx))) + } + if (method === "containsAny") { + const values = args + return makeBoolean(values.some((entry) => list.some((item) => valueEquals(item, entry, ctx)))) + } + if (method === "containsAll") { + const values = args + return makeBoolean(values.every((entry) => list.some((item) => valueEquals(item, entry, ctx)))) + } + if (method === "flat") { + const flattened: Value[] = [] + for (const item of list) { + if (isListValue(item)) { + flattened.push(...item.value) + } else { + flattened.push(item) + } + } + return makeList(flattened) + } + if (method === "join") { + const separator = args[0] ? valueToString(args[0]) : "," + return makeString(list.map(valueToString).join(separator)) + } + if (method === "reverse") { + return makeList([...list].reverse()) + } + if (method === "slice") { + const start = args[0] ? valueToNumber(args[0]) : 0 + const end = args[1] ? valueToNumber(args[1]) : undefined + const startIndex = Number.isFinite(start) ? Math.trunc(start) : 0 + const endIndex = end !== undefined && Number.isFinite(end) ? Math.trunc(end) : undefined + return makeList(list.slice(startIndex, endIndex)) + } + if (method === "sort") { + const sorted = [...list].sort((a, b) => { + const aNum = valueToNumber(a) + const bNum = valueToNumber(b) + if (Number.isFinite(aNum) && Number.isFinite(bNum)) return aNum - bNum + const aStr = valueToString(a) + const bStr = valueToString(b) + if (aStr === bStr) return 0 + return aStr > bStr ? 1 : -1 + }) + return makeList(sorted) + } + if (method === "unique") { + const unique: Value[] = [] + for (const item of list) { + if (!unique.some((entry) => valueEquals(entry, item, ctx))) { + unique.push(item) + } + } + return makeList(unique) + } + if (method === "sum") { + const nums = list.map(valueToNumber).filter((value) => Number.isFinite(value)) + if (nums.length === 0) return makeNull() + return makeNumber(nums.reduce((acc, value) => acc + value, 0)) + } + if (method === "mean" || method === "average") { + const nums = list.map(valueToNumber).filter((value) => Number.isFinite(value)) + if (nums.length === 0) return makeNull() + const sum = nums.reduce((acc, value) => acc + value, 0) + return makeNumber(sum / nums.length) + } + if (method === "median") { + const nums = list.map(valueToNumber).filter((value) => Number.isFinite(value)) + if (nums.length === 0) return makeNull() + const sorted = [...nums].sort((a, b) => a - b) + const mid = Math.floor(sorted.length / 2) + if (sorted.length % 2 === 0) { + return makeNumber((sorted[mid - 1] + sorted[mid]) / 2) + } + return makeNumber(sorted[mid]) + } + if (method === "stddev") { + const nums = list.map(valueToNumber).filter((value) => Number.isFinite(value)) + if (nums.length === 0) return makeNull() + const mean = nums.reduce((acc, value) => acc + value, 0) / nums.length + const variance = nums.reduce((acc, value) => acc + (value - mean) ** 2, 0) / nums.length + return makeNumber(Math.sqrt(variance)) + } + if (method === "min" || method === "max") { + const nums = list.map(valueToNumber).filter((value) => Number.isFinite(value)) + if (nums.length === 0) return makeNull() + const value = method === "min" ? Math.min(...nums) : Math.max(...nums) + return makeNumber(value) + } + if (method === "isEmpty") { + return makeBoolean(list.length === 0) + } + if (method === "length") { + return makeNumber(list.length) + } + return makeNull() +} + +const applyListFilter = (receiver: Value, program: ProgramIR | null, ctx: EvalContext): Value => { + if (!isListValue(receiver)) return makeNull() + if (!program) return makeList(receiver.value) + const list = receiver.value + const filtered = list.filter((value, index) => { + const locals: Record = { value, index: makeNumber(index) } + const baseLocals = ctx.locals ? ctx.locals : {} + const fileCtx = isFileValue(value) + ? { + ...ctx, + file: value.value, + propertyCache: undefined, + formulaCache: undefined, + formulaStack: new Set(), + } + : ctx + const nextCtx: EvalContext = { ...fileCtx, locals: { ...baseLocals, ...locals } } + return valueToBoolean(evaluateProgram(program, nextCtx)) + }) + return makeList(filtered) +} + +const applyListMap = (receiver: Value, program: ProgramIR | null, ctx: EvalContext): Value => { + if (!isListValue(receiver)) return makeNull() + if (!program) return makeList(receiver.value) + const list = receiver.value + const mapped = list.map((value, index) => { + const locals: Record = { value, index: makeNumber(index) } + const baseLocals = ctx.locals ? ctx.locals : {} + const fileCtx = isFileValue(value) + ? { + ...ctx, + file: value.value, + propertyCache: undefined, + formulaCache: undefined, + formulaStack: new Set(), + } + : ctx + const nextCtx: EvalContext = { ...fileCtx, locals: { ...baseLocals, ...locals } } + return evaluateProgram(program, nextCtx) + }) + return makeList(mapped) +} + +const applyListReduce = ( + receiver: Value, + program: ProgramIR | null, + initial: ProgramIR | null, + ctx: EvalContext, +): Value => { + if (!isListValue(receiver)) return makeNull() + const initialValue = initial ? evaluateProgram(initial, ctx) : makeNull() + if (!program) return initialValue + let acc = initialValue + const list = receiver.value + for (let index = 0; index < list.length; index += 1) { + const value = list[index] + const locals: Record = { value, index: makeNumber(index), acc } + const baseLocals = ctx.locals ? ctx.locals : {} + const fileCtx = isFileValue(value) + ? { + ...ctx, + file: value.value, + propertyCache: undefined, + formulaCache: undefined, + formulaStack: new Set(), + } + : ctx + const nextCtx: EvalContext = { ...fileCtx, locals: { ...baseLocals, ...locals } } + acc = evaluateProgram(program, nextCtx) + } + return acc +} + +const evalDateMethod = (receiver: DateValue, method: string, args: Value[]): Value => { + const value = receiver.value + if (method === "date") { + const date = new Date(value.getTime()) + date.setUTCHours(0, 0, 0, 0) + return makeDate(date) + } + if (method === "format") { + const pattern = args[0] ? valueToString(args[0]) : "YYYY-MM-DD" + return makeString(formatDatePattern(value, pattern)) + } + if (method === "time") { + return makeString(formatTime(value)) + } + if (method === "relative") { + return makeString(formatRelative(value)) + } + if (method === "isEmpty") { + return makeBoolean(false) + } + if (method === "year") return makeNumber(value.getUTCFullYear()) + if (method === "month") return makeNumber(value.getUTCMonth() + 1) + if (method === "day") return makeNumber(value.getUTCDate()) + if (method === "hour") return makeNumber(value.getUTCHours()) + if (method === "minute") return makeNumber(value.getUTCMinutes()) + if (method === "second") return makeNumber(value.getUTCSeconds()) + if (method === "millisecond") return makeNumber(value.getUTCMilliseconds()) + return makeNull() +} + +const evalObjectMethod = (receiver: ObjectValue, method: string): Value => { + const entries = Object.entries(receiver.value) + if (method === "isEmpty") return makeBoolean(entries.length === 0) + if (method === "keys") return makeList(entries.map(([key]) => makeString(key))) + if (method === "values") return makeList(entries.map(([, value]) => value)) + return makeNull() +} + +const evalFileMethod = ( + receiver: FileValue, + method: string, + args: Value[], + ctx: EvalContext, +): Value => { + const file = receiver.value + if (method === "asLink") { + const display = args[0] ? valueToString(args[0]) : undefined + const slug = file.slug ? String(file.slug) : "" + return makeLink(slug, display) + } + if (method === "hasTag") { + const tags = args.map((arg) => valueToString(arg)) + const rawTags = file.frontmatter?.tags + const fileTags = Array.isArray(rawTags) ? rawTags : typeof rawTags === "string" ? [rawTags] : [] + return makeBoolean(tags.some((tag) => fileTags.includes(tag))) + } + if (method === "inFolder") { + const folder = args[0] ? valueToString(args[0]) : "" + const slug = file.slug ? String(file.slug) : "" + const normalized = folder.endsWith("/") ? folder : `${folder}/` + return makeBoolean(slug.startsWith(normalized)) + } + if (method === "hasProperty") { + const prop = args[0] ? valueToString(args[0]) : "" + const fm = file.frontmatter + return makeBoolean(Boolean(fm && prop in fm)) + } + if (method === "hasLink") { + const arg = args[0] ?? makeNull() + const targetSlug = resolveLinkSlugFromValue(arg, ctx) + if (!targetSlug) return makeBoolean(false) + const links = Array.isArray(file.links) ? file.links.map((link) => String(link)) : [] + return makeBoolean(links.includes(targetSlug)) + } + return makeNull() +} + +const evalLinkMethod = ( + receiver: LinkValue, + method: string, + args: Value[], + ctx: EvalContext, +): Value => { + if (method === "asFile") { + const file = findFileByTarget(receiver.value, ctx) + return file ? makeFile(file) : makeNull() + } + if (method === "linksTo") { + const arg = args[0] ?? makeNull() + const targetSlug = resolveLinkSlugFromValue(arg, ctx) + const receiverSlug = resolveLinkSlugFromText(receiver.value, ctx) + if (!targetSlug || !receiverSlug) return makeBoolean(false) + return makeBoolean(receiverSlug === targetSlug) + } + return makeNull() +} + +const resolveFormulaProperty = (name: string, ctx: EvalContext): Value => { + if (!ctx.formulas || !ctx.formulas[name]) return makeNull() + if (!ctx.formulaCache) ctx.formulaCache = new Map() + if (!ctx.formulaStack) ctx.formulaStack = new Set() + const cached = ctx.formulaCache.get(name) + if (cached) return cached + if (ctx.formulaStack.has(name)) return makeNull() + ctx.formulaStack.add(name) + const expr = ctx.formulas[name] + const nextCtx: EvalContext = { + ...ctx, + diagnosticContext: `formula.${name}`, + diagnosticSource: ctx.formulaSources?.[name] ?? ctx.diagnosticSource, + } + const value = evaluateExpression(expr, nextCtx) + ctx.formulaCache.set(name, value) + ctx.formulaStack.delete(name) + return value +} + +const resolveFileProperty = (file: QuartzPluginData, property: string, ctx: EvalContext): Value => { + if (property === "file") return makeFile(file) + if (property === "name" || property === "basename") { + const filePath = typeof file.filePath === "string" ? file.filePath : "" + const source = filePath.length > 0 ? filePath : file.slug ? String(file.slug) : "" + const segment = source.split("/").pop() || "" + if (property === "name") return makeString(segment) + const basename = segment.replace(/\.[^/.]+$/, "") + return makeString(basename) + } + if (property === "title") { + const title = typeof file.frontmatter?.title === "string" ? file.frontmatter.title : "" + if (title.length > 0) return makeString(title) + return resolveFileProperty(file, "basename", ctx) + } + if (property === "path") { + const path = file.filePath || file.slug || "" + return makeString(String(path)) + } + if (property === "folder") { + const slug = file.slug ? String(file.slug) : "" + const parts = slug.split("/") + const folder = parts.length > 1 ? parts.slice(0, -1).join("/") : "" + return makeString(folder) + } + if (property === "ext") { + const filePath = typeof file.filePath === "string" ? file.filePath : "" + const slug = file.slug ? String(file.slug) : "" + const source = filePath.length > 0 ? filePath : slug + const match = source.match(/\.([^.]+)$/) + return makeString(match ? match[1] : "md") + } + if (property === "size") { + if (isRecord(file) && typeof file.size === "number") { + return makeNumber(file.size) + } + return makeNull() + } + if (property === "ctime") { + const created = file.dates?.created + if (!created) return makeNull() + return makeDate(new Date(created)) + } + if (property === "mtime") { + const modified = file.dates?.modified + if (!modified) return makeNull() + return makeDate(new Date(modified)) + } + if (property === "tags") { + const rawTags = file.frontmatter?.tags + const tags = Array.isArray(rawTags) ? rawTags : typeof rawTags === "string" ? [rawTags] : [] + return makeList(tags.map((tag) => makeString(String(tag)))) + } + if (property === "aliases") { + const aliases = file.frontmatter?.aliases + if (!aliases) return makeList([]) + const list = Array.isArray(aliases) ? aliases : [aliases] + return makeList(list.map((alias) => makeString(String(alias)))) + } + if (property === "links" || property === "outlinks") { + const links = Array.isArray(file.links) ? file.links : [] + return makeList(links.map((link) => makeLink(String(link)))) + } + if (property === "backlinks" || property === "inlinks") { + const slug = file.slug ? String(file.slug) : "" + const key = simplifySlug(slug as FullSlug) + const backlinks = + ctx.backlinksIndex?.get(key) ?? + ctx.allFiles + .filter((entry) => { + const links = Array.isArray(entry.links) ? entry.links.map((link) => String(link)) : [] + return key.length > 0 && links.includes(key) + }) + .map((entry) => (entry.slug ? simplifySlug(entry.slug as FullSlug) : "")) + .filter((entry) => entry.length > 0) + return makeList(backlinks.map((link) => makeLink(String(link)))) + } + if (property === "embeds") { + const embeds = isRecord(file) && Array.isArray(file.embeds) ? file.embeds : [] + return makeList(embeds.map((entry) => makeString(String(entry)))) + } + if (property === "properties") { + return toValue(file.frontmatter) + } + if (property === "link") { + const slug = file.slug ? String(file.slug) : "" + return makeLink(slug) + } + const raw: unknown = file.frontmatter ? file.frontmatter[property] : undefined + return toValue(raw) +} + +const accessProperty = (value: Value, property: string, ctx: EvalContext): Value => { + if (isStringValue(value) && property === "length") return makeNumber(value.value.length) + if (isListValue(value) && property === "length") return makeNumber(value.value.length) + if (isDateValue(value)) { + if (property === "year") return makeNumber(value.value.getUTCFullYear()) + if (property === "month") return makeNumber(value.value.getUTCMonth() + 1) + if (property === "day") return makeNumber(value.value.getUTCDate()) + if (property === "hour") return makeNumber(value.value.getUTCHours()) + if (property === "minute") return makeNumber(value.value.getUTCMinutes()) + if (property === "second") return makeNumber(value.value.getUTCSeconds()) + if (property === "millisecond") return makeNumber(value.value.getUTCMilliseconds()) + } + if (isObjectValue(value)) { + return value.value[property] ?? makeNull() + } + if (isFileValue(value)) { + return resolveFileProperty(value.value, property, ctx) + } + if (isLinkValue(value)) { + if (property === "value") return makeString(value.value) + } + return makeNull() +} + +const isValueType = (value: Value, typeName: string): boolean => { + if (typeName === "null" || typeName === "undefined") return value.kind === "null" + if (typeName === "string") return value.kind === "string" + if (typeName === "number") return value.kind === "number" + if (typeName === "boolean") return value.kind === "boolean" + if (typeName === "array" || typeName === "list") return value.kind === "list" + if (typeName === "object") return value.kind === "object" + if (typeName === "date") return value.kind === "date" + if (typeName === "duration") return value.kind === "duration" + if (typeName === "file") return value.kind === "file" + if (typeName === "link") return value.kind === "link" + return false +} + +const resolveFileSlug = (file: QuartzPluginData): string | undefined => { + if (!file.slug) return undefined + return simplifySlug(file.slug as FullSlug) +} + +const normalizeLinkText = (value: string): string => value.trim() + +const resolveLinkSlugFromText = (raw: string, ctx: EvalContext): string | undefined => { + const trimmed = raw.trim() + if (!trimmed) return undefined + if (/^[a-z][a-z0-9+.-]*:/.test(trimmed)) return undefined + const currentSlug = ctx.file.slug + ? String(ctx.file.slug) + : ctx.thisFile?.slug + ? String(ctx.thisFile.slug) + : undefined + const parsed = parseWikilink(trimmed) + if (parsed) { + if (/^[a-z][a-z0-9+.-]*:/.test(parsed.target)) return undefined + if (currentSlug) { + const resolved = resolveWikilinkTarget(parsed, currentSlug as FullSlug) + return resolved ? simplifySlug(resolved.slug) : undefined + } + const parsedTarget = parsed.target.trim() + if (!parsedTarget) return undefined + const slug = slugifyFilePath(parsedTarget as FilePath) + return simplifySlug(slug) + } + const [target, anchor] = splitAnchor(trimmed) + if (currentSlug) { + const resolved = resolveWikilinkTarget( + { + raw: trimmed, + target, + anchor: anchor.length > 0 ? anchor : undefined, + alias: undefined, + embed: trimmed.startsWith("!"), + }, + currentSlug as FullSlug, + ) + if (resolved) return simplifySlug(resolved.slug) + } + if (!target) return undefined + const normalized = target.replace(/\\/g, "/").replace(/^\/+/, "") + if (!normalized) return undefined + const slug = slugifyFilePath(normalized as FilePath) + return simplifySlug(slug) +} + +const resolveLinkSlugFromValue = (value: Value, ctx: EvalContext): string | undefined => { + if (isFileValue(value)) return resolveFileSlug(value.value) + if (isLinkValue(value)) return resolveLinkSlugFromText(value.value, ctx) + if (isStringValue(value)) return resolveLinkSlugFromText(value.value, ctx) + return undefined +} + +const resolveLinkComparisonKey = ( + value: Value, + ctx: EvalContext, +): { slug?: string; text: string } => { + if (isFileValue(value)) { + const slug = resolveFileSlug(value.value) + return { slug, text: slug ?? "" } + } + if (isLinkValue(value)) { + const slug = resolveLinkSlugFromText(value.value, ctx) + const text = value.display && value.display.length > 0 ? value.display : value.value + return { slug, text: normalizeLinkText(text) } + } + if (isStringValue(value)) { + const slug = resolveLinkSlugFromText(value.value, ctx) + return { slug, text: normalizeLinkText(value.value) } + } + return { text: normalizeLinkText(valueToString(value)) } +} + +const findFileByTarget = (target: string, ctx: EvalContext): QuartzPluginData | undefined => { + const slug = resolveLinkSlugFromText(target, ctx) + if (!slug) return undefined + const indexed = ctx.fileIndex?.get(slug) + if (indexed) return indexed + return ctx.allFiles.find((entry) => resolveFileSlug(entry) === slug) +} diff --git a/quartz/util/base/compiler/ir.ts b/quartz/util/base/compiler/ir.ts new file mode 100644 index 000000000..0d336f6ab --- /dev/null +++ b/quartz/util/base/compiler/ir.ts @@ -0,0 +1,164 @@ +import { BinaryExpr, Expr, Literal, Span, UnaryExpr } from "./ast" + +export type JumpInstruction = { + op: "jump" | "jump_if_false" | "jump_if_true" + target: number + span: Span +} + +export type Instruction = + | { op: "const"; literal: Literal; span: Span } + | { op: "ident"; name: string; span: Span } + | { op: "load_formula"; name: string; span: Span } + | { op: "load_formula_index"; span: Span } + | { op: "member"; property: string; span: Span } + | { op: "index"; span: Span } + | { op: "list"; count: number; span: Span } + | { op: "unary"; operator: UnaryExpr["operator"]; span: Span } + | { op: "binary"; operator: BinaryExpr["operator"]; span: Span } + | { op: "to_bool"; span: Span } + | { op: "call_global"; name: string; argc: number; span: Span } + | { op: "call_method"; name: string; argc: number; span: Span } + | { op: "call_dynamic"; span: Span } + | { op: "filter"; program: ProgramIR | null; span: Span } + | { op: "map"; program: ProgramIR | null; span: Span } + | { op: "reduce"; program: ProgramIR | null; initial: ProgramIR | null; span: Span } + | JumpInstruction + +export type ProgramIR = { instructions: Instruction[]; span: Span } + +const compileExpr = (expr: Expr, out: Instruction[]) => { + switch (expr.type) { + case "Literal": + out.push({ op: "const", literal: expr, span: expr.span }) + return + case "Identifier": + out.push({ op: "ident", name: expr.name, span: expr.span }) + return + case "UnaryExpr": + compileExpr(expr.argument, out) + out.push({ op: "unary", operator: expr.operator, span: expr.span }) + return + case "BinaryExpr": + compileExpr(expr.left, out) + compileExpr(expr.right, out) + out.push({ op: "binary", operator: expr.operator, span: expr.span }) + return + case "LogicalExpr": { + if (expr.operator === "&&") { + compileExpr(expr.left, out) + const jumpFalse: JumpInstruction = { op: "jump_if_false", target: -1, span: expr.span } + out.push(jumpFalse) + compileExpr(expr.right, out) + out.push({ op: "to_bool", span: expr.span }) + const jumpEnd: JumpInstruction = { op: "jump", target: -1, span: expr.span } + out.push(jumpEnd) + const falseTarget = out.length + jumpFalse.target = falseTarget + out.push({ + op: "const", + literal: { type: "Literal", kind: "boolean", value: false, span: expr.span }, + span: expr.span, + }) + jumpEnd.target = out.length + return + } + compileExpr(expr.left, out) + const jumpTrue: JumpInstruction = { op: "jump_if_true", target: -1, span: expr.span } + out.push(jumpTrue) + compileExpr(expr.right, out) + out.push({ op: "to_bool", span: expr.span }) + const jumpEnd: JumpInstruction = { op: "jump", target: -1, span: expr.span } + out.push(jumpEnd) + const trueTarget = out.length + jumpTrue.target = trueTarget + out.push({ + op: "const", + literal: { type: "Literal", kind: "boolean", value: true, span: expr.span }, + span: expr.span, + }) + jumpEnd.target = out.length + return + } + case "MemberExpr": + if (expr.object.type === "Identifier" && expr.object.name === "formula") { + out.push({ op: "load_formula", name: expr.property, span: expr.span }) + return + } + compileExpr(expr.object, out) + out.push({ op: "member", property: expr.property, span: expr.span }) + return + case "IndexExpr": + if (expr.object.type === "Identifier" && expr.object.name === "formula") { + compileExpr(expr.index, out) + out.push({ op: "load_formula_index", span: expr.span }) + return + } + compileExpr(expr.object, out) + compileExpr(expr.index, out) + out.push({ op: "index", span: expr.span }) + return + case "ListExpr": + for (const element of expr.elements) { + compileExpr(element, out) + } + out.push({ op: "list", count: expr.elements.length, span: expr.span }) + return + case "CallExpr": { + if (expr.callee.type === "Identifier") { + for (const arg of expr.args) { + compileExpr(arg, out) + } + out.push({ + op: "call_global", + name: expr.callee.name, + argc: expr.args.length, + span: expr.span, + }) + return + } + if (expr.callee.type === "MemberExpr") { + const method = expr.callee.property + if (method === "filter" || method === "map" || method === "reduce") { + compileExpr(expr.callee.object, out) + const exprArg = expr.args[0] + const program = exprArg ? compileExpression(exprArg) : null + if (method === "filter") { + out.push({ op: "filter", program, span: expr.span }) + return + } + if (method === "map") { + out.push({ op: "map", program, span: expr.span }) + return + } + const initialArg = expr.args[1] + const initial = initialArg ? compileExpression(initialArg) : null + out.push({ op: "reduce", program, initial, span: expr.span }) + return + } + compileExpr(expr.callee.object, out) + for (const arg of expr.args) { + compileExpr(arg, out) + } + out.push({ op: "call_method", name: method, argc: expr.args.length, span: expr.span }) + return + } + compileExpr(expr.callee, out) + out.push({ op: "call_dynamic", span: expr.span }) + return + } + case "ErrorExpr": + out.push({ + op: "const", + literal: { type: "Literal", kind: "null", value: null, span: expr.span }, + span: expr.span, + }) + return + } +} + +export const compileExpression = (expr: Expr): ProgramIR => { + const instructions: Instruction[] = [] + compileExpr(expr, instructions) + return { instructions, span: expr.span } +} diff --git a/quartz/util/base/compiler/lexer.test.ts b/quartz/util/base/compiler/lexer.test.ts new file mode 100644 index 000000000..9fadf681e --- /dev/null +++ b/quartz/util/base/compiler/lexer.test.ts @@ -0,0 +1,53 @@ +import assert from "node:assert" +import test from "node:test" +import { lex } from "./lexer" + +test("lexes bracket access with hyphenated keys", () => { + const result = lex('note["my-field"]') + const types = result.tokens.map((token) => token.type) + assert.deepStrictEqual(types, ["identifier", "punctuation", "string", "punctuation", "eof"]) + const value = result.tokens[2] + if (value.type !== "string") { + throw new Error("expected string token") + } + assert.strictEqual(value.value, "my-field") +}) + +test("lexes bracket access with escaped quotes", () => { + const result = lex('note["my\\\"field"]') + const value = result.tokens.find((token) => token.type === "string") + if (!value || value.type !== "string") { + throw new Error("expected string token") + } + assert.strictEqual(value.value, 'my"field') +}) + +test("lexes regex literals with flags", () => { + const result = lex('name.replace(/:/g, "-")') + const regexToken = result.tokens.find((token) => token.type === "regex") + if (!regexToken || regexToken.type !== "regex") { + throw new Error("expected regex token") + } + assert.strictEqual(regexToken.pattern, ":") + assert.strictEqual(regexToken.flags, "g") +}) + +test("lexes regex literals with escaped slashes", () => { + const result = lex("path.matches(/\\//)") + const regexToken = result.tokens.find((token) => token.type === "regex") + if (!regexToken || regexToken.type !== "regex") { + throw new Error("expected regex token") + } + assert.strictEqual(regexToken.pattern, "\\/") + assert.strictEqual(regexToken.flags, "") +}) + +test("lexes division as operator, not regex", () => { + const result = lex("a / b") + const operatorToken = result.tokens.find( + (token) => token.type === "operator" && token.value === "/", + ) + assert.ok(operatorToken) + const regexToken = result.tokens.find((token) => token.type === "regex") + assert.strictEqual(regexToken, undefined) +}) diff --git a/quartz/util/base/compiler/lexer.ts b/quartz/util/base/compiler/lexer.ts new file mode 100644 index 000000000..face6cd70 --- /dev/null +++ b/quartz/util/base/compiler/lexer.ts @@ -0,0 +1,300 @@ +import { Position, Span } from "./ast" +import { Diagnostic } from "./errors" +import { + Operator, + Punctuation, + Token, + StringToken, + RegexToken, + NumberToken, + BooleanToken, + NullToken, + ThisToken, + IdentifierToken, + OperatorToken, + PunctuationToken, + EofToken, +} from "./tokens" + +type LexResult = { tokens: Token[]; diagnostics: Diagnostic[] } + +const operatorTokens: Operator[] = [ + "==", + "!=", + ">=", + "<=", + "&&", + "||", + "+", + "-", + "*", + "/", + "%", + "!", + ">", + "<", +] + +const punctuationTokens: Punctuation[] = [".", ",", "(", ")", "[", "]"] + +const isOperator = (value: string): value is Operator => + operatorTokens.some((token) => token === value) + +const isPunctuation = (value: string): value is Punctuation => + punctuationTokens.some((token) => token === value) + +export function lex(input: string, file?: string): LexResult { + const tokens: Token[] = [] + const diagnostics: Diagnostic[] = [] + let index = 0 + let line = 1 + let column = 1 + let canStartRegex = true + + const makePosition = (offset: number, lineValue: number, columnValue: number): Position => ({ + offset, + line: lineValue, + column: columnValue, + }) + + const currentPosition = (): Position => makePosition(index, line, column) + + const makeSpan = (start: Position, end: Position): Span => ({ start, end, file }) + + const advance = (): string => { + const ch = input[index] + index += 1 + if (ch === "\n") { + line += 1 + column = 1 + } else { + column += 1 + } + return ch + } + + const peek = (offset = 0): string => input[index + offset] ?? "" + + const addDiagnostic = (message: string, span: Span) => { + diagnostics.push({ kind: "lex", message, span }) + } + + const updateRegexState = (token: Token | null) => { + if (!token) { + canStartRegex = true + return + } + if (token.type === "operator") { + canStartRegex = true + return + } + if (token.type === "punctuation") { + canStartRegex = token.value === "(" || token.value === "[" || token.value === "," + return + } + canStartRegex = false + } + + const isWhitespace = (ch: string) => ch === " " || ch === "\t" || ch === "\n" || ch === "\r" + const isDigit = (ch: string) => ch >= "0" && ch <= "9" + const isIdentStart = (ch: string) => + (ch >= "a" && ch <= "z") || (ch >= "A" && ch <= "Z") || ch === "_" + const isIdentContinue = (ch: string) => isIdentStart(ch) || isDigit(ch) + + while (index < input.length) { + const ch = peek() + + if (isWhitespace(ch)) { + advance() + continue + } + + const start = currentPosition() + + if (ch === "=" && peek(1) !== "=") { + let offset = 1 + while (isWhitespace(peek(offset))) { + offset += 1 + } + if (peek(offset) === ">") { + advance() + for (let step = 1; step < offset; step += 1) { + advance() + } + if (peek() === ">") { + advance() + } + const end = currentPosition() + addDiagnostic( + "arrow functions are not supported, use list.filter(expression)", + makeSpan(start, end), + ) + continue + } + } + + if (ch === '"' || ch === "'") { + const quote = advance() + let value = "" + let closed = false + + while (index < input.length) { + const curr = advance() + if (curr === quote) { + closed = true + break + } + if (curr === "\\") { + const next = advance() + if (next === "n") value += "\n" + else if (next === "t") value += "\t" + else if (next === "r") value += "\r" + else if (next === "\\" || next === "'" || next === '"') value += next + else value += next + } else { + value += curr + } + } + + const end = currentPosition() + const span = makeSpan(start, end) + if (!closed) addDiagnostic("unterminated string literal", span) + const token: StringToken = { type: "string", value, span } + tokens.push(token) + updateRegexState(token) + continue + } + + if (ch === "/" && canStartRegex) { + const next = peek(1) + if (next !== "/" && next !== "") { + advance() + let pattern = "" + let closed = false + let inClass = false + while (index < input.length) { + const curr = advance() + if (curr === "\\" && index < input.length) { + const escaped = advance() + pattern += `\\${escaped}` + continue + } + if (curr === "[" && !inClass) inClass = true + if (curr === "]" && inClass) inClass = false + if (curr === "/" && !inClass) { + closed = true + break + } + pattern += curr + } + let flags = "" + while (index < input.length) { + const flag = peek() + if (!/^[gimsuy]$/.test(flag)) break + flags += advance() + } + const end = currentPosition() + const span = makeSpan(start, end) + if (!closed) addDiagnostic("unterminated regex literal", span) + const token: RegexToken = { type: "regex", pattern, flags, span } + tokens.push(token) + updateRegexState(token) + continue + } + } + + if (isDigit(ch)) { + let num = "" + while (index < input.length && isDigit(peek())) { + num += advance() + } + if (peek() === "." && isDigit(peek(1))) { + num += advance() + while (index < input.length && isDigit(peek())) { + num += advance() + } + } + const end = currentPosition() + const span = makeSpan(start, end) + const token: NumberToken = { type: "number", value: Number(num), span } + tokens.push(token) + updateRegexState(token) + continue + } + + if (isIdentStart(ch)) { + let ident = "" + while (index < input.length && isIdentContinue(peek())) { + ident += advance() + } + const end = currentPosition() + const span = makeSpan(start, end) + if (ident === "true" || ident === "false") { + const token: BooleanToken = { type: "boolean", value: ident === "true", span } + tokens.push(token) + updateRegexState(token) + continue + } + if (ident === "null") { + const token: NullToken = { type: "null", span } + tokens.push(token) + updateRegexState(token) + continue + } + if (ident === "this") { + const token: ThisToken = { type: "this", span } + tokens.push(token) + updateRegexState(token) + continue + } + const token: IdentifierToken = { type: "identifier", value: ident, span } + tokens.push(token) + updateRegexState(token) + continue + } + + const twoChar = ch + peek(1) + if (isOperator(twoChar)) { + advance() + advance() + const end = currentPosition() + const span = makeSpan(start, end) + const token: OperatorToken = { type: "operator", value: twoChar, span } + tokens.push(token) + updateRegexState(token) + continue + } + + if (isOperator(ch)) { + advance() + const end = currentPosition() + const span = makeSpan(start, end) + const token: OperatorToken = { type: "operator", value: ch, span } + tokens.push(token) + updateRegexState(token) + continue + } + + if (isPunctuation(ch)) { + advance() + const end = currentPosition() + const span = makeSpan(start, end) + const token: PunctuationToken = { type: "punctuation", value: ch, span } + tokens.push(token) + updateRegexState(token) + continue + } + + advance() + const end = currentPosition() + addDiagnostic(`unexpected character: ${ch}`, makeSpan(start, end)) + } + + const eofPos = currentPosition() + const eofSpan = makeSpan(eofPos, eofPos) + const eofToken: EofToken = { type: "eof", span: eofSpan } + tokens.push(eofToken) + updateRegexState(eofToken) + + return { tokens, diagnostics } +} diff --git a/quartz/util/base/compiler/parser.test.ts b/quartz/util/base/compiler/parser.test.ts new file mode 100644 index 000000000..b48cbd332 --- /dev/null +++ b/quartz/util/base/compiler/parser.test.ts @@ -0,0 +1,261 @@ +import assert from "node:assert" +import test from "node:test" +import { parseExpressionSource } from "./parser" + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null + +const strip = (node: unknown): unknown => { + if (!isRecord(node)) return node + const type = node.type + if (type === "Identifier") { + return { type, name: node.name } + } + if (type === "Literal") { + const kind = node.kind + const value = node.value + const flags = node.flags + return flags !== undefined ? { type, kind, value, flags } : { type, kind, value } + } + if (type === "UnaryExpr") { + return { type, operator: node.operator, argument: strip(node.argument) } + } + if (type === "BinaryExpr" || type === "LogicalExpr") { + return { type, operator: node.operator, left: strip(node.left), right: strip(node.right) } + } + if (type === "CallExpr") { + const args = Array.isArray(node.args) ? node.args.map(strip) : [] + return { type, callee: strip(node.callee), args } + } + if (type === "MemberExpr") { + return { type, object: strip(node.object), property: node.property } + } + if (type === "IndexExpr") { + return { type, object: strip(node.object), index: strip(node.index) } + } + if (type === "ListExpr") { + const elements = Array.isArray(node.elements) ? node.elements.map(strip) : [] + return { type, elements } + } + if (type === "ErrorExpr") { + return { type, message: node.message } + } + return node +} + +test("ebnf to ast mapping snapshots", () => { + const cases: Array<{ source: string; expected: unknown }> = [ + { + source: 'status == "done"', + expected: { + type: "BinaryExpr", + operator: "==", + left: { type: "Identifier", name: "status" }, + right: { type: "Literal", kind: "string", value: "done" }, + }, + }, + { + source: "!done", + expected: { + type: "UnaryExpr", + operator: "!", + argument: { type: "Identifier", name: "done" }, + }, + }, + { + source: "file.ctime", + expected: { + type: "MemberExpr", + object: { type: "Identifier", name: "file" }, + property: "ctime", + }, + }, + { + source: 'note["my-field"]', + expected: { + type: "IndexExpr", + object: { type: "Identifier", name: "note" }, + index: { type: "Literal", kind: "string", value: "my-field" }, + }, + }, + { + source: "date(due) < today()", + expected: { + type: "BinaryExpr", + operator: "<", + left: { + type: "CallExpr", + callee: { type: "Identifier", name: "date" }, + args: [{ type: "Identifier", name: "due" }], + }, + right: { type: "CallExpr", callee: { type: "Identifier", name: "today" }, args: [] }, + }, + }, + { + source: "now() - file.ctime", + expected: { + type: "BinaryExpr", + operator: "-", + left: { type: "CallExpr", callee: { type: "Identifier", name: "now" }, args: [] }, + right: { + type: "MemberExpr", + object: { type: "Identifier", name: "file" }, + property: "ctime", + }, + }, + }, + { + source: "(pages * 2).round(0)", + expected: { + type: "CallExpr", + callee: { + type: "MemberExpr", + object: { + type: "BinaryExpr", + operator: "*", + left: { type: "Identifier", name: "pages" }, + right: { type: "Literal", kind: "number", value: 2 }, + }, + property: "round", + }, + args: [{ type: "Literal", kind: "number", value: 0 }], + }, + }, + { + source: 'tags.containsAny("a","b")', + expected: { + type: "CallExpr", + callee: { + type: "MemberExpr", + object: { type: "Identifier", name: "tags" }, + property: "containsAny", + }, + args: [ + { type: "Literal", kind: "string", value: "a" }, + { type: "Literal", kind: "string", value: "b" }, + ], + }, + }, + { + source: "list(links).filter(value.isTruthy())", + expected: { + type: "CallExpr", + callee: { + type: "MemberExpr", + object: { + type: "CallExpr", + callee: { type: "Identifier", name: "list" }, + args: [{ type: "Identifier", name: "links" }], + }, + property: "filter", + }, + args: [ + { + type: "CallExpr", + callee: { + type: "MemberExpr", + object: { type: "Identifier", name: "value" }, + property: "isTruthy", + }, + args: [], + }, + ], + }, + }, + { + source: '["a", "b", "c"].length', + expected: { + type: "MemberExpr", + object: { + type: "ListExpr", + elements: [ + { type: "Literal", kind: "string", value: "a" }, + { type: "Literal", kind: "string", value: "b" }, + { type: "Literal", kind: "string", value: "c" }, + ], + }, + property: "length", + }, + }, + { + source: "this.file.name", + expected: { + type: "MemberExpr", + object: { + type: "MemberExpr", + object: { type: "Identifier", name: "this" }, + property: "file", + }, + property: "name", + }, + }, + { + source: "a || b && c", + expected: { + type: "LogicalExpr", + operator: "||", + left: { type: "Identifier", name: "a" }, + right: { + type: "LogicalExpr", + operator: "&&", + left: { type: "Identifier", name: "b" }, + right: { type: "Identifier", name: "c" }, + }, + }, + }, + { + source: "values[0]", + expected: { + type: "IndexExpr", + object: { type: "Identifier", name: "values" }, + index: { type: "Literal", kind: "number", value: 0 }, + }, + }, + ] + + for (const entry of cases) { + const result = parseExpressionSource(entry.source) + assert.strictEqual(result.diagnostics.length, 0) + assert.deepStrictEqual(strip(result.program.body), entry.expected) + } +}) + +test("syntax doc samples parse", () => { + const samples = [ + 'note["price"]', + "file.size > 10", + "file.hasLink(this.file)", + 'date("2024-12-01") + "1M" + "4h" + "3m"', + "now() - file.ctime", + "property[0]", + 'link("filename", icon("plus"))', + 'file.mtime > now() - "1 week"', + '/abc/.matches("abcde")', + 'name.replace(/:/g, "-")', + 'values.filter(value.isType("number")).reduce(if(acc == null || value > acc, value, acc), null)', + ] + + for (const source of samples) { + const result = parseExpressionSource(source) + assert.strictEqual(result.diagnostics.length, 0) + assert.ok(result.program.body) + } +}) + +test("string escapes are decoded", () => { + const result = parseExpressionSource('"a\\n\\"b"') + assert.strictEqual(result.diagnostics.length, 0) + const literal = strip(result.program.body) + if (!isRecord(literal)) { + throw new Error("expected literal record") + } + assert.strictEqual(literal.type, "Literal") + assert.strictEqual(literal.kind, "string") + assert.strictEqual(literal.value, 'a\n"b') +}) + +test("parser reports errors and recovers", () => { + const result = parseExpressionSource("status ==") + assert.ok(result.diagnostics.length > 0) + assert.ok(result.program.body) +}) diff --git a/quartz/util/base/compiler/parser.ts b/quartz/util/base/compiler/parser.ts new file mode 100644 index 000000000..93f3f1006 --- /dev/null +++ b/quartz/util/base/compiler/parser.ts @@ -0,0 +1,370 @@ +import { + BinaryExpr, + CallExpr, + ErrorExpr, + Expr, + Identifier, + IndexExpr, + ListExpr, + Literal, + LogicalExpr, + MemberExpr, + Program, + UnaryExpr, + spanFrom, +} from "./ast" +import { Diagnostic } from "./errors" +import { lex } from "./lexer" +import { Operator, Token } from "./tokens" + +export type ParseResult = { program: Program; tokens: Token[]; diagnostics: Diagnostic[] } + +type InfixInfo = { lbp: number; rbp: number; kind: "binary" | "logical" } + +const infixBindingPowers: Record = { + "||": { lbp: 1, rbp: 2, kind: "logical" }, + "&&": { lbp: 3, rbp: 4, kind: "logical" }, + "==": { lbp: 5, rbp: 6, kind: "binary" }, + "!=": { lbp: 5, rbp: 6, kind: "binary" }, + ">": { lbp: 7, rbp: 8, kind: "binary" }, + ">=": { lbp: 7, rbp: 8, kind: "binary" }, + "<": { lbp: 7, rbp: 8, kind: "binary" }, + "<=": { lbp: 7, rbp: 8, kind: "binary" }, + "+": { lbp: 9, rbp: 10, kind: "binary" }, + "-": { lbp: 9, rbp: 10, kind: "binary" }, + "*": { lbp: 11, rbp: 12, kind: "binary" }, + "/": { lbp: 11, rbp: 12, kind: "binary" }, + "%": { lbp: 11, rbp: 12, kind: "binary" }, +} + +const isLogicalOperator = (value: Operator): value is LogicalExpr["operator"] => + value === "&&" || value === "||" + +const isBinaryOperator = (value: Operator): value is BinaryExpr["operator"] => + value === "+" || + value === "-" || + value === "*" || + value === "/" || + value === "%" || + value === "==" || + value === "!=" || + value === ">" || + value === ">=" || + value === "<" || + value === "<=" + +export function parseExpressionSource(source: string, file?: string): ParseResult { + const { tokens, diagnostics } = lex(source, file) + const parser = new Parser(tokens, diagnostics) + const program = parser.parseProgram() + return { program, tokens, diagnostics } +} + +class Parser { + private tokens: Token[] + private index: number + private diagnostics: Diagnostic[] + + constructor(tokens: Token[], diagnostics: Diagnostic[]) { + this.tokens = tokens + this.index = 0 + this.diagnostics = diagnostics + } + + parseProgram(): Program { + const start = this.tokens[0]?.span ?? this.tokens[this.tokens.length - 1].span + const body = this.peek().type === "eof" ? null : this.parseExpression(0) + const end = this.tokens[this.tokens.length - 1]?.span ?? start + return { type: "Program", body, span: spanFrom(start, end) } + } + + private parseExpression(minBp: number): Expr { + let left = this.parsePrefix() + left = this.parsePostfix(left) + + while (true) { + const token = this.peek() + if (token.type !== "operator") break + const info = infixBindingPowers[token.value] + if (!info || info.lbp < minBp) break + this.advance() + const right = this.parseExpression(info.rbp) + const span = spanFrom(left.span, right.span) + if (info.kind === "logical" && isLogicalOperator(token.value)) { + left = { type: "LogicalExpr", operator: token.value, left, right, span } + } else if (info.kind === "binary" && isBinaryOperator(token.value)) { + left = { type: "BinaryExpr", operator: token.value, left, right, span } + } else { + this.error("unexpected operator", token.span) + } + } + + return left + } + + private parsePrefix(): Expr { + const token = this.peek() + if (token.type === "operator" && (token.value === "!" || token.value === "-")) { + this.advance() + const argument = this.parseExpression(13) + const span = spanFrom(token.span, argument.span) + const node: UnaryExpr = { type: "UnaryExpr", operator: token.value, argument, span } + return node + } + return this.parsePrimary() + } + + private parsePostfix(expr: Expr): Expr { + let current = expr + while (true) { + const token = this.peek() + if (token.type === "punctuation" && token.value === ".") { + this.advance() + const propToken = this.peek() + if (propToken.type !== "identifier") { + this.error("expected identifier after '.'", propToken.span) + return current + } + this.advance() + const span = spanFrom(current.span, propToken.span) + const node: MemberExpr = { + type: "MemberExpr", + object: current, + property: propToken.value, + span, + } + current = node + continue + } + + if (token.type === "punctuation" && token.value === "[") { + this.advance() + const indexExpr = this.parseExpression(0) + const endToken = this.peek() + if (!(endToken.type === "punctuation" && endToken.value === "]")) { + this.error("expected ']'", endToken.span) + this.syncTo("]") + } else { + this.advance() + } + const span = spanFrom(current.span, endToken.span) + const node: IndexExpr = { type: "IndexExpr", object: current, index: indexExpr, span } + current = node + continue + } + + if (token.type === "punctuation" && token.value === "(") { + this.advance() + const args: Expr[] = [] + while (this.peek().type !== "eof") { + const next = this.peek() + if (next.type === "punctuation" && next.value === ")") { + this.advance() + break + } + const arg = this.parseExpression(0) + args.push(arg) + const sep = this.peek() + if (sep.type === "punctuation" && sep.value === ",") { + this.advance() + const maybeClose = this.peek() + if (maybeClose.type === "punctuation" && maybeClose.value === ")") { + this.advance() + break + } + continue + } + if (sep.type === "punctuation" && sep.value === ")") { + this.advance() + break + } + this.error("expected ',' or ')'", sep.span) + this.syncTo(")") + const maybeClose = this.peek() + if (maybeClose.type === "punctuation" && maybeClose.value === ")") { + this.advance() + } + break + } + const endToken = this.previous() + const span = spanFrom(current.span, endToken.span) + const node: CallExpr = { type: "CallExpr", callee: current, args, span } + current = node + continue + } + + break + } + return current + } + + private parsePrimary(): Expr { + const token = this.peek() + + if (token.type === "number") { + this.advance() + const node: Literal = { + type: "Literal", + kind: "number", + value: token.value, + span: token.span, + } + return node + } + + if (token.type === "string") { + this.advance() + const node: Literal = { + type: "Literal", + kind: "string", + value: token.value, + span: token.span, + } + return node + } + + if (token.type === "boolean") { + this.advance() + const node: Literal = { + type: "Literal", + kind: "boolean", + value: token.value, + span: token.span, + } + return node + } + + if (token.type === "null") { + this.advance() + const node: Literal = { type: "Literal", kind: "null", value: null, span: token.span } + return node + } + + if (token.type === "regex") { + this.advance() + const node: Literal = { + type: "Literal", + kind: "regex", + value: token.pattern, + flags: token.flags, + span: token.span, + } + return node + } + + if (token.type === "identifier") { + this.advance() + const node: Identifier = { type: "Identifier", name: token.value, span: token.span } + return node + } + + if (token.type === "this") { + this.advance() + const node: Identifier = { type: "Identifier", name: "this", span: token.span } + return node + } + + if (token.type === "punctuation" && token.value === "(") { + this.advance() + const expr = this.parseExpression(0) + const closeToken = this.peek() + if (closeToken.type === "punctuation" && closeToken.value === ")") { + this.advance() + } else { + this.error("expected ')'", closeToken.span) + this.syncTo(")") + const maybeClose = this.peek() + if (maybeClose.type === "punctuation" && maybeClose.value === ")") { + this.advance() + } + } + return expr + } + + if (token.type === "punctuation" && token.value === "[") { + return this.parseList() + } + + this.error("unexpected token", token.span) + this.advance() + const node: ErrorExpr = { type: "ErrorExpr", message: "unexpected token", span: token.span } + return node + } + + private parseList(): Expr { + const startToken = this.peek() + this.advance() + const elements: Expr[] = [] + while (this.peek().type !== "eof") { + const next = this.peek() + if (next.type === "punctuation" && next.value === "]") { + this.advance() + const span = spanFrom(startToken.span, next.span) + const node: ListExpr = { type: "ListExpr", elements, span } + return node + } + const element = this.parseExpression(0) + elements.push(element) + const sep = this.peek() + if (sep.type === "punctuation" && sep.value === ",") { + this.advance() + const maybeClose = this.peek() + if (maybeClose.type === "punctuation" && maybeClose.value === "]") { + this.advance() + const span = spanFrom(startToken.span, maybeClose.span) + const node: ListExpr = { type: "ListExpr", elements, span } + return node + } + continue + } + if (sep.type === "punctuation" && sep.value === "]") { + this.advance() + const span = spanFrom(startToken.span, sep.span) + const node: ListExpr = { type: "ListExpr", elements, span } + return node + } + this.error("expected ',' or ']'", sep.span) + this.syncTo("]") + const maybeClose = this.peek() + if (maybeClose.type === "punctuation" && maybeClose.value === "]") { + const endToken = maybeClose + this.advance() + const span = spanFrom(startToken.span, endToken.span) + const node: ListExpr = { type: "ListExpr", elements, span } + return node + } + break + } + const endToken = this.previous() + const span = spanFrom(startToken.span, endToken.span) + return { type: "ListExpr", elements, span } + } + + private error(message: string, span: Token["span"]) { + this.diagnostics.push({ kind: "parse", message, span }) + } + + private syncTo(value: ")" | "]") { + while (this.peek().type !== "eof") { + const token = this.peek() + if (token.type === "punctuation" && token.value === value) { + return + } + this.advance() + } + } + + private peek(): Token { + return this.tokens[this.index] + } + + private previous(): Token { + return this.tokens[Math.max(0, this.index - 1)] + } + + private advance(): Token { + const token = this.tokens[this.index] + if (this.index < this.tokens.length - 1) this.index += 1 + return token + } +} diff --git a/quartz/util/base/compiler/properties.test.ts b/quartz/util/base/compiler/properties.test.ts new file mode 100644 index 000000000..a3c5993c5 --- /dev/null +++ b/quartz/util/base/compiler/properties.test.ts @@ -0,0 +1,27 @@ +import assert from "node:assert" +import test from "node:test" +import { parseExpressionSource } from "./parser" +import { buildPropertyExpressionSource } from "./properties" + +test("builds property expression sources", () => { + const cases: Array<{ input: string; expected: string }> = [ + { input: "status", expected: "note.status" }, + { input: "note.status", expected: "note.status" }, + { input: "file.name", expected: "file.name" }, + { input: "file.my-field", expected: 'file["my-field"]' }, + { input: "my-field", expected: 'note["my-field"]' }, + { input: 'note["my field"]', expected: 'note["my field"]' }, + { input: "formula.total", expected: "formula.total" }, + { input: "this.file.name", expected: "this.file.name" }, + { input: "a.b-c.d", expected: 'note.a["b-c"].d' }, + { input: "date(file.ctime)", expected: "date(file.ctime)" }, + ] + + for (const entry of cases) { + const result = buildPropertyExpressionSource(entry.input) + assert.strictEqual(result, entry.expected) + const parsed = parseExpressionSource(entry.expected) + assert.strictEqual(parsed.diagnostics.length, 0) + assert.ok(parsed.program.body) + } +}) diff --git a/quartz/util/base/compiler/properties.ts b/quartz/util/base/compiler/properties.ts new file mode 100644 index 000000000..23526defd --- /dev/null +++ b/quartz/util/base/compiler/properties.ts @@ -0,0 +1,27 @@ +const simpleIdentifierPattern = /^[A-Za-z_][A-Za-z0-9_]*$/ + +export function buildPropertyExpressionSource(property: string): string | null { + const trimmed = property.trim() + if (!trimmed) return null + if (trimmed.includes("(") || trimmed.includes("[") || trimmed.includes("]")) { + return trimmed + } + const parts = trimmed.split(".") + const root = parts[0] + const rest = parts.slice(1) + const buildAccess = (base: string, segments: string[]) => { + let source = base + for (const segment of segments) { + if (simpleIdentifierPattern.test(segment)) { + source = `${source}.${segment}` + } else { + source = `${source}[${JSON.stringify(segment)}]` + } + } + return source + } + if (root === "file" || root === "note" || root === "formula" || root === "this") { + return buildAccess(root, rest) + } + return buildAccess("note", parts) +} diff --git a/quartz/util/base/compiler/schema.ts b/quartz/util/base/compiler/schema.ts new file mode 100644 index 000000000..a34974510 --- /dev/null +++ b/quartz/util/base/compiler/schema.ts @@ -0,0 +1,36 @@ +export const BUILTIN_SUMMARY_TYPES = [ + "count", + "sum", + "average", + "avg", + "min", + "max", + "range", + "unique", + "filled", + "missing", + "median", + "stddev", + "checked", + "unchecked", + "empty", + "earliest", + "latest", +] as const + +export type BuiltinSummaryType = (typeof BUILTIN_SUMMARY_TYPES)[number] + +export interface SummaryDefinition { + type: "builtin" | "formula" + builtinType?: BuiltinSummaryType + formulaRef?: string + expression?: string +} + +export interface ViewSummaryConfig { + columns: Record +} + +export interface PropertyConfig { + displayName?: string +} diff --git a/quartz/util/base/compiler/tokens.ts b/quartz/util/base/compiler/tokens.ts new file mode 100644 index 000000000..37917a430 --- /dev/null +++ b/quartz/util/base/compiler/tokens.ts @@ -0,0 +1,42 @@ +import { Span } from "./ast" + +export type Operator = + | "==" + | "!=" + | ">=" + | "<=" + | ">" + | "<" + | "&&" + | "||" + | "+" + | "-" + | "*" + | "/" + | "%" + | "!" + +export type Punctuation = "." | "," | "(" | ")" | "[" | "]" + +export type NumberToken = { type: "number"; value: number; span: Span } +export type StringToken = { type: "string"; value: string; span: Span } +export type BooleanToken = { type: "boolean"; value: boolean; span: Span } +export type NullToken = { type: "null"; span: Span } +export type IdentifierToken = { type: "identifier"; value: string; span: Span } +export type ThisToken = { type: "this"; span: Span } +export type OperatorToken = { type: "operator"; value: Operator; span: Span } +export type PunctuationToken = { type: "punctuation"; value: Punctuation; span: Span } +export type RegexToken = { type: "regex"; pattern: string; flags: string; span: Span } +export type EofToken = { type: "eof"; span: Span } + +export type Token = + | NumberToken + | StringToken + | BooleanToken + | NullToken + | IdentifierToken + | ThisToken + | OperatorToken + | PunctuationToken + | RegexToken + | EofToken diff --git a/quartz/util/base/inspec-base.ts b/quartz/util/base/inspec-base.ts new file mode 100644 index 000000000..0a85959f0 --- /dev/null +++ b/quartz/util/base/inspec-base.ts @@ -0,0 +1,278 @@ +import yaml from "js-yaml" +import fs from "node:fs/promises" +import path from "node:path" +import { + parseExpressionSource, + compileExpression, + buildPropertyExpressionSource, + BUILTIN_SUMMARY_TYPES, +} from "./compiler" +import { Expr, LogicalExpr, UnaryExpr, spanFrom } from "./compiler/ast" +import { Diagnostic } from "./compiler/errors" + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value) + +type CollectedExpression = { + kind: string + context: string + source: string + ast: Expr | null + ir: unknown + diagnostics: Diagnostic[] +} + +const parseToExpr = (source: string, filePath: string) => { + const result = parseExpressionSource(source, filePath) + return { expr: result.program.body ?? null, diagnostics: result.diagnostics } +} + +const buildLogical = (operator: "&&" | "||", expressionsList: Expr[]): Expr | null => { + if (expressionsList.length === 0) return null + let current: Expr | null = null + for (const next of expressionsList) { + if (!current) { + current = next + continue + } + const span = spanFrom(current.span, next.span) + const node: LogicalExpr = { type: "LogicalExpr", operator, left: current, right: next, span } + current = node + } + return current +} + +const negateExpressions = (expressionsList: Expr[]): Expr[] => + expressionsList.map((expr) => { + const node: UnaryExpr = { + type: "UnaryExpr", + operator: "!", + argument: expr, + span: spanFrom(expr.span, expr.span), + } + return node + }) + +const buildFilterExpr = ( + raw: unknown, + context: string, + diagnostics: Diagnostic[], + filePath: string, +): Expr | null => { + if (typeof raw === "string") { + const parsed = parseToExpr(raw, filePath) + diagnostics.push(...parsed.diagnostics) + return parsed.expr + } + if (!isRecord(raw)) return null + if (Array.isArray(raw.and)) { + const parts = raw.and + .map((entry, index) => + buildFilterExpr(entry, `${context}.and[${index}]`, diagnostics, filePath), + ) + .filter((entry): entry is Expr => Boolean(entry)) + return buildLogical("&&", parts) + } + if (Array.isArray(raw.or)) { + const parts = raw.or + .map((entry, index) => + buildFilterExpr(entry, `${context}.or[${index}]`, diagnostics, filePath), + ) + .filter((entry): entry is Expr => Boolean(entry)) + return buildLogical("||", parts) + } + if (Array.isArray(raw.not)) { + const parts = raw.not + .map((entry, index) => + buildFilterExpr(entry, `${context}.not[${index}]`, diagnostics, filePath), + ) + .filter((entry): entry is Expr => Boolean(entry)) + return buildLogical("&&", negateExpressions(parts)) + } + return null +} + +const collectPropertyExpressions = ( + views: unknown[], +): Map => { + const entries = new Map() + const addProperty = (property: string, context: string) => { + const key = property.trim() + if (!key || entries.has(key)) return + const source = buildPropertyExpressionSource(key) + if (!source) return + entries.set(key, { source, context }) + } + + views.forEach((view, viewIndex) => { + if (!isRecord(view)) return + const viewContext = `views[${viewIndex}]` + if (Array.isArray(view.order)) { + view.order.forEach((entry, orderIndex) => { + if (typeof entry === "string") { + addProperty(entry, `${viewContext}.order[${orderIndex}]`) + } + }) + } + + if (Array.isArray(view.sort)) { + view.sort.forEach((entry, sortIndex) => { + if (isRecord(entry) && typeof entry.property === "string") { + addProperty(entry.property, `${viewContext}.sort[${sortIndex}].property`) + } + }) + } + + if (typeof view.groupBy === "string") { + addProperty(view.groupBy, `${viewContext}.groupBy`) + } else if (isRecord(view.groupBy) && typeof view.groupBy.property === "string") { + addProperty(view.groupBy.property, `${viewContext}.groupBy.property`) + } + + if (view.summaries && isRecord(view.summaries)) { + const columns = + "columns" in view.summaries && isRecord(view.summaries.columns) + ? view.summaries.columns + : view.summaries + for (const key of Object.keys(columns)) { + addProperty(key, `${viewContext}.summaries.${key}`) + } + } + + if (typeof view.image === "string") { + addProperty(view.image, `${viewContext}.image`) + } + + if (view.type === "map") { + const coords = typeof view.coordinates === "string" ? view.coordinates : "coordinates" + addProperty(coords, `${viewContext}.coordinates`) + if (typeof view.markerIcon === "string") { + addProperty(view.markerIcon, `${viewContext}.markerIcon`) + } + if (typeof view.markerColor === "string") { + addProperty(view.markerColor, `${viewContext}.markerColor`) + } + } + }) + + return entries +} + +const main = async () => { + const inputPath = process.argv[2] ? String(process.argv[2]) : "content/antilibrary.base" + const filePath = path.resolve(process.cwd(), inputPath) + const raw = await fs.readFile(filePath, "utf8") + const parsed = yaml.load(raw) + const config = isRecord(parsed) ? parsed : {} + + const collected: CollectedExpression[] = [] + + if (config.filters !== undefined) { + const diagnostics: Diagnostic[] = [] + const expr = buildFilterExpr(config.filters, "filters", diagnostics, filePath) + collected.push({ + kind: "filters", + context: "filters", + source: typeof config.filters === "string" ? config.filters : JSON.stringify(config.filters), + ast: expr, + ir: expr ? compileExpression(expr) : null, + diagnostics, + }) + } + + if (isRecord(config.formulas)) { + for (const [name, value] of Object.entries(config.formulas)) { + if (typeof value !== "string") continue + const parsedExpr = parseToExpr(value, filePath) + collected.push({ + kind: "formula", + context: `formulas.${name}`, + source: value, + ast: parsedExpr.expr, + ir: parsedExpr.expr ? compileExpression(parsedExpr.expr) : null, + diagnostics: parsedExpr.diagnostics, + }) + } + } + + const topLevelSummaries = isRecord(config.summaries) ? config.summaries : {} + + if (isRecord(config.summaries)) { + for (const [name, value] of Object.entries(config.summaries)) { + if (typeof value !== "string") continue + const parsedExpr = parseToExpr(value, filePath) + collected.push({ + kind: "summary", + context: `summaries.${name}`, + source: value, + ast: parsedExpr.expr, + ir: parsedExpr.expr ? compileExpression(parsedExpr.expr) : null, + diagnostics: parsedExpr.diagnostics, + }) + } + } + + if (Array.isArray(config.views)) { + config.views.forEach((view, index) => { + if (!isRecord(view)) return + if (view.filters !== undefined) { + const diagnostics: Diagnostic[] = [] + const expr = buildFilterExpr(view.filters, `views[${index}].filters`, diagnostics, filePath) + collected.push({ + kind: "view.filter", + context: `views[${index}].filters`, + source: typeof view.filters === "string" ? view.filters : JSON.stringify(view.filters), + ast: expr, + ir: expr ? compileExpression(expr) : null, + diagnostics, + }) + } + + if (view.summaries && isRecord(view.summaries)) { + const columns = + "columns" in view.summaries && isRecord(view.summaries.columns) + ? view.summaries.columns + : view.summaries + for (const [column, summaryValue] of Object.entries(columns)) { + if (typeof summaryValue !== "string") continue + const normalized = summaryValue.toLowerCase().trim() + const builtins = new Set(BUILTIN_SUMMARY_TYPES) + if (builtins.has(normalized)) continue + const summarySource = + summaryValue in topLevelSummaries && typeof topLevelSummaries[summaryValue] === "string" + ? String(topLevelSummaries[summaryValue]) + : summaryValue + const parsedExpr = parseToExpr(summarySource, filePath) + collected.push({ + kind: "view.summary", + context: `views[${index}].summaries.${column}`, + source: summarySource, + ast: parsedExpr.expr, + ir: parsedExpr.expr ? compileExpression(parsedExpr.expr) : null, + diagnostics: parsedExpr.diagnostics, + }) + } + } + }) + } + + const views = Array.isArray(config.views) ? config.views : [] + const propertyExpressions = collectPropertyExpressions(views) + for (const [_, entry] of propertyExpressions.entries()) { + const parsedExpr = parseToExpr(entry.source, filePath) + collected.push({ + kind: "property", + context: entry.context, + source: entry.source, + ast: parsedExpr.expr, + ir: parsedExpr.expr ? compileExpression(parsedExpr.expr) : null, + diagnostics: parsedExpr.diagnostics, + }) + } + + const payload = { file: inputPath, count: collected.length, expressions: collected } + + process.stdout.write(JSON.stringify(payload, null, 2)) +} + +main() diff --git a/quartz/util/base/query.ts b/quartz/util/base/query.ts new file mode 100644 index 000000000..4f03e8e76 --- /dev/null +++ b/quartz/util/base/query.ts @@ -0,0 +1,248 @@ +import { QuartzPluginData } from "../../plugins/vfile" +import { evaluateSummaryExpression, valueToUnknown, EvalContext, ProgramIR } from "./compiler" +import { SummaryDefinition, ViewSummaryConfig, BuiltinSummaryType } from "./types" + +type SummaryValueResolver = ( + file: QuartzPluginData, + column: string, + allFiles: QuartzPluginData[], +) => unknown + +type SummaryContextFactory = (file: QuartzPluginData) => EvalContext + +export function computeColumnSummary( + column: string, + files: QuartzPluginData[], + summary: SummaryDefinition, + allFiles: QuartzPluginData[] = [], + valueResolver: SummaryValueResolver, + getContext: SummaryContextFactory, + summaryExpression?: ProgramIR, +): string | number | undefined { + if (files.length === 0) { + return undefined + } + + const values = files.map((file) => valueResolver(file, column, allFiles)) + + if (summary.type === "builtin" && summary.builtinType) { + return computeBuiltinSummary(values, summary.builtinType) + } + + if (summary.type === "formula" && summary.expression) { + if (summaryExpression) { + const summaryCtx = getContext(files[0]) + summaryCtx.diagnosticContext = `summaries.${column}` + summaryCtx.diagnosticSource = summary.expression + summaryCtx.rows = files + const value = evaluateSummaryExpression(summaryExpression, values, summaryCtx) + const unknownValue = valueToUnknown(value) + if (typeof unknownValue === "number" || typeof unknownValue === "string") { + return unknownValue + } + return undefined + } + } + + return undefined +} + +function computeBuiltinSummary( + values: any[], + type: BuiltinSummaryType, +): string | number | undefined { + switch (type) { + case "count": + return values.length + + case "sum": { + const nums = values.filter((v) => typeof v === "number") + if (nums.length === 0) return undefined + return nums.reduce((acc, v) => acc + v, 0) + } + + case "average": + case "avg": { + const nums = values.filter((v) => typeof v === "number") + if (nums.length === 0) return undefined + const sum = nums.reduce((acc, v) => acc + v, 0) + return Math.round((sum / nums.length) * 100) / 100 + } + + case "min": { + const comparable = values.filter( + (v) => typeof v === "number" || v instanceof Date || typeof v === "string", + ) + if (comparable.length === 0) return undefined + const normalized = comparable.map((v) => (v instanceof Date ? v.getTime() : v)) + const min = Math.min(...normalized.filter((v) => typeof v === "number")) + if (isNaN(min)) { + const strings = comparable.filter((v) => typeof v === "string") as string[] + if (strings.length === 0) return undefined + return strings.sort()[0] + } + if (comparable.some((v) => v instanceof Date)) { + return new Date(min).toISOString().split("T")[0] + } + return min + } + + case "max": { + const comparable = values.filter( + (v) => typeof v === "number" || v instanceof Date || typeof v === "string", + ) + if (comparable.length === 0) return undefined + const normalized = comparable.map((v) => (v instanceof Date ? v.getTime() : v)) + const max = Math.max(...normalized.filter((v) => typeof v === "number")) + if (isNaN(max)) { + const strings = comparable.filter((v) => typeof v === "string") as string[] + if (strings.length === 0) return undefined + return strings.sort().reverse()[0] + } + if (comparable.some((v) => v instanceof Date)) { + return new Date(max).toISOString().split("T")[0] + } + return max + } + + case "range": { + const comparable = values.filter( + (v) => typeof v === "number" || v instanceof Date || typeof v === "string", + ) + if (comparable.length === 0) return undefined + const normalized = comparable.map((v) => (v instanceof Date ? v.getTime() : v)) + const nums = normalized.filter((v) => typeof v === "number") + if (nums.length === 0) return undefined + const min = Math.min(...nums) + const max = Math.max(...nums) + if (comparable.some((v) => v instanceof Date)) { + return `${new Date(min).toISOString().split("T")[0]} - ${new Date(max).toISOString().split("T")[0]}` + } + return `${min} - ${max}` + } + + case "unique": { + const nonNull = values.filter((v) => v !== undefined && v !== null && v !== "") + const unique = new Set(nonNull.map((v) => (v instanceof Date ? v.toISOString() : String(v)))) + return unique.size + } + + case "filled": { + const filled = values.filter((v) => v !== undefined && v !== null && v !== "") + return filled.length + } + + case "missing": { + const missing = values.filter((v) => v === undefined || v === null || v === "") + return missing.length + } + + case "median": { + const nums = values.filter((v) => typeof v === "number") as number[] + if (nums.length === 0) return undefined + const sorted = [...nums].sort((a, b) => a - b) + const mid = Math.floor(sorted.length / 2) + if (sorted.length % 2 === 0) { + return (sorted[mid - 1] + sorted[mid]) / 2 + } + return sorted[mid] + } + + case "stddev": { + const nums = values.filter((v) => typeof v === "number") as number[] + if (nums.length === 0) return undefined + const mean = nums.reduce((acc, v) => acc + v, 0) / nums.length + const variance = nums.reduce((acc, v) => acc + (v - mean) * (v - mean), 0) / nums.length + return Math.round(Math.sqrt(variance) * 100) / 100 + } + + case "checked": + return values.filter((v) => v === true).length + + case "unchecked": + return values.filter((v) => v === false).length + + case "empty": { + const count = values.filter( + (v) => + v === undefined || + v === null || + v === "" || + (Array.isArray(v) && v.length === 0) || + (typeof v === "object" && v !== null && !Array.isArray(v) && Object.keys(v).length === 0), + ).length + return count + } + + case "earliest": { + const dates = values.filter( + (v) => + v instanceof Date || + (typeof v === "string" && /^\d{4}-\d{2}-\d{2}/.test(v)) || + typeof v === "number", + ) + if (dates.length === 0) return undefined + const timestamps = dates.map((v) => { + if (v instanceof Date) return v.getTime() + if (typeof v === "string") return new Date(v).getTime() + return v + }) + const earliest = Math.min(...timestamps) + return new Date(earliest).toISOString().split("T")[0] + } + + case "latest": { + const dates = values.filter( + (v) => + v instanceof Date || + (typeof v === "string" && /^\d{4}-\d{2}-\d{2}/.test(v)) || + typeof v === "number", + ) + if (dates.length === 0) return undefined + const timestamps = dates.map((v) => { + if (v instanceof Date) return v.getTime() + if (typeof v === "string") return new Date(v).getTime() + return v + }) + const latest = Math.max(...timestamps) + return new Date(latest).toISOString().split("T")[0] + } + + default: + return undefined + } +} + +export function computeViewSummaries( + columns: string[], + files: QuartzPluginData[], + summaryConfig: ViewSummaryConfig | undefined, + allFiles: QuartzPluginData[] = [], + getContext: SummaryContextFactory, + valueResolver: SummaryValueResolver, + summaryExpressions?: Record, +): Record { + const results: Record = {} + + if (!summaryConfig?.columns) { + return results + } + + for (const column of columns) { + const summary = summaryConfig.columns[column] + if (summary) { + const expression = summaryExpressions ? summaryExpressions[column] : undefined + results[column] = computeColumnSummary( + column, + files, + summary, + allFiles, + valueResolver, + getContext, + expression, + ) + } + } + + return results +} diff --git a/quartz/util/base/render.ts b/quartz/util/base/render.ts new file mode 100644 index 000000000..c36c175ab --- /dev/null +++ b/quartz/util/base/render.ts @@ -0,0 +1,1335 @@ +import { Root } from "hast" +import { h } from "hastscript" +import { QuartzPluginData } from "../../plugins/vfile" +import { + resolveRelative, + FullSlug, + joinSegments, + FilePath, + slugifyFilePath, + simplifySlug, + isAbsoluteURL, +} from "../../util/path" +import { extractWikilinksWithPositions, resolveWikilinkTarget } from "../../util/wikilinks" +import { + BaseExpressionDiagnostic, + ProgramIR, + buildPropertyExpressionSource, + evaluateExpression, + evaluateFilterExpression, + valueToUnknown, + EvalContext, + Value, +} from "./compiler" +import { computeViewSummaries } from "./query" +import { + BaseView, + BaseGroupBy, + BaseFile, + BaseSortConfig, + PropertyConfig, + ViewSummaryConfig, + parseViewSummaries, +} from "./types" + +type RenderElement = ReturnType +type RenderNode = RenderElement | string + +function getFileBaseName(filePath?: string, slug?: string): string | undefined { + const source = filePath ?? slug + if (!source) return undefined + const fragment = source.split("/").pop() || source + return fragment.replace(/\.[^/.]+$/, "") +} + +function getFileDisplayName(file?: QuartzPluginData): string | undefined { + if (!file) return undefined + const title = file.frontmatter?.title + if (typeof title === "string" && title.length > 0) return title + const baseFromPath = getFileBaseName(file.filePath as string | undefined) + if (baseFromPath) return baseFromPath + const baseFromSlug = getFileBaseName(file.slug) + if (baseFromSlug) return baseFromSlug.replace(/-/g, " ") + return undefined +} + +function fallbackNameFromSlug(slug: FullSlug): string { + const base = getFileBaseName(slug) ?? slug + return base.replace(/-/g, " ") +} + +function findFileBySlug( + allFiles: QuartzPluginData[], + targetSlug: FullSlug, +): QuartzPluginData | undefined { + const targetSimple = simplifySlug(targetSlug) + return allFiles.find( + (entry) => entry.slug && simplifySlug(entry.slug as FullSlug) === targetSimple, + ) +} + +function renderInternalLinkNode( + targetSlug: FullSlug, + currentSlug: FullSlug, + allFiles: QuartzPluginData[], + alias?: string, + anchor?: string, +): RenderElement { + const targetFile = findFileBySlug(allFiles, targetSlug) + const displayText = + alias && alias.trim().length > 0 + ? alias.trim() + : (getFileDisplayName(targetFile) ?? fallbackNameFromSlug(targetSlug)) + + const hrefBase = resolveRelative(currentSlug, targetSlug) + const href = anchor && anchor.length > 0 ? `${hrefBase}${anchor}` : hrefBase + const dataSlug = anchor && anchor.length > 0 ? `${targetSlug}${anchor}` : targetSlug + + return h("a.internal", { href, "data-slug": dataSlug }, displayText) +} + +function buildFileLinkNode(slug: FullSlug, currentSlug: FullSlug, label: string): RenderElement { + const href = resolveRelative(currentSlug, slug) + return h("a.internal", { href, "data-slug": slug }, label) +} + +function splitTargetAndAlias(raw: string): { target: string; alias?: string } { + let buffer = "" + let alias: string | undefined + let escaped = false + for (let i = 0; i < raw.length; i++) { + const ch = raw[i] + if (escaped) { + buffer += ch + escaped = false + continue + } + if (ch === "\\") { + escaped = true + continue + } + if (ch === "|" && alias === undefined) { + alias = raw.slice(i + 1) + break + } + buffer += ch + } + + const target = buffer.replace(/\\\|/g, "|").trim() + const cleanedAlias = alias?.replace(/\\\|/g, "|").trim() + return { target, alias: cleanedAlias?.length ? cleanedAlias : undefined } +} + +function normalizeTargetSlug( + target: string, + currentSlug: FullSlug, + anchor?: string, +): { slug: FullSlug; anchor?: string } { + const trimmed = target.trim() + if (!trimmed) return { slug: currentSlug, anchor } + const slug = slugifyFilePath(trimmed as FilePath) + return { slug, anchor } +} + +function renderInlineString( + value: string, + currentSlug: FullSlug, + allFiles: QuartzPluginData[], +): RenderNode[] { + if (!value.includes("[[")) { + return [value] + } + + const nodes: RenderNode[] = [] + const ranges = extractWikilinksWithPositions(value) + let lastIndex = 0 + for (const range of ranges) { + const start = range.start + if (start > lastIndex) nodes.push(value.slice(lastIndex, start)) + + const parsed = range.wikilink + const raw = value.slice(range.start, range.end) + if (parsed.embed) { + nodes.push(raw) + lastIndex = range.end + continue + } + + const resolved = resolveWikilinkTarget(parsed, currentSlug) + if (!resolved) { + nodes.push(parsed.alias ?? parsed.target ?? raw) + lastIndex = range.end + continue + } + + nodes.push( + renderInternalLinkNode(resolved.slug, currentSlug, allFiles, parsed.alias, resolved.anchor), + ) + lastIndex = range.end + } + + if (lastIndex < value.length) { + nodes.push(value.slice(lastIndex)) + } + + return nodes +} + +function renderBacklinkNodes( + backlinks: string[], + currentSlug: FullSlug, + allFiles: QuartzPluginData[], +): RenderNode[] { + const nodes: RenderNode[] = [] + for (const entry of backlinks) { + if (!entry) continue + let raw = entry.trim() + if (!raw) continue + let alias: string | undefined + if (raw.startsWith("!")) { + raw = raw.slice(1) + } + if (raw.startsWith("[[") && raw.endsWith("]]")) { + const inner = raw.slice(2, -2) + const parsed = splitTargetAndAlias(inner) + raw = parsed.target + alias = parsed.alias + } + const { slug: targetSlug, anchor } = normalizeTargetSlug(raw, currentSlug) + if (nodes.length > 0) { + nodes.push(", ") + } + nodes.push( + renderInternalLinkNode( + targetSlug, + currentSlug, + allFiles, + alias, + anchor && anchor.length > 0 ? anchor : undefined, + ), + ) + } + return nodes +} + +function getPropertyDisplayName( + property: string, + properties?: Record, +): string { + const candidates: string[] = [] + + const addCandidate = (candidate: string | undefined) => { + if (!candidate) return + if (!candidates.includes(candidate)) { + candidates.push(candidate) + } + } + + addCandidate(property) + + const withoutPrefix = property.replace(/^(?:note|file)\./, "") + addCandidate(withoutPrefix) + + if (!property.startsWith("note.")) { + addCandidate(`note.${property}`) + } + if (!property.startsWith("file.")) { + addCandidate(`file.${property}`) + } + + addCandidate(withoutPrefix.split(".").pop()) + + for (const candidate of candidates) { + const displayName = properties?.[candidate]?.displayName + if (displayName && displayName.length > 0) { + return displayName + } + } + + const base = withoutPrefix.length > 0 ? withoutPrefix : property + return base + .split(".") + .pop()! + .replace(/-/g, " ") + .replace(/_/g, " ") + .replace(/([A-Z])/g, " $1") + .trim() +} + +function renderBooleanCheckbox(value: boolean): RenderElement { + return h("input", { + type: "checkbox", + checked: value ? true : undefined, + disabled: true, + class: "base-checkbox", + }) +} + +function buildTableHead( + columns: string[], + properties?: Record, +): RenderElement { + return h( + "tr", + columns.map((col) => h("th", {}, getPropertyDisplayName(col, properties))), + ) +} + +type EvalContextFactory = (file: QuartzPluginData) => EvalContext + +type PropertyExprGetter = (property: string) => ProgramIR | null + +function resolveValueWithFormulas( + file: QuartzPluginData, + property: string, + getContext: EvalContextFactory, + getPropertyExpr: PropertyExprGetter, +): unknown { + const expr = getPropertyExpr(property) + if (!expr) return undefined + const ctx = getContext(file) + const cacheKey = property.trim() + if (cacheKey.length > 0 && ctx.propertyCache?.has(cacheKey)) { + return valueToUnknown(ctx.propertyCache.get(cacheKey)!) + } + ctx.diagnosticContext = `property.${property}` + ctx.diagnosticSource = buildPropertyExpressionSource(property) ?? property + const value = evaluateExpression(expr, ctx) + if (cacheKey.length > 0 && ctx.propertyCache) { + ctx.propertyCache.set(cacheKey, value) + } + return valueToUnknown(value) +} + +function buildTableCell( + file: QuartzPluginData, + column: string, + currentSlug: FullSlug, + allFiles: QuartzPluginData[], + getContext: EvalContextFactory, + getPropertyExpr: PropertyExprGetter, +): RenderElement { + const slug = (file.slug || "") as FullSlug + const fallbackSlugSegment = file.slug?.split("/").pop() || "" + const fallbackTitle = + getFileBaseName(file.filePath as string | undefined) || + fallbackSlugSegment.replace(/\.[^/.]+$/, "").replace(/-/g, " ") + + const linkProperty = + column === "file.name" + ? "file.name" + : column === "title" || column === "file.title" || column === "note.title" + ? "file.title" + : undefined + + if (linkProperty) { + const rawValue = resolveValueWithFormulas(file, linkProperty, getContext, getPropertyExpr) + const resolvedValue = + typeof rawValue === "string" && rawValue.length > 0 ? rawValue : fallbackTitle + return h("td", [buildFileLinkNode(slug, currentSlug, resolvedValue)]) + } + + if (column === "file.links") { + const links = resolveValueWithFormulas(file, "file.links", getContext, getPropertyExpr) + const count = Array.isArray(links) ? links.length : 0 + return h("td", {}, String(count)) + } + + if (column === "file.backlinks" || column === "file.inlinks") { + const backlinks = resolveValueWithFormulas(file, column, getContext, getPropertyExpr) + if (!Array.isArray(backlinks) || backlinks.length === 0) { + return h("td", {}, "") + } + const entries = backlinks.filter((entry): entry is string => typeof entry === "string") + if (entries.length === 0) { + return h("td", {}, "") + } + const nodes = renderBacklinkNodes(entries, currentSlug, allFiles) + return h("td", {}, nodes) + } + + const canEvalExpr = Boolean(getPropertyExpr(column)) + + if (!canEvalExpr && column.startsWith("note.")) { + const actualColumn = column.replace("note.", "") + return buildTableCell(file, actualColumn, currentSlug, allFiles, getContext, getPropertyExpr) + } + + const value = resolveValueWithFormulas(file, column, getContext, getPropertyExpr) + + if (value === undefined || value === null) { + return h("td", {}, "") + } + + if (Array.isArray(value)) { + const parts: RenderNode[] = [] + value.forEach((item, idx) => { + if (typeof item === "string") { + parts.push(...renderInlineString(item, currentSlug, allFiles)) + } else { + parts.push(String(item)) + } + if (idx < value.length - 1) { + parts.push(", ") + } + }) + return h("td", {}, parts) + } + + if (value instanceof Date) { + return h("td", {}, value.toISOString().split("T")[0]) + } + + if (typeof value === "string") { + const rendered = renderInlineString(value, currentSlug, allFiles) + return h("td", {}, rendered) + } + + if (typeof value === "boolean") { + return h("td", {}, [renderBooleanCheckbox(value)]) + } + + return h("td", {}, String(value)) +} + +function applySorting( + files: QuartzPluginData[], + sortConfig: BaseSortConfig[] = [], + getContext: EvalContextFactory, + getPropertyExpr: PropertyExprGetter, +): QuartzPluginData[] { + if (sortConfig.length === 0) return files + + const normalizeSortValue = (val: unknown): string | number | null | undefined => { + if (val instanceof Date) { + return val.getTime() + } + if (Array.isArray(val)) { + return val.join(", ") + } + if (typeof val === "string" || typeof val === "number") { + return val + } + if (typeof val === "boolean") { + return val ? 1 : 0 + } + if (val === null || val === undefined) { + return val + } + return String(val) + } + + return [...files].sort((a, b) => { + for (const { property, direction } of sortConfig) { + const aRaw = resolveValueWithFormulas(a, property, getContext, getPropertyExpr) + const bRaw = resolveValueWithFormulas(b, property, getContext, getPropertyExpr) + + const aVal = normalizeSortValue(aRaw) + const bVal = normalizeSortValue(bRaw) + + let comparison = 0 + if (aVal === undefined || aVal === null || aVal === "") { + if (bVal === undefined || bVal === null || bVal === "") { + comparison = 0 + } else { + comparison = 1 + } + } else if (bVal === undefined || bVal === null || bVal === "") { + comparison = -1 + } else if (typeof aVal === "string" && typeof bVal === "string") { + comparison = aVal.localeCompare(bVal) + } else { + const aNumber = typeof aVal === "number" ? aVal : Number(aVal) + const bNumber = typeof bVal === "number" ? bVal : Number(bVal) + if (Number.isFinite(aNumber) && Number.isFinite(bNumber)) { + comparison = aNumber > bNumber ? 1 : aNumber < bNumber ? -1 : 0 + } else { + comparison = String(aVal).localeCompare(String(bVal)) + } + } + + if (comparison !== 0) { + return direction === "ASC" ? comparison : -comparison + } + } + return 0 + }) +} + +function groupFiles( + files: QuartzPluginData[], + groupBy: string | BaseGroupBy, + getContext: EvalContextFactory, + getPropertyExpr: PropertyExprGetter, +): Map { + const groups = new Map() + + const property = typeof groupBy === "string" ? groupBy : groupBy.property + const direction = typeof groupBy === "string" ? "ASC" : groupBy.direction + + for (const file of files) { + const value = resolveValueWithFormulas(file, property, getContext, getPropertyExpr) + const key = value === undefined || value === null ? "(empty)" : String(value) + + if (!groups.has(key)) { + groups.set(key, []) + } + groups.get(key)!.push(file) + } + + const sortedGroups = new Map( + [...groups.entries()].sort(([a], [b]) => { + if (direction === "ASC") { + return a.localeCompare(b) + } else { + return b.localeCompare(a) + } + }), + ) + + return sortedGroups +} + +function buildTableSummaryRow( + columns: string[], + files: QuartzPluginData[], + summaryConfig: ViewSummaryConfig | undefined, + allFiles: QuartzPluginData[], + summaryExpressions: Record | undefined, + getContext: EvalContextFactory, + getPropertyExpr: PropertyExprGetter, +): RenderElement | undefined { + if (!summaryConfig?.columns || Object.keys(summaryConfig.columns).length === 0) { + return undefined + } + + const summaryValues = computeViewSummaries( + columns, + files, + summaryConfig, + allFiles, + getContext, + (file, column) => resolveValueWithFormulas(file, column, getContext, getPropertyExpr), + summaryExpressions, + ) + + const hasValues = Object.values(summaryValues).some((v) => v !== undefined) + if (!hasValues) { + return undefined + } + + const cells: RenderElement[] = columns.map((col) => { + const value = summaryValues[col] + if (value === undefined) { + return h("td.base-summary-cell", {}, "") + } + return h("td.base-summary-cell", {}, String(value)) + }) + + return h("tfoot", [h("tr.base-summary-row", cells)]) +} + +function buildTable( + files: QuartzPluginData[], + view: BaseView, + currentSlug: FullSlug, + allFiles: QuartzPluginData[], + getContext: EvalContextFactory, + getPropertyExpr: PropertyExprGetter, + properties?: Record, + topLevelSummaries?: Record, + summaryExpressions?: Record, +): RenderElement { + const columns = view.order || [] + + const summaryConfig = parseViewSummaries(view.summaries, topLevelSummaries) + + if (view.groupBy) { + const groups = groupFiles(files, view.groupBy, getContext, getPropertyExpr) + const allRows: RenderElement[] = [] + + for (const [groupName, groupFiles] of groups) { + const groupHeader = h("tr.base-group-header", [ + h("td", { colspan: columns.length }, groupName), + ]) + allRows.push(groupHeader) + + for (const file of groupFiles) { + const cells = columns.map((col) => + buildTableCell(file, col, currentSlug, allFiles, getContext, getPropertyExpr), + ) + allRows.push(h("tr", cells)) + } + } + + const tbody = h("tbody", allRows) + const thead = h("thead", buildTableHead(columns, properties)) + return h("table.base-table", [thead, tbody]) + } + + const rows = files.map((f) => { + const cells = columns.map((col) => + buildTableCell(f, col, currentSlug, allFiles, getContext, getPropertyExpr), + ) + return h("tr", cells) + }) + + const tbody = h("tbody", rows) + const thead = h("thead", buildTableHead(columns, properties)) + const tfoot = buildTableSummaryRow( + columns, + files, + summaryConfig, + allFiles, + summaryExpressions, + getContext, + getPropertyExpr, + ) + const tableChildren = tfoot ? [thead, tbody, tfoot] : [thead, tbody] + return h("table.base-table", tableChildren) +} + +function listValueToPlainText(value: unknown): string | undefined { + if (value === undefined || value === null) { + return undefined + } + if (Array.isArray(value)) { + const parts = value + .map((item) => listValueToPlainText(item)) + .filter((part): part is string => Boolean(part && part.length > 0)) + if (parts.length === 0) return undefined + return parts.join(", ") + } + if (value instanceof Date) { + return value.toISOString().split("T")[0] + } + if (typeof value === "string") { + const cleaned = value + .replace(/\[\[([^\]|]+)\|([^\]]+)\]\]/g, "$2") + .replace(/\[\[([^\]]+)\]\]/g, "$1") + .trim() + return cleaned.length > 0 ? cleaned : undefined + } + const stringified = String(value).trim() + return stringified.length > 0 ? stringified : undefined +} + +function hasRenderableValue(value: unknown): boolean { + if (value === undefined || value === null) return false + if (Array.isArray(value)) { + return value.some((item) => hasRenderableValue(item)) + } + if (value instanceof Date) return true + if (typeof value === "string") return value.trim().length > 0 + return true +} + +function renderPropertyValueNodes( + value: unknown, + currentSlug: FullSlug, + allFiles: QuartzPluginData[], +): RenderNode[] { + if (value === undefined || value === null) return [] + if (Array.isArray(value)) { + const nodes: RenderNode[] = [] + value.forEach((item, idx) => { + nodes.push(...renderPropertyValueNodes(item, currentSlug, allFiles)) + if (idx < value.length - 1) { + nodes.push(", ") + } + }) + return nodes + } + if (value instanceof Date) { + return [value.toISOString().split("T")[0]] + } + if (typeof value === "string") { + return renderInlineString(value, currentSlug, allFiles) + } + return [String(value)] +} + +function createListItemRenderer( + view: BaseView, + currentSlug: FullSlug, + allFiles: QuartzPluginData[], + getContext: EvalContextFactory, + getPropertyExpr: PropertyExprGetter, + properties?: Record, +): (file: QuartzPluginData) => RenderElement { + const nestedProperties = view.nestedProperties === true || view.indentProperties === true + const order = Array.isArray(view.order) && view.order.length > 0 ? view.order : ["title"] + const [primaryProp, ...secondaryProps] = order + const rawSeparator = typeof view.separator === "string" ? view.separator : "," + const separator = rawSeparator.endsWith(" ") ? rawSeparator : `${rawSeparator} ` + + return (file) => { + const slug = (file.slug || "") as FullSlug + const fallbackTitle = getFileDisplayName(file) ?? fallbackNameFromSlug(slug) + + const primaryValue = primaryProp + ? resolveValueWithFormulas(file, primaryProp, getContext, getPropertyExpr) + : resolveValueWithFormulas(file, "title", getContext, getPropertyExpr) + const primaryText = listValueToPlainText(primaryValue) ?? fallbackTitle + const anchor = buildFileLinkNode(slug, currentSlug, primaryText) + + const seen = new Set() + if (primaryProp) { + seen.add(primaryProp) + } + + if (!nestedProperties) { + const inlineNodes: RenderNode[] = [] + + for (const propertyKey of secondaryProps) { + if (!propertyKey || seen.has(propertyKey)) continue + const value = resolveValueWithFormulas(file, propertyKey, getContext, getPropertyExpr) + if (!hasRenderableValue(value)) continue + + const renderedValue = renderPropertyValueNodes(value, currentSlug, allFiles) + if (renderedValue.length === 0) continue + + inlineNodes.push(separator) + inlineNodes.push(...renderedValue) + seen.add(propertyKey) + } + + return inlineNodes.length > 0 ? h("li", [anchor, ...inlineNodes]) : h("li", [anchor]) + } + + const metadataItems: RenderElement[] = [] + + for (const propertyKey of secondaryProps) { + if (!propertyKey || seen.has(propertyKey)) continue + const value = resolveValueWithFormulas(file, propertyKey, getContext, getPropertyExpr) + if (!hasRenderableValue(value)) continue + + const renderedValue = renderPropertyValueNodes(value, currentSlug, allFiles) + if (renderedValue.length === 0) continue + + const label = getPropertyDisplayName(propertyKey, properties) + metadataItems.push(h("li", [h("span.base-list-meta-label", `${label}: `), ...renderedValue])) + seen.add(propertyKey) + } + + if (metadataItems.length === 0) { + return h("li", [anchor]) + } + + return h("li", [anchor, h("ul.base-list-nested", metadataItems)]) + } +} + +function buildList( + files: QuartzPluginData[], + view: BaseView, + currentSlug: FullSlug, + allFiles: QuartzPluginData[], + getContext: EvalContextFactory, + getPropertyExpr: PropertyExprGetter, + properties?: Record, +): RenderElement { + const renderListItem = createListItemRenderer( + view, + currentSlug, + allFiles, + getContext, + getPropertyExpr, + properties, + ) + + if (view.groupBy) { + const groups = groupFiles(files, view.groupBy, getContext, getPropertyExpr) + const groupElements: RenderElement[] = [] + + for (const [groupName, groupedFiles] of groups) { + const items = groupedFiles.map((file) => renderListItem(file)) + groupElements.push( + h("div.base-list-group", [ + h("h3.base-list-group-header", groupName), + h("ul.base-list", items), + ]), + ) + } + + return h("div.base-list-container", groupElements) + } + + const items = files.map((file) => renderListItem(file)) + return h("ul.base-list", items) +} + +function normalizeCalendarDate(value: unknown): string | undefined { + if (value instanceof Date) { + return value.toISOString().split("T")[0] + } + if (typeof value === "string") { + const match = value.match(/^\d{4}-\d{2}-\d{2}/) + if (match) return match[0] + const parsed = new Date(value) + if (!Number.isNaN(parsed.getTime())) { + return parsed.toISOString().split("T")[0] + } + return undefined + } + if (typeof value === "number" && Number.isFinite(value)) { + const parsed = new Date(value) + if (!Number.isNaN(parsed.getTime())) { + return parsed.toISOString().split("T")[0] + } + } + return undefined +} + +function buildCalendar( + files: QuartzPluginData[], + view: BaseView, + currentSlug: FullSlug, + allFiles: QuartzPluginData[], + getContext: EvalContextFactory, + getPropertyExpr: PropertyExprGetter, + properties?: Record, +): RenderElement { + const dateField = + typeof view.date === "string" + ? view.date + : typeof view.dateField === "string" + ? view.dateField + : typeof view.dateProperty === "string" + ? view.dateProperty + : "date" + + const renderListItem = createListItemRenderer( + view, + currentSlug, + allFiles, + getContext, + getPropertyExpr, + properties, + ) + + const groups = new Map() + for (const file of files) { + const dateValue = resolveValueWithFormulas(file, dateField, getContext, getPropertyExpr) + const dateKey = normalizeCalendarDate(dateValue) ?? "(no date)" + if (!groups.has(dateKey)) { + groups.set(dateKey, []) + } + groups.get(dateKey)!.push(file) + } + + const groupElements: RenderElement[] = [] + const sorted = [...groups.entries()].sort(([a], [b]) => a.localeCompare(b)) + for (const [dateKey, groupedFiles] of sorted) { + const items = groupedFiles.map((file) => renderListItem(file)) + groupElements.push( + h("div.base-calendar-group", [ + h("h3.base-calendar-group-header", dateKey), + h("ul.base-list", items), + ]), + ) + } + + return h("div.base-calendar-container", groupElements) +} + +function resolveCardImageUrl(imageValue: unknown, currentSlug: FullSlug): string | undefined { + const source = + typeof imageValue === "string" + ? imageValue + : Array.isArray(imageValue) && typeof imageValue[0] === "string" + ? imageValue[0] + : undefined + + if (!source) return undefined + + const trimmed = source.trim() + if (!trimmed) return undefined + + const toRelativeFromSlug = (target: string): string => { + if (isAbsoluteURL(target)) return target + const imgSlug = slugifyFilePath(target as FilePath) + return resolveRelative(currentSlug, imgSlug) + } + + const wl = trimmed.match(/^\[\[(.+?)\]\]$/) + if (wl) { + const inner = wl[1] + const { target } = splitTargetAndAlias(inner) + const { slug } = normalizeTargetSlug(target, currentSlug) + return resolveRelative(currentSlug, slug) + } + + return toRelativeFromSlug(trimmed) +} + +function buildCards( + files: QuartzPluginData[], + view: BaseView, + currentSlug: FullSlug, + allFiles: QuartzPluginData[], + getContext: EvalContextFactory, + getPropertyExpr: PropertyExprGetter, + properties?: Record, +): RenderElement { + const imageField = view.image || "image" + + const renderCard = (file: QuartzPluginData): RenderElement => { + const slug = (file.slug || "") as FullSlug + const title = getFileDisplayName(file) ?? fallbackNameFromSlug(slug) + const href = resolveRelative(currentSlug, slug) + + const imageValue = resolveValueWithFormulas(file, imageField, getContext, getPropertyExpr) + const imageUrl = resolveCardImageUrl(imageValue, currentSlug) + + const metadataItems: RenderElement[] = [] + const order = Array.isArray(view.order) ? view.order : [] + const metadataFields = order.filter( + (field): field is string => + typeof field === "string" && + field !== "title" && + field !== "file.title" && + field !== "note.title" && + field !== imageField, + ) + + for (const field of metadataFields) { + const value = resolveValueWithFormulas(file, field, getContext, getPropertyExpr) + if (!hasRenderableValue(value)) continue + const renderedValue = renderPropertyValueNodes(value, currentSlug, allFiles) + if (renderedValue.length === 0) continue + const label = getPropertyDisplayName(field, properties) + metadataItems.push( + h("div.base-card-meta-item", [ + h("span.base-card-meta-label", label), + h("span.base-card-meta-value", renderedValue), + ]), + ) + } + + const cardChildren: RenderElement[] = [] + if (imageUrl) { + cardChildren.push( + h( + "a.base-card-image-link", + { + href, + "data-slug": slug, + style: { + "background-image": `url(${imageUrl})`, + "background-size": "cover", + top: "0px", + "inset-inline": "0px", + }, + }, + [], + ), + ) + } + + const contentChildren: RenderElement[] = [ + h("a.base-card-title-link", { href, "data-slug": slug }, [h("h3.base-card-title", title)]), + ] + if (metadataItems.length > 0) { + contentChildren.push(h("div.base-card-meta", metadataItems)) + } + + cardChildren.push(h("div.base-card-content", contentChildren)) + + return h("div.base-card", cardChildren) + } + + const styleParts: string[] = [] + if (typeof view.cardSize === "number" && view.cardSize > 0) { + styleParts.push(`--base-card-min: ${view.cardSize}px;`) + } + if (typeof view.cardAspect === "number" && view.cardAspect > 0) { + styleParts.push(`--base-card-aspect: ${view.cardAspect};`) + } + const varStyle = styleParts.length > 0 ? styleParts.join(" ") : undefined + + if (view.groupBy) { + const groups = groupFiles(files, view.groupBy, getContext, getPropertyExpr) + const groupElements: RenderElement[] = [] + + const groupSizes = view.groupSizes + const groupAspects = view.groupAspects + + for (const [groupName, groupFiles] of groups) { + const cards = groupFiles.map((file) => renderCard(file)) + const parts: string[] = [] + const size = groupSizes?.[groupName] + if (typeof size === "number" && size > 0) { + parts.push(`--base-card-min: ${size}px;`) + } + const aspect = groupAspects?.[groupName] + if (typeof aspect === "number" && aspect > 0) { + parts.push(`--base-card-aspect: ${aspect};`) + } + const gridStyle = parts.length > 0 ? parts.join(" ") : undefined + + groupElements.push( + h("div.base-card-group", [ + h("h3.base-card-group-header", groupName), + h("div.base-card-grid", gridStyle ? { style: gridStyle } : {}, cards), + ]), + ) + } + + return h("div.base-card-container", varStyle ? { style: varStyle } : {}, groupElements) + } + + const cards = files.map((file) => renderCard(file)) + return h("div.base-card-grid", varStyle ? { style: varStyle } : {}, cards) +} + +type MapMarker = { + lat: number + lon: number + title: string + slug: FullSlug + icon?: string + color?: string + popupFields: Record +} + +function buildMap( + files: QuartzPluginData[], + view: BaseView, + currentSlug: FullSlug, + getContext: EvalContextFactory, + getPropertyExpr: PropertyExprGetter, + properties?: Record, +): RenderElement { + const resolveMapProperty = (file: QuartzPluginData, prop: string | undefined): unknown => { + if (!prop) return undefined + const key = prop.trim() + if (!key) return undefined + if (getPropertyExpr(key)) { + return resolveValueWithFormulas(file, key, getContext, getPropertyExpr) + } + if (key.startsWith("note.")) { + const stripped = key.replace(/^note\./, "") + if (getPropertyExpr(stripped)) { + return resolveValueWithFormulas(file, stripped, getContext, getPropertyExpr) + } + } else { + const prefixed = `note.${key}` + if (getPropertyExpr(prefixed)) { + return resolveValueWithFormulas(file, prefixed, getContext, getPropertyExpr) + } + } + return undefined + } + + const coordinatesProp = view.coordinates || "coordinates" + + const markers: MapMarker[] = [] + + for (const file of files) { + const coordsValue = resolveMapProperty(file, coordinatesProp) + if (!coordsValue || !Array.isArray(coordsValue) || coordsValue.length < 2) { + continue + } + + const lat = parseFloat(String(coordsValue[0])) + const lon = parseFloat(String(coordsValue[1])) + + if (isNaN(lat) || isNaN(lon)) { + continue + } + + const title = getFileDisplayName(file) ?? fallbackNameFromSlug((file.slug || "") as FullSlug) + const slug = (file.slug || "") as FullSlug + + const popupFields: Record = {} + const order = Array.isArray(view.order) ? view.order : [] + for (const field of order) { + if (typeof field !== "string") continue + if (field === "title" || field === "file.title" || field === "note.title") continue + const value = resolveValueWithFormulas(file, field, getContext, getPropertyExpr) + if (hasRenderableValue(value)) { + popupFields[field] = value + } + } + + const icon = resolveMapProperty(file, view.markerIcon) + const color = resolveMapProperty(file, view.markerColor) + + markers.push({ + lat, + lon, + title, + slug, + icon: icon ? String(icon) : undefined, + color: color ? String(color) : undefined, + popupFields, + }) + } + + const config = { + defaultZoom: view.defaultZoom ?? 12, + defaultCenter: view.defaultCenter, + clustering: view.clustering !== false, + } + + const attrs: Record = { + "data-markers": JSON.stringify(markers), + "data-config": JSON.stringify(config), + "data-current-slug": currentSlug, + } + + if (properties) { + attrs["data-properties"] = JSON.stringify(properties) + } + + return h("div.base-map", attrs) +} + +function resolveViewSlug(baseSlug: FullSlug, viewName: string, viewIndex: number): FullSlug { + if (viewIndex === 0) return baseSlug + const slugifiedName = slugifyFilePath((viewName + ".tmp") as FilePath, true) + return joinSegments(baseSlug, slugifiedName) as FullSlug +} + +function renderDiagnostics( + diagnostics: BaseExpressionDiagnostic[] | undefined, + currentSlug: FullSlug, +): RenderElement | undefined { + if (!diagnostics || diagnostics.length === 0) { + return undefined + } + const items: RenderElement[] = diagnostics.map((diag, index) => { + const line = diag.span.start.line + const column = diag.span.start.column + const location = Number.isFinite(line) && Number.isFinite(column) ? `${line}:${column}` : "" + const label = location.length > 0 ? `${diag.context} (${location})` : diag.context + return h("li.base-diagnostics-item", { key: String(index) }, [ + h("div.base-diagnostics-label", label), + h("div.base-diagnostics-message", diag.message), + h("code.base-diagnostics-source", diag.source), + ]) + }) + return h("div.base-diagnostics", [ + h("div.base-diagnostics-title", "bases diagnostics"), + h("div.base-diagnostics-meta", [ + h("span", "page"), + h("span.base-diagnostics-page", currentSlug), + ]), + h("ul.base-diagnostics-list", items), + ]) +} + +export type BaseViewMeta = { name: string; type: BaseView["type"]; slug: FullSlug } + +export type RenderedBaseView = { view: BaseView; slug: FullSlug; tree: Root } + +export type BaseMetadata = { baseSlug: FullSlug; currentView: string; allViews: BaseViewMeta[] } + +export function renderBaseViewsForFile( + baseFileData: QuartzPluginData, + allFiles: QuartzPluginData[], + thisFile?: QuartzPluginData, +): { views: RenderedBaseView[]; allViews: BaseViewMeta[] } { + const config = baseFileData.basesConfig as BaseFile | undefined + if (!config || !baseFileData.slug) { + return { views: [], allViews: [] } + } + + const baseSlug = baseFileData.slug as FullSlug + const expressions = baseFileData.basesExpressions + const formulaExpressions = expressions?.formulas + const summaryExpressionsByView = expressions?.viewSummaries + const viewFilterExpressions = expressions?.viewFilters + const formulaCaches = new Map>() + const propertyCaches = new Map>() + const runtimeDiagnostics: BaseExpressionDiagnostic[] = [] + const runtimeDiagnosticSet = new Set() + const propertyExpressions = expressions?.propertyExpressions ?? {} + + const fileIndex = new Map() + for (const entry of allFiles) { + if (!entry.slug) continue + fileIndex.set(simplifySlug(entry.slug as FullSlug), entry) + } + + const backlinkSets = new Map>() + for (const entry of allFiles) { + if (!entry.slug) continue + const sourceSlug = simplifySlug(entry.slug as FullSlug) + const links = Array.isArray(entry.links) ? entry.links.map((link) => String(link)) : [] + for (const link of links) { + if (!link) continue + let set = backlinkSets.get(link) + if (!set) { + set = new Set() + backlinkSets.set(link, set) + } + set.add(sourceSlug) + } + } + const backlinksIndex = new Map() + for (const [key, set] of backlinkSets) { + backlinksIndex.set(key, [...set]) + } + + const getPropertyExpr: PropertyExprGetter = (property) => { + const key = property.trim() + if (!key) return null + return propertyExpressions[key] ?? null + } + + const baseThisFile = thisFile ?? baseFileData + + const getEvalContext: EvalContextFactory = (file) => { + const slug = file.slug ? String(file.slug) : file.filePath ? String(file.filePath) : "" + let cache = formulaCaches.get(slug) + if (!cache) { + cache = new Map() + formulaCaches.set(slug, cache) + } + let propertyCache = propertyCaches.get(slug) + if (!propertyCache) { + propertyCache = new Map() + propertyCaches.set(slug, propertyCache) + } + return { + file, + thisFile: baseThisFile, + allFiles, + fileIndex, + backlinksIndex, + formulas: formulaExpressions, + formulaSources: config.formulas, + formulaCache: cache, + formulaStack: new Set(), + propertyCache, + diagnostics: runtimeDiagnostics, + diagnosticSet: runtimeDiagnosticSet, + } + } + + const allViews = config.views.map((view, idx) => ({ + name: view.name, + type: view.type, + slug: resolveViewSlug(baseSlug, view.name, idx), + })) + + const baseFilterSource = typeof config.filters === "string" ? config.filters : "" + const baseMatchedFiles = expressions?.filters + ? allFiles.filter((file) => { + const ctx = getEvalContext(file) + ctx.diagnosticContext = "filters" + ctx.diagnosticSource = baseFilterSource + return evaluateFilterExpression(expressions.filters!, ctx) + }) + : allFiles + + const views: RenderedBaseView[] = [] + + for (const [viewIndex, view] of config.views.entries()) { + const slug = resolveViewSlug(baseSlug, view.name, viewIndex) + + let matchedFiles = baseMatchedFiles + const viewFilter = viewFilterExpressions ? viewFilterExpressions[String(viewIndex)] : undefined + if (viewFilter) { + const viewFilterSource = typeof view.filters === "string" ? view.filters : "" + matchedFiles = matchedFiles.filter((file) => { + const ctx = getEvalContext(file) + ctx.diagnosticContext = `views[${viewIndex}].filters` + ctx.diagnosticSource = viewFilterSource + return evaluateFilterExpression(viewFilter, ctx) + }) + } + + const sortedFiles = applySorting(matchedFiles, view.sort, getEvalContext, getPropertyExpr) + const limitedFiles = view.limit ? sortedFiles.slice(0, view.limit) : sortedFiles + + const diagnostics = [...(baseFileData.basesDiagnostics ?? []), ...runtimeDiagnostics] + const diagnosticsNode = renderDiagnostics( + diagnostics.length > 0 ? diagnostics : undefined, + slug, + ) + const wrapView = (node: RenderElement) => + h("div.base-view", { "data-base-view-type": view.type, "data-base-view-name": view.name }, [ + node, + ]) + + let viewNode: RenderElement | undefined + switch (view.type) { + case "table": + viewNode = buildTable( + limitedFiles, + view, + slug, + allFiles, + getEvalContext, + getPropertyExpr, + config.properties, + config.summaries, + summaryExpressionsByView ? summaryExpressionsByView[String(viewIndex)] : undefined, + ) + break + case "list": + viewNode = buildList( + limitedFiles, + view, + slug, + allFiles, + getEvalContext, + getPropertyExpr, + config.properties, + ) + break + case "card": + case "cards": + case "gallery": + case "board": + viewNode = buildCards( + limitedFiles, + view, + slug, + allFiles, + getEvalContext, + getPropertyExpr, + config.properties, + ) + break + case "calendar": + viewNode = buildCalendar( + limitedFiles, + view, + slug, + allFiles, + getEvalContext, + getPropertyExpr, + config.properties, + ) + break + case "map": + viewNode = buildMap( + limitedFiles, + view, + slug, + getEvalContext, + getPropertyExpr, + config.properties, + ) + break + default: + viewNode = undefined + } + + if (!viewNode) continue + + const wrapped = wrapView(viewNode) + const tree: Root = { + type: "root", + children: diagnosticsNode ? [diagnosticsNode, wrapped] : [wrapped], + } + + views.push({ view, slug, tree }) + } + + return { views, allViews } +} diff --git a/quartz/util/base/types.test.ts b/quartz/util/base/types.test.ts new file mode 100644 index 000000000..1549ea8d0 --- /dev/null +++ b/quartz/util/base/types.test.ts @@ -0,0 +1,31 @@ +import assert from "node:assert" +import test from "node:test" +import { parseViews, parseViewSummaries } from "./types" + +test("parseViews preserves raw filters", () => { + const views = parseViews([ + { type: "table", name: "test", filters: 'status == "done"', order: ["file.name"] }, + ]) + + assert.strictEqual(views.length, 1) + assert.strictEqual(views[0].filters, 'status == "done"') + assert.deepStrictEqual(views[0].order, ["file.name"]) +}) + +test("parseViews rejects missing type/name", () => { + assert.throws(() => parseViews([{}])) +}) + +test("parseViewSummaries resolves builtin and formula refs", () => { + const summaries = parseViewSummaries( + { price: "Average", score: "avgScore", extra: "values.length" }, + { avgScore: "values.mean()" }, + ) + + assert.ok(summaries) + if (!summaries) return + assert.strictEqual(summaries.columns.price.type, "builtin") + assert.strictEqual(summaries.columns.score.type, "formula") + assert.strictEqual(summaries.columns.score.formulaRef, "avgScore") + assert.strictEqual(summaries.columns.extra.type, "formula") +}) diff --git a/quartz/util/base/types.ts b/quartz/util/base/types.ts new file mode 100644 index 000000000..4ab6ae4e6 --- /dev/null +++ b/quartz/util/base/types.ts @@ -0,0 +1,119 @@ +import { + SummaryDefinition, + ViewSummaryConfig, + PropertyConfig, + BuiltinSummaryType, + BUILTIN_SUMMARY_TYPES, +} from "./compiler/schema" + +export type { SummaryDefinition, ViewSummaryConfig, PropertyConfig, BuiltinSummaryType } +export { BUILTIN_SUMMARY_TYPES } + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value) + +const isNonEmptyString = (value: unknown): value is string => + typeof value === "string" && value.trim().length > 0 + +export type BaseFileFilter = + | string + | { and: BaseFileFilter[] } + | { or: BaseFileFilter[] } + | { not: BaseFileFilter[] } + +export interface BaseFile { + filters?: BaseFileFilter + views: BaseView[] + properties?: Record + summaries?: Record + formulas?: Record +} + +export interface BaseView { + type: "table" | "list" | "gallery" | "board" | "calendar" | "card" | "cards" | "map" + name: string + order?: string[] + sort?: BaseSortConfig[] + columnSize?: Record + groupBy?: string | BaseGroupBy + limit?: number + filters?: BaseFileFilter + summaries?: Record | ViewSummaryConfig + image?: string + cardSize?: number + cardAspect?: number + nestedProperties?: boolean + indentProperties?: boolean + separator?: string + date?: string + dateField?: string + dateProperty?: string + coordinates?: string + markerIcon?: string + markerColor?: string + defaultZoom?: number + defaultCenter?: [number, number] + clustering?: boolean + groupSizes?: Record + groupAspects?: Record +} + +export interface BaseSortConfig { + property: string + direction: "ASC" | "DESC" +} + +export interface BaseGroupBy { + property: string + direction: "ASC" | "DESC" +} + +export function parseViews(raw: unknown[]): BaseView[] { + return raw.map((entry) => { + if (!isRecord(entry)) throw new Error("Each view must be an object") + const { type, name } = entry + if (!isNonEmptyString(type) || !isNonEmptyString(name)) { + throw new Error("Each view must have 'type' and 'name' fields") + } + return { ...entry, type, name } as BaseView + }) +} + +export function parseViewSummaries( + viewSummaries: Record | ViewSummaryConfig | undefined, + topLevelSummaries?: Record, +): ViewSummaryConfig | undefined { + if (!viewSummaries || typeof viewSummaries !== "object") return undefined + + if ("columns" in viewSummaries && typeof viewSummaries.columns === "object") { + return viewSummaries as ViewSummaryConfig + } + + const columns: Record = {} + + for (const [column, summaryValue] of Object.entries(viewSummaries)) { + if (typeof summaryValue !== "string") continue + + const normalized = summaryValue.toLowerCase().trim() + + if (BUILTIN_SUMMARY_TYPES.includes(normalized as BuiltinSummaryType)) { + columns[column] = { type: "builtin", builtinType: normalized as BuiltinSummaryType } + continue + } + + if (topLevelSummaries && summaryValue in topLevelSummaries) { + columns[column] = { + type: "formula", + formulaRef: summaryValue, + expression: topLevelSummaries[summaryValue], + } + continue + } + + if (summaryValue.includes("(") || summaryValue.includes(".")) { + columns[column] = { type: "formula", expression: summaryValue } + } + } + + return Object.keys(columns).length > 0 ? { columns } : undefined +} diff --git a/quartz/util/path.ts b/quartz/util/path.ts index b95770159..cadcb2fd7 100644 --- a/quartz/util/path.ts +++ b/quartz/util/path.ts @@ -73,7 +73,7 @@ export function slugifyFilePath(fp: FilePath, excludeExt?: boolean): FullSlug { fp = stripSlashes(fp) as FilePath let ext = getFileExtension(fp) const withoutFileExt = fp.replace(new RegExp(ext + "$"), "") - if (excludeExt || [".md", ".html", undefined].includes(ext)) { + if (excludeExt || [".md", ".html", ".base", undefined].includes(ext)) { ext = "" } diff --git a/quartz/util/wikilinks.ts b/quartz/util/wikilinks.ts new file mode 100644 index 000000000..f4158aedc --- /dev/null +++ b/quartz/util/wikilinks.ts @@ -0,0 +1,94 @@ +import { FilePath, FullSlug, slugifyFilePath } from "./path" + +export type WikilinkWithPosition = { + wikilink: ParsedWikilink + start: number + end: number +} + +export type ParsedWikilink = { + raw: string + target: string + anchor?: string + alias?: string + embed: boolean +} + +export type ResolvedWikilink = { + slug: FullSlug + anchor?: string +} + +const wikilinkRegex = /^!?\[\[([^\]|#]+)(?:#([^\]|]+))?(?:\|([^\]]+))?\]\]$/ + +export function parseWikilink(text: string): ParsedWikilink | null { + const trimmed = text.trim() + const match = wikilinkRegex.exec(trimmed) + if (!match) return null + + const [, target, anchor, alias] = match + return { + raw: trimmed, + target: target?.trim() ?? "", + anchor: anchor?.trim(), + alias: alias?.trim(), + embed: trimmed.startsWith("!"), + } +} + +export function resolveWikilinkTarget( + parsed: ParsedWikilink, + currentSlug: FullSlug, +): ResolvedWikilink | null { + const target = parsed.target.trim() + if (!target) return null + + if (target.startsWith("/")) { + const slug = slugifyFilePath(target.slice(1).replace(/\\/g, "/") as FilePath) + return { slug, anchor: parsed.anchor } + } + + const currentParts = currentSlug.split("/") + const currentDir = currentParts.slice(0, -1) + + const targetParts = target.replace(/\\/g, "/").split("/") + const resolved: string[] = [...currentDir] + + for (const part of targetParts) { + if (part === "..") { + resolved.pop() + } else if (part !== "." && part.length > 0) { + resolved.push(part) + } + } + + const slug = slugifyFilePath(resolved.join("/") as FilePath) + return { slug, anchor: parsed.anchor } +} + +const globalWikilinkRegex = /!?\[\[([^\]|#]+)(?:#([^\]|]+))?(?:\|([^\]]+))?\]\]/g + +export function extractWikilinksWithPositions(text: string): WikilinkWithPosition[] { + const results: WikilinkWithPosition[] = [] + let match: RegExpExecArray | null + + globalWikilinkRegex.lastIndex = 0 + + while ((match = globalWikilinkRegex.exec(text)) !== null) { + const [fullMatch, target, anchor, alias] = match + + results.push({ + wikilink: { + raw: fullMatch, + target: target?.trim() ?? "", + anchor: anchor?.trim(), + alias: alias?.trim(), + embed: fullMatch.startsWith("!"), + }, + start: match.index, + end: match.index + fullMatch.length, + }) + } + + return results +}