mirror of
https://github.com/jackyzha0/quartz.git
synced 2026-02-03 22:15:42 -06:00
Merge 43d7da143e into ec00a40aef
This commit is contained in:
commit
9a8143e58d
@ -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.
|
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
|
## 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.
|
||||||
|
|||||||
42
docs/features/bases.md
Normal file
42
docs/features/bases.md
Normal file
@ -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 `<base-name>/<view-name>`.
|
||||||
|
|
||||||
|
## 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).
|
||||||
93
docs/navigation.base
Normal file
93
docs/navigation.base
Normal file
@ -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
|
||||||
18
docs/plugins/BasePage.md
Normal file
18
docs/plugins/BasePage.md
Normal file
@ -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).
|
||||||
20
docs/plugins/ObsidianBases.md
Normal file
20
docs/plugins/ObsidianBases.md
Normal file
@ -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).
|
||||||
@ -72,6 +72,7 @@ const config: QuartzConfig = {
|
|||||||
Plugin.CrawlLinks({ markdownLinkResolution: "shortest" }),
|
Plugin.CrawlLinks({ markdownLinkResolution: "shortest" }),
|
||||||
Plugin.Description(),
|
Plugin.Description(),
|
||||||
Plugin.Latex({ renderEngine: "katex" }),
|
Plugin.Latex({ renderEngine: "katex" }),
|
||||||
|
Plugin.ObsidianBases(),
|
||||||
],
|
],
|
||||||
filters: [Plugin.RemoveDrafts()],
|
filters: [Plugin.RemoveDrafts()],
|
||||||
emitters: [
|
emitters: [
|
||||||
@ -90,6 +91,7 @@ const config: QuartzConfig = {
|
|||||||
Plugin.NotFoundPage(),
|
Plugin.NotFoundPage(),
|
||||||
// Comment out CustomOgImages to speed up build time
|
// Comment out CustomOgImages to speed up build time
|
||||||
Plugin.CustomOgImages(),
|
Plugin.CustomOgImages(),
|
||||||
|
Plugin.BasePage(),
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@ -72,7 +72,7 @@ async function buildQuartz(argv: Argv, mut: Mutex, clientRefresh: () => void) {
|
|||||||
|
|
||||||
perf.addEvent("glob")
|
perf.addEvent("glob")
|
||||||
const allFiles = await glob("**/*.*", argv.directory, cfg.configuration.ignorePatterns)
|
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(
|
console.log(
|
||||||
`Found ${markdownPaths.length} input files from \`${argv.directory}\` in ${perf.timeSince("glob")}`,
|
`Found ${markdownPaths.length} input files from \`${argv.directory}\` in ${perf.timeSince("glob")}`,
|
||||||
)
|
)
|
||||||
|
|||||||
218
quartz/components/BaseViewSelector.tsx
Normal file
218
quartz/components/BaseViewSelector.tsx
Normal file
@ -0,0 +1,218 @@
|
|||||||
|
import { JSX } from "preact"
|
||||||
|
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: Record<string, JSX.Element> = {
|
||||||
|
table: (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<rect width="18" height="18" x="3" y="3" rx="2" />
|
||||||
|
<path d="M3 9h18" />
|
||||||
|
<path d="M3 15h18" />
|
||||||
|
<path d="M9 3v18" />
|
||||||
|
<path d="M15 3v18" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
list: (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<line x1="8" x2="21" y1="6" y2="6" />
|
||||||
|
<line x1="8" x2="21" y1="12" y2="12" />
|
||||||
|
<line x1="8" x2="21" y1="18" y2="18" />
|
||||||
|
<line x1="3" x2="3.01" y1="6" y2="6" />
|
||||||
|
<line x1="3" x2="3.01" y1="12" y2="12" />
|
||||||
|
<line x1="3" x2="3.01" y1="18" y2="18" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
chevronsUpDown: (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<path d="m7 15 5 5 5-5" />
|
||||||
|
<path d="m7 9 5-5 5 5" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
chevronRight: (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<path d="m9 18 6-6-6-6" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
x: (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M18 6 6 18" />
|
||||||
|
<path d="m6 6 12 12" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
map: (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M15 6v12a3 3 0 1 0 3-3H6a3 3 0 1 0 3 3V6a3 3 0 1 0-3 3h12a3 3 0 1 0-3-3" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
card: (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<rect width="7" height="7" x="3" y="3" rx="1" />
|
||||||
|
<rect width="7" height="7" x="14" y="3" rx="1" />
|
||||||
|
<rect width="7" height="7" x="14" y="14" rx="1" />
|
||||||
|
<rect width="7" height="7" x="3" y="14" rx="1" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
const viewTypeIcons: Record<string, JSX.Element | undefined> = {
|
||||||
|
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 (
|
||||||
|
<div class={classNames(displayClass, "bases-toolbar")} data-base-view-selector>
|
||||||
|
<div class="bases-toolbar-item bases-toolbar-views-menu">
|
||||||
|
<span
|
||||||
|
class="text-icon-button"
|
||||||
|
aria-label="Select view"
|
||||||
|
aria-expanded="false"
|
||||||
|
aria-haspopup="true"
|
||||||
|
role="button"
|
||||||
|
tabindex={0}
|
||||||
|
>
|
||||||
|
<span class="text-button-icon">{currentIcon}</span>
|
||||||
|
<span class="text-button-label">{currentViewName.toLowerCase()}</span>
|
||||||
|
<span class="text-button-icon mod-aux">{icons.chevronsUpDown}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="menu-scroll" data-dropdown>
|
||||||
|
<div class="bases-toolbar-menu-container">
|
||||||
|
<div class="search-input-container">
|
||||||
|
<input type="search" placeholder="Search..." data-search-input />
|
||||||
|
<div class="search-input-clear-button" data-clear-search hidden>
|
||||||
|
{icons.x}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="bases-toolbar-items">
|
||||||
|
<div class="suggestion-group" data-group="views" data-view-list>
|
||||||
|
{allViews.map((view) => {
|
||||||
|
const isActive = view.name === currentViewName
|
||||||
|
const icon = viewTypeIcons[view.type] || icons.table
|
||||||
|
const href = resolveRelative(fileData.slug!, view.slug)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={href}
|
||||||
|
data-slug={view.slug}
|
||||||
|
class={
|
||||||
|
isActive
|
||||||
|
? "suggestion-item bases-toolbar-menu-item mod-active is-selected"
|
||||||
|
: "suggestion-item bases-toolbar-menu-item"
|
||||||
|
}
|
||||||
|
data-view-name={view.name}
|
||||||
|
data-view-type={view.type}
|
||||||
|
>
|
||||||
|
<div class="bases-toolbar-menu-item-info">
|
||||||
|
<div class="bases-toolbar-menu-item-info-icon">{icon}</div>
|
||||||
|
<div class="bases-toolbar-menu-item-name">{view.name.toLowerCase()}</div>
|
||||||
|
</div>
|
||||||
|
<div class="clickable-icon bases-toolbar-menu-item-icon">
|
||||||
|
{icons.chevronRight}
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
BaseViewSelector.css = baseViewSelectorStyle
|
||||||
|
BaseViewSelector.afterDOMLoaded = script
|
||||||
|
|
||||||
|
export default (() => BaseViewSelector) satisfies QuartzComponentConstructor
|
||||||
@ -51,7 +51,9 @@ export default ((opts?: Partial<BreadcrumbOptions>) => {
|
|||||||
ctx,
|
ctx,
|
||||||
}: QuartzComponentProps) => {
|
}: QuartzComponentProps) => {
|
||||||
const trie = (ctx.trie ??= trieFromAllFiles(allFiles))
|
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)
|
const pathNodes = trie.ancestryChain(slugParts)
|
||||||
|
|
||||||
if (!pathNodes) {
|
if (!pathNodes) {
|
||||||
@ -64,14 +66,24 @@ export default ((opts?: Partial<BreadcrumbOptions>) => {
|
|||||||
crumb.displayName = options.rootName
|
crumb.displayName = options.rootName
|
||||||
}
|
}
|
||||||
|
|
||||||
// For last node (current page), set empty path
|
|
||||||
if (idx === pathNodes.length - 1) {
|
if (idx === pathNodes.length - 1) {
|
||||||
crumb.path = ""
|
if (baseMeta) {
|
||||||
|
crumb.path = resolveRelative(fileData.slug!, simplifySlug(baseMeta.baseSlug))
|
||||||
|
} else {
|
||||||
|
crumb.path = ""
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return crumb
|
return crumb
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (baseMeta && options.showCurrentPage) {
|
||||||
|
crumbs.push({
|
||||||
|
displayName: baseMeta.currentView.replaceAll("-", " "),
|
||||||
|
path: "",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
if (!options.showCurrentPage) {
|
if (!options.showCurrentPage) {
|
||||||
crumbs.pop()
|
crumbs.pop()
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
import Content from "./pages/Content"
|
import Content from "./pages/Content"
|
||||||
import TagContent from "./pages/TagContent"
|
import TagContent from "./pages/TagContent"
|
||||||
import FolderContent from "./pages/FolderContent"
|
import FolderContent from "./pages/FolderContent"
|
||||||
|
import BaseContent from "./pages/BaseContent"
|
||||||
|
import BaseViewSelector from "./BaseViewSelector"
|
||||||
import NotFound from "./pages/404"
|
import NotFound from "./pages/404"
|
||||||
import ArticleTitle from "./ArticleTitle"
|
import ArticleTitle from "./ArticleTitle"
|
||||||
import Darkmode from "./Darkmode"
|
import Darkmode from "./Darkmode"
|
||||||
@ -29,6 +31,8 @@ export {
|
|||||||
Content,
|
Content,
|
||||||
TagContent,
|
TagContent,
|
||||||
FolderContent,
|
FolderContent,
|
||||||
|
BaseContent,
|
||||||
|
BaseViewSelector,
|
||||||
Darkmode,
|
Darkmode,
|
||||||
ReaderMode,
|
ReaderMode,
|
||||||
Head,
|
Head,
|
||||||
|
|||||||
20
quartz/components/pages/BaseContent.tsx
Normal file
20
quartz/components/pages/BaseContent.tsx
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
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 (
|
||||||
|
<div class="popover-hint">
|
||||||
|
<article class={["base-content", ...(fileData.frontmatter?.cssclasses ?? [])].join(" ")}>
|
||||||
|
{htmlToJsx(fileData.filePath!, fileData.basesRenderedTree ?? tree)}
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
BaseContent.css = style
|
||||||
|
return BaseContent
|
||||||
|
}) satisfies QuartzComponentConstructor
|
||||||
144
quartz/components/scripts/base-view-selector.inline.ts
Normal file
144
quartz/components/scripts/base-view-selector.inline.ts
Normal file
@ -0,0 +1,144 @@
|
|||||||
|
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 triggerEl = selector.querySelector(".text-icon-button") as HTMLElement | null
|
||||||
|
const searchInputEl = selector.querySelector("[data-search-input]") as HTMLInputElement | null
|
||||||
|
const clearButtonEl = selector.querySelector("[data-clear-search]") as HTMLElement | null
|
||||||
|
const viewListEl = selector.querySelector("[data-view-list]") as HTMLElement | null
|
||||||
|
|
||||||
|
if (!triggerEl || !searchInputEl || !clearButtonEl || !viewListEl) return
|
||||||
|
|
||||||
|
const trigger = triggerEl
|
||||||
|
const searchInput = searchInputEl
|
||||||
|
const clearButton = clearButtonEl
|
||||||
|
const viewList = viewListEl
|
||||||
|
|
||||||
|
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<HTMLElement>(".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)
|
||||||
299
quartz/components/styles/basePage.scss
Normal file
299
quartz/components/styles/basePage.scss
Normal file
@ -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;
|
||||||
|
}
|
||||||
275
quartz/components/styles/baseViewSelector.scss
Normal file
275
quartz/components/styles/baseViewSelector.scss
Normal file
@ -0,0 +1,275 @@
|
|||||||
|
@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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -7,8 +7,12 @@ import { Argv } from "../../util/ctx"
|
|||||||
import { QuartzConfig } from "../../cfg"
|
import { QuartzConfig } from "../../cfg"
|
||||||
|
|
||||||
const filesToCopy = async (argv: Argv, cfg: QuartzConfig) => {
|
const filesToCopy = async (argv: Argv, cfg: QuartzConfig) => {
|
||||||
// glob all non MD files in content folder and copy it over
|
// glob all non MD/base files in content folder and copy it over
|
||||||
return await glob("**", argv.directory, ["**/*.md", ...cfg.configuration.ignorePatterns])
|
return await glob("**", argv.directory, [
|
||||||
|
"**/*.md",
|
||||||
|
"**/*.base",
|
||||||
|
...cfg.configuration.ignorePatterns,
|
||||||
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
const copyFile = async (argv: Argv, fp: FilePath) => {
|
const copyFile = async (argv: Argv, fp: FilePath) => {
|
||||||
@ -37,7 +41,7 @@ export const Assets: QuartzEmitterPlugin = () => {
|
|||||||
async *partialEmit(ctx, _content, _resources, changeEvents) {
|
async *partialEmit(ctx, _content, _resources, changeEvents) {
|
||||||
for (const changeEvent of changeEvents) {
|
for (const changeEvent of changeEvents) {
|
||||||
const ext = path.extname(changeEvent.path)
|
const ext = path.extname(changeEvent.path)
|
||||||
if (ext === ".md") continue
|
if (ext === ".md" || ext === ".base") continue
|
||||||
|
|
||||||
if (changeEvent.type === "add" || changeEvent.type === "change") {
|
if (changeEvent.type === "add" || changeEvent.type === "change") {
|
||||||
yield copyFile(ctx.argv, changeEvent.path)
|
yield copyFile(ctx.argv, changeEvent.path)
|
||||||
|
|||||||
184
quartz/plugins/emitters/basePage.tsx
Normal file
184
quartz/plugins/emitters/basePage.tsx
Normal file
@ -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<Partial<BasePageOptions>> = (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<string>()
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -83,6 +83,8 @@ export const ContentPage: QuartzEmitterPlugin<Partial<FullPageLayout>> = (userOp
|
|||||||
containsIndex = true
|
containsIndex = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (file.data.filePath!.endsWith(".base")) continue
|
||||||
|
|
||||||
// only process home page, non-tag pages, and non-index pages
|
// only process home page, non-tag pages, and non-index pages
|
||||||
if (slug.endsWith("/index") || slug.startsWith("tags/")) continue
|
if (slug.endsWith("/index") || slug.startsWith("tags/")) continue
|
||||||
yield processContent(ctx, tree, file.data, allFiles, opts, resources)
|
yield processContent(ctx, tree, file.data, allFiles, opts, resources)
|
||||||
@ -112,6 +114,7 @@ export const ContentPage: QuartzEmitterPlugin<Partial<FullPageLayout>> = (userOp
|
|||||||
for (const [tree, file] of content) {
|
for (const [tree, file] of content) {
|
||||||
const slug = file.data.slug!
|
const slug = file.data.slug!
|
||||||
if (!changedSlugs.has(slug)) continue
|
if (!changedSlugs.has(slug)) continue
|
||||||
|
if (file.data.filePath!.endsWith(".base")) continue
|
||||||
if (slug.endsWith("/index") || slug.startsWith("tags/")) continue
|
if (slug.endsWith("/index") || slug.startsWith("tags/")) continue
|
||||||
|
|
||||||
yield processContent(ctx, tree, file.data, allFiles, opts, resources)
|
yield processContent(ctx, tree, file.data, allFiles, opts, resources)
|
||||||
|
|||||||
@ -10,3 +10,4 @@ export { ComponentResources } from "./componentResources"
|
|||||||
export { NotFoundPage } from "./404"
|
export { NotFoundPage } from "./404"
|
||||||
export { CNAME } from "./cname"
|
export { CNAME } from "./cname"
|
||||||
export { CustomOgImages } from "./ogImage"
|
export { CustomOgImages } from "./ogImage"
|
||||||
|
export { BasePage } from "./basePage"
|
||||||
|
|||||||
521
quartz/plugins/transformers/bases.ts
Normal file
521
quartz/plugins/transformers/bases.ts
Normal file
@ -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<string> {
|
||||||
|
const properties = new Set<string>()
|
||||||
|
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<string>,
|
||||||
|
file: string,
|
||||||
|
diagnostics: BaseExpressionDiagnostic[],
|
||||||
|
): Record<string, ProgramIR> {
|
||||||
|
const expressions: Record<string, ProgramIR> = {}
|
||||||
|
|
||||||
|
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<string, string> | undefined,
|
||||||
|
file: string,
|
||||||
|
diagnostics: BaseExpressionDiagnostic[],
|
||||||
|
): Record<string, ProgramIR> {
|
||||||
|
if (!formulas) return {}
|
||||||
|
|
||||||
|
const compiled: Record<string, ProgramIR> = {}
|
||||||
|
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<string, string> | undefined,
|
||||||
|
file: string,
|
||||||
|
diagnostics: BaseExpressionDiagnostic[],
|
||||||
|
): Record<string, ProgramIR> {
|
||||||
|
if (!summaries) return {}
|
||||||
|
|
||||||
|
const compiled: Record<string, ProgramIR> = {}
|
||||||
|
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<string, string> | undefined,
|
||||||
|
file: string,
|
||||||
|
diagnostics: BaseExpressionDiagnostic[],
|
||||||
|
): Record<string, Record<string, ProgramIR>> {
|
||||||
|
const result: Record<string, Record<string, ProgramIR>> = {}
|
||||||
|
|
||||||
|
for (let i = 0; i < views.length; i++) {
|
||||||
|
const view = views[i]
|
||||||
|
if (!view.summaries) continue
|
||||||
|
|
||||||
|
const viewSummaryConfig = parseViewSummaries(
|
||||||
|
view.summaries as Record<string, string>,
|
||||||
|
topLevelSummaries,
|
||||||
|
)
|
||||||
|
if (!viewSummaryConfig?.columns) continue
|
||||||
|
|
||||||
|
const viewExpressions: Record<string, ProgramIR> = {}
|
||||||
|
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<Partial<BasesOptions>> = (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<string, { displayName?: string }>
|
||||||
|
| undefined
|
||||||
|
const summaries = parsed.summaries as Record<string, string> | undefined
|
||||||
|
const formulas = parsed.formulas as Record<string, string> | undefined
|
||||||
|
|
||||||
|
const baseConfig: BaseFile = {
|
||||||
|
filters,
|
||||||
|
views,
|
||||||
|
properties,
|
||||||
|
summaries,
|
||||||
|
formulas,
|
||||||
|
}
|
||||||
|
|
||||||
|
const compiledFilters = compileFilterStructure(
|
||||||
|
filters as FilterStructure | undefined,
|
||||||
|
filePathStr,
|
||||||
|
diagnostics,
|
||||||
|
"filters",
|
||||||
|
)
|
||||||
|
|
||||||
|
const viewFilters: Record<string, ProgramIR> = {}
|
||||||
|
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<string, unknown>
|
||||||
|
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[]
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -11,3 +11,4 @@ export { SyntaxHighlighting } from "./syntax"
|
|||||||
export { TableOfContents } from "./toc"
|
export { TableOfContents } from "./toc"
|
||||||
export { HardLineBreaks } from "./linebreaks"
|
export { HardLineBreaks } from "./linebreaks"
|
||||||
export { RoamFlavoredMarkdown } from "./roam"
|
export { RoamFlavoredMarkdown } from "./roam"
|
||||||
|
export { ObsidianBases } from "./bases"
|
||||||
|
|||||||
@ -289,8 +289,11 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// internal link
|
const isBaseFile = fp.endsWith(".base")
|
||||||
const url = fp + anchor
|
const basePath = isBaseFile ? fp.slice(0, -5) : fp
|
||||||
|
const url = isBaseFile
|
||||||
|
? basePath + (anchor ? `/${anchor.slice(1).replace(/\s+/g, "-")}` : "")
|
||||||
|
: fp + anchor
|
||||||
|
|
||||||
return {
|
return {
|
||||||
type: "link",
|
type: "link",
|
||||||
@ -298,7 +301,7 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>>
|
|||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
type: "text",
|
type: "text",
|
||||||
value: alias ?? fp,
|
value: alias ?? basePath,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|||||||
@ -104,12 +104,16 @@ export function createFileParser(ctx: BuildCtx, fps: FilePath[]) {
|
|||||||
file.data.relativePath = path.posix.relative(argv.directory, file.path) as FilePath
|
file.data.relativePath = path.posix.relative(argv.directory, file.path) as FilePath
|
||||||
file.data.slug = slugifyFilePath(file.data.relativePath)
|
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)
|
const newAst = await processor.run(ast, file)
|
||||||
res.push([newAst, file])
|
res.push([newAst, file])
|
||||||
|
|
||||||
if (argv.verbose) {
|
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) {
|
} catch (err) {
|
||||||
trace(`\nFailed to process markdown \`${fp}\``, err as Error)
|
trace(`\nFailed to process markdown \`${fp}\``, err as Error)
|
||||||
|
|||||||
92
quartz/util/base/README.md
Normal file
92
quartz/util/base/README.md
Normal file
@ -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
|
||||||
76
quartz/util/base/compiler/ast.ts
Normal file
76
quartz/util/base/compiler/ast.ts
Normal file
@ -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 }
|
||||||
|
}
|
||||||
9
quartz/util/base/compiler/diagnostics.ts
Normal file
9
quartz/util/base/compiler/diagnostics.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import { Span } from "./ast"
|
||||||
|
|
||||||
|
export type BaseExpressionDiagnostic = {
|
||||||
|
kind: "lex" | "parse" | "runtime"
|
||||||
|
message: string
|
||||||
|
span: Span
|
||||||
|
context: string
|
||||||
|
source: string
|
||||||
|
}
|
||||||
3
quartz/util/base/compiler/errors.ts
Normal file
3
quartz/util/base/compiler/errors.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
import { Span } from "./ast"
|
||||||
|
|
||||||
|
export type Diagnostic = { kind: "lex" | "parse"; message: string; span: Span }
|
||||||
10
quartz/util/base/compiler/expressions.ts
Normal file
10
quartz/util/base/compiler/expressions.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import { ProgramIR } from "./ir"
|
||||||
|
|
||||||
|
export type BasesExpressions = {
|
||||||
|
filters?: ProgramIR
|
||||||
|
viewFilters: Record<string, ProgramIR>
|
||||||
|
formulas: Record<string, ProgramIR>
|
||||||
|
summaries: Record<string, ProgramIR>
|
||||||
|
viewSummaries: Record<string, Record<string, ProgramIR>>
|
||||||
|
propertyExpressions: Record<string, ProgramIR>
|
||||||
|
}
|
||||||
44
quartz/util/base/compiler/index.ts
Normal file
44
quartz/util/base/compiler/index.ts
Normal file
@ -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"
|
||||||
73
quartz/util/base/compiler/interpreter.test.ts
Normal file
73
quartz/util/base/compiler/interpreter.test.ts
Normal file
@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
1718
quartz/util/base/compiler/interpreter.ts
Normal file
1718
quartz/util/base/compiler/interpreter.ts
Normal file
File diff suppressed because it is too large
Load Diff
164
quartz/util/base/compiler/ir.ts
Normal file
164
quartz/util/base/compiler/ir.ts
Normal file
@ -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 }
|
||||||
|
}
|
||||||
53
quartz/util/base/compiler/lexer.test.ts
Normal file
53
quartz/util/base/compiler/lexer.test.ts
Normal file
@ -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)
|
||||||
|
})
|
||||||
300
quartz/util/base/compiler/lexer.ts
Normal file
300
quartz/util/base/compiler/lexer.ts
Normal file
@ -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 }
|
||||||
|
}
|
||||||
261
quartz/util/base/compiler/parser.test.ts
Normal file
261
quartz/util/base/compiler/parser.test.ts
Normal file
@ -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<string, unknown> =>
|
||||||
|
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)
|
||||||
|
})
|
||||||
370
quartz/util/base/compiler/parser.ts
Normal file
370
quartz/util/base/compiler/parser.ts
Normal file
@ -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<string, InfixInfo> = {
|
||||||
|
"||": { 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
|
||||||
|
}
|
||||||
|
}
|
||||||
27
quartz/util/base/compiler/properties.test.ts
Normal file
27
quartz/util/base/compiler/properties.test.ts
Normal file
@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
27
quartz/util/base/compiler/properties.ts
Normal file
27
quartz/util/base/compiler/properties.ts
Normal file
@ -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)
|
||||||
|
}
|
||||||
36
quartz/util/base/compiler/schema.ts
Normal file
36
quartz/util/base/compiler/schema.ts
Normal file
@ -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<string, SummaryDefinition>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PropertyConfig {
|
||||||
|
displayName?: string
|
||||||
|
}
|
||||||
42
quartz/util/base/compiler/tokens.ts
Normal file
42
quartz/util/base/compiler/tokens.ts
Normal file
@ -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
|
||||||
278
quartz/util/base/inspec-base.ts
Normal file
278
quartz/util/base/inspec-base.ts
Normal file
@ -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<string, unknown> =>
|
||||||
|
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<string, { source: string; context: string }> => {
|
||||||
|
const entries = new Map<string, { source: string; context: string }>()
|
||||||
|
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<string>(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()
|
||||||
248
quartz/util/base/query.ts
Normal file
248
quartz/util/base/query.ts
Normal file
@ -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<string, ProgramIR>,
|
||||||
|
): Record<string, string | number | undefined> {
|
||||||
|
const results: Record<string, string | number | undefined> = {}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
1335
quartz/util/base/render.ts
Normal file
1335
quartz/util/base/render.ts
Normal file
File diff suppressed because it is too large
Load Diff
31
quartz/util/base/types.test.ts
Normal file
31
quartz/util/base/types.test.ts
Normal file
@ -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")
|
||||||
|
})
|
||||||
119
quartz/util/base/types.ts
Normal file
119
quartz/util/base/types.ts
Normal file
@ -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<string, unknown> =>
|
||||||
|
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<string, PropertyConfig>
|
||||||
|
summaries?: Record<string, string>
|
||||||
|
formulas?: Record<string, string>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BaseView {
|
||||||
|
type: "table" | "list" | "gallery" | "board" | "calendar" | "card" | "cards" | "map"
|
||||||
|
name: string
|
||||||
|
order?: string[]
|
||||||
|
sort?: BaseSortConfig[]
|
||||||
|
columnSize?: Record<string, number>
|
||||||
|
groupBy?: string | BaseGroupBy
|
||||||
|
limit?: number
|
||||||
|
filters?: BaseFileFilter
|
||||||
|
summaries?: Record<string, string> | 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<string, number>
|
||||||
|
groupAspects?: Record<string, number>
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string, string> | ViewSummaryConfig | undefined,
|
||||||
|
topLevelSummaries?: Record<string, string>,
|
||||||
|
): ViewSummaryConfig | undefined {
|
||||||
|
if (!viewSummaries || typeof viewSummaries !== "object") return undefined
|
||||||
|
|
||||||
|
if ("columns" in viewSummaries && typeof viewSummaries.columns === "object") {
|
||||||
|
return viewSummaries as ViewSummaryConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns: Record<string, SummaryDefinition> = {}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
@ -73,7 +73,7 @@ export function slugifyFilePath(fp: FilePath, excludeExt?: boolean): FullSlug {
|
|||||||
fp = stripSlashes(fp) as FilePath
|
fp = stripSlashes(fp) as FilePath
|
||||||
let ext = getFileExtension(fp)
|
let ext = getFileExtension(fp)
|
||||||
const withoutFileExt = fp.replace(new RegExp(ext + "$"), "")
|
const withoutFileExt = fp.replace(new RegExp(ext + "$"), "")
|
||||||
if (excludeExt || [".md", ".html", undefined].includes(ext)) {
|
if (excludeExt || [".md", ".html", ".base", undefined].includes(ext)) {
|
||||||
ext = ""
|
ext = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
94
quartz/util/wikilinks.ts
Normal file
94
quartz/util/wikilinks.ts
Normal file
@ -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
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user