Skip to content

feat(obsidian): bases implementation#2292

Open
aarnphm wants to merge 3 commits intov4from
feat/bases
Open

feat(obsidian): bases implementation#2292
aarnphm wants to merge 3 commits intov4from
feat/bases

Conversation

@aarnphm
Copy link
Collaborator

@aarnphm aarnphm commented Jan 30, 2026

This PR ports my own implementation of bases in personal vault towards upstream Quartz v4. A decision was made to include this into v4 whilst giving us time to work on v5.

Preview link can be found here: https://jackyzha0-feat-bases.quartz-1h4.pages.dev/navigation

Surely there will be plenty of bug, but the compilers and ast are largely compliant with Obsidian spec here.

Would be good to upstream these changes to gauge people interactions and notes/feedback/contributions for PR. I will also send this implementation to the core Obsidian team for feedback.

Closes #1995

Thanks Codex to help migrate a bunch of final links within the build systems (not the compiler) to make it work with Quartz v4 (not my own fork), and Kimi K2.5 for the ghost-writing of docs.

few caveats:

  • transclude of base file is not implemented yet (i need to refactor out from my own implementation to put it upstream)
  • base codeblock is possible to implement, but it will mangle a bit on implementation on the ofm.ts side, which is fine, we will tackle afterwards.

The following section is mostly some of the design docs I have whilst building the interpreter and AST.

ebnf

expression     := logical_or
logical_or     := logical_and ( "||" logical_and )*
logical_and    := equality ( "&&" equality )*
equality       := comparison ( ( "==" | "!=" ) comparison )*
comparison     := additive ( ( ">" | ">=" | "<" | "<=" ) additive )*
additive       := multiplicative ( ( "+" | "-" ) multiplicative )*
multiplicative := unary ( ( "*" | "/" | "%" ) unary )*
unary          := ( "!" | "-" ) unary | call
call           := primary ( member_access | index_access | func_call )*
member_access  := "." identifier
index_access   := "[" expression "]"
func_call      := "(" arguments? ")"
arguments      := expression ( "," expression )*
primary        := literal
               | identifier
               | "this"
               | "(" expression ")"
               | list
list           := "[" ( expression ( "," expression )* )? "]"

literal        := number | string | boolean | null | date_literal | duration_literal | regex_literal
identifier     := ident_start ident_continue*

ast

Program
  body: Expr

Expr =
  | Literal
  | Identifier
  | UnaryExpr
  | BinaryExpr
  | LogicalExpr
  | CallExpr
  | MemberExpr
  | IndexExpr
  | ListExpr

Literal
  kind: "number" | "string" | "boolean" | "null" | "date" | "duration"
  value: number | string | boolean

Identifier
  name: string

UnaryExpr
  operator: "!" | "-"
  argument: Expr

BinaryExpr
  operator: "+" | "-" | "*" | "/" | "%" | "==" | "!=" | ">" | ">=" | "<" | "<="
  left: Expr
  right: Expr

LogicalExpr
  operator: "&&" | "||"
  left: Expr
  right: Expr

CallExpr
  callee: Expr
  args: Expr[]

MemberExpr
  object: Expr
  property: string

IndexExpr
  object: Expr
  index: Expr

ListExpr
  elements: Expr[]

These are the mapping between ebnf to the ast defined above:

production ast node
expression Program.body (Expr)
logical_or LogicalExpr (operator "||")
logical_and LogicalExpr (operator "&&")
equality BinaryExpr ("==" or "!=")
comparison BinaryExpr ("<", "<=", ">", ">=")
additive BinaryExpr ("+" or "-")
multiplicative BinaryExpr ("*", "/", "%")
unary UnaryExpr ("!" or "-")
member_access MemberExpr
index_access IndexExpr
func_call CallExpr
list ListExpr
literal Literal
identifier Identifier

I use Pratt parser for this implementation, along with a stack-based IR:

- const <literal>
- ident <name>
- load_formula <name>
- load_formula_index
- member <property>
- index
- list <count>
- unary <op>
- binary <op>
- to_bool
- call_global <name> <argc>
- call_method <name> <argc>
- call_dynamic
- filter <program?>
- map <program?>
- reduce <program?> <initial?>
- jump <target>
- jump_if_false <target>
- jump_if_true <target>

I believe this compiler/interpreter compiles with all builtin functions provided by Obsidian.

decision (and possible derivation)

comparisons:

  • scalar types are compared with permissive coercions (string, number, boolean, date, duration, null)
  • == and != coerce scalars to date, then number, then string comparison
  • > < >= <= are valid for scalar comparisons; list and object comparisons return null
  • null compares equal to empty string and empty-ish scalar values after coercion
  • link comparisons: links can compare to files or links, if the link resolves to a file compare by file, otherwise compare by text

arithmetic:

  • number op number yields number
  • date + duration yields date
  • date - duration yields date
  • date - date yields duration in ms
  • duration + duration yields duration
  • duration - duration yields duration
  • duration * number yields duration (duration must be on the left)
  • duration / number yields duration (duration must be on the left)
  • string + any yields string via any.toString()
  • other mixed-type arithmetic returns null and raises a runtime diagnostic

truthiness:

  • boolean value uses itself
  • null, empty string, empty list, empty object are false in isTruthy
  • if(condition, ...) uses condition.isTruthy() semantics

errors:

  • parse errors include span, line, col, message
  • unknown function or method yields a deterministic error and returns null
  • invalid arity yields error and returns null
  • runtime errors do not crash build, they are collected and attached to the base view output

Signed-off-by: Aaron Pham contact@aarnphm.xyz

Signed-off-by: Aaron Pham <contact@aarnphm.xyz>
Signed-off-by: Aaron Pham <contact@aarnphm.xyz>
@github-actions
Copy link

github-actions bot commented Jan 30, 2026

built with Refined Cloudflare Pages Action

⚡ Cloudflare Pages Deployment

Name Status Preview Last Commit
quartz ✅ Ready (View Log) Visit Preview 43d7da1

Signed-off-by: Aaron Pham <contact@aarnphm.xyz>
@saberzero1
Copy link
Collaborator

saberzero1 commented Jan 31, 2026

Overall, amazing work. I was just going to use a yaml parser, but a proper formalized grammar is significantly better for this.

I'll run some extensive tests tomorrow.

As general feedback/questions:

  • Does this PR include all standard supported Bases views?
    • The Map view is technically a community plugin (although first-party). According to Kepano this is to not unnecessarily increase the bundle size and especially startup time for non-Bases users.
  • Do we support embedding Bases using ![[]] syntax?
  • Do the views DOM match the views DOM in Obsidian?
    • This is mostly relevant for Quartz Themes. No biggie if it doesn't match, as I have to update the extraction mappings either way.
  • How easily extensible is this? E.g. if any user wants to add an additional view or when Obsidian add more default views?

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR adds first-party support for Obsidian Bases (.base) in Quartz v4 by introducing a Bases compiler/runtime, integrating parsing and rendering into the build pipeline, and exposing new docs and UI for switching between base views.

Changes:

  • Add a Bases expression compiler + interpreter and a renderer for table/list/cards/calendar/map views.
  • Integrate .base files into parsing/build: new ObsidianBases transformer + BasePage emitter, and build pipeline updates to include .base.
  • Add UI components/styles/scripts for switching between base views, plus documentation and example .base content.

Reviewed changes

Copilot reviewed 46 out of 46 changed files in this pull request and generated 6 comments.

Show a summary per file
File Description
quartz/util/wikilinks.ts New wikilink parser/resolver used by Bases rendering/runtime.
quartz/util/path.ts Treat .base as an extension to strip when slugifying file paths.
quartz/util/base/types.ts Base config types + helpers for parsing views and summary configs.
quartz/util/base/types.test.ts Tests for base view parsing + summary config normalization.
quartz/util/base/render.ts Bases view renderer (table/list/cards/calendar/map) + diagnostics display.
quartz/util/base/query.ts Summary computation helpers (builtins + formula summaries).
quartz/util/base/inspec-base.ts CLI utility to parse/compile expressions from a .base file.
quartz/util/base/compiler/tokens.ts Token definitions for Bases expression language.
quartz/util/base/compiler/schema.ts Summary schema + builtin summary types list.
quartz/util/base/compiler/properties.ts Property expression source builder for columns/config keys.
quartz/util/base/compiler/properties.test.ts Tests for property expression source builder.
quartz/util/base/compiler/parser.ts Pratt parser implementation for Bases expressions.
quartz/util/base/compiler/parser.test.ts Parser tests + AST shape snapshots for grammar samples.
quartz/util/base/compiler/lexer.ts Lexer with span tracking and regex literal handling.
quartz/util/base/compiler/lexer.test.ts Lexer tests (regex, escapes, bracket access).
quartz/util/base/compiler/ir.ts Expression compiler to stack-based bytecode IR.
quartz/util/base/compiler/interpreter.ts Stack VM interpreter + value model + builtins/methods.
quartz/util/base/compiler/interpreter.test.ts Interpreter tests for links, dates, and list statistics.
quartz/util/base/compiler/index.ts Public exports for compiler/runtime utilities.
quartz/util/base/compiler/expressions.ts Types for compiled expressions stored on vfile data.
quartz/util/base/compiler/errors.ts Lexer/parser diagnostic type.
quartz/util/base/compiler/diagnostics.ts Runtime diagnostic type for Bases rendering.
quartz/util/base/compiler/ast.ts AST node definitions + span helpers.
quartz/util/base/README.md Documentation for Bases compiler/runtime and usage notes.
quartz/processors/parse.ts Skip Markdown parsing for .base files; still run plugin pipeline.
quartz/plugins/transformers/ofm.ts Adjust wikilink handling for .base targets and view routing.
quartz/plugins/transformers/index.ts Export ObsidianBases transformer.
quartz/plugins/transformers/bases.ts New transformer to parse .base YAML and compile expressions.
quartz/plugins/emitters/index.ts Export BasePage emitter.
quartz/plugins/emitters/contentPage.tsx Skip emitting standard content pages for .base files.
quartz/plugins/emitters/basePage.tsx New emitter to generate pages per base view.
quartz/plugins/emitters/assets.ts Avoid copying .base files as static assets.
quartz/components/styles/baseViewSelector.scss Styles for base view selector dropdown.
quartz/components/styles/basePage.scss Styles for Bases views and diagnostics panel.
quartz/components/scripts/base-view-selector.inline.ts Client script for base view selector interactions.
quartz/components/pages/BaseContent.tsx Page body component for rendering Bases view content.
quartz/components/index.ts Export BaseContent and BaseViewSelector components.
quartz/components/Breadcrumbs.tsx Breadcrumb tweaks for base view pages.
quartz/components/BaseViewSelector.tsx New component for switching between base views.
quartz/build.ts Include .base files in the set of parsed input files.
quartz.config.ts Enable ObsidianBases() transformer and BasePage() emitter by default.
docs/plugins/ObsidianBases.md Documentation page for the transformer plugin.
docs/plugins/BasePage.md Documentation page for the emitter plugin.
docs/navigation.base Example base file used for docs navigation views.
docs/features/bases.md End-user docs describing Bases support and linking semantics.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +294 to +296
const url = isBaseFile
? basePath + (anchor ? `/${anchor.slice(1).replace(/\s+/g, "-")}` : "")
: fp + anchor
Copy link

Copilot AI Jan 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For .base wikilinks, the generated URL segment is derived from anchor.slice(1).replace(/\s+/g, "-"). However, Quartz normalizes anchors via splitAnchor (github-slugger) which lowercases and strips punctuation, and base view slugs are generated via slugifyFilePath (which preserves casing and applies different replacements like & -> -and-). This mismatch will cause broken links for view names with capitalization and/or special characters (e.g. [[my.base#Plugins]] becomes /plugins but the emitted page slug is /Plugins). Use the same slugging logic for view names as renderBaseViewsForFile (or adjust view slug generation to match the anchor normalization), rather than a whitespace-only replacement.

Suggested change
const url = isBaseFile
? basePath + (anchor ? `/${anchor.slice(1).replace(/\s+/g, "-")}` : "")
: fp + anchor
let url: string
if (isBaseFile) {
if (anchor) {
// Use the same anchor normalization as elsewhere (via splitAnchor / github-slugger)
const [, normalizedAnchor] = splitAnchor(basePath + anchor)
url = normalizedAnchor ? `${basePath}/${normalizedAnchor}` : basePath
} else {
url = basePath
}
} else {
url = fp + anchor
}

Copilot uses AI. Check for mistakes.
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
Copy link

Copilot AI Jan 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The README references quartz/util/base/inspect-base.ts, but the script added in this PR is quartz/util/base/inspec-base.ts. This makes the documented command fail. Either rename the script to inspect-base.ts or update the README command to match the actual filename.

Suggested change
npx tsx quartz/util/base/inspect-base.ts docs/navigation.base > /tmp/ast-ir.json
npx tsx quartz/util/base/inspec-base.ts docs/navigation.base > /tmp/ast-ir.json

Copilot uses AI. Check for mistakes.
Comment on lines +32 to +39
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`.

Copy link

Copilot AI Jan 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This example claims [[my-base.base#Task List]] resolves to my-base/Task-List, but the current .base wikilink rewrite logic in ObsidianFlavoredMarkdown lowercases/normalizes anchors (via splitAnchor) and then builds /task-list. Either update the docs to reflect the actual URL format, or adjust the implementation so the generated route matches the documented Task-List casing.

Copilot uses AI. Check for mistakes.
Comment on lines +294 to +296
const url = isBaseFile
? basePath + (anchor ? `/${anchor.slice(1).replace(/\s+/g, "-")}` : "")
: fp + anchor
Copy link

Copilot AI Jan 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

.base wikilinks are rewritten into a direct path like ${basePath}/${...} based on the raw fp text inside the wikilink. This bypasses Quartz's markdownLinkResolution: "shortest" logic in transformLink (which can resolve [[navigation]] to docs/navigation), because once you append a view segment (navigation/<view>), the resolver no longer matches by filename. Concretely, the docs include docs/navigation.base but link to it as [[navigation.base#Plugins]], which will currently produce a URL under /navigation/... instead of /docs/navigation/.... Consider keeping the .base target as the link destination through link resolution (so it can be disambiguated), then rewriting to the view route after resolution.

Suggested change
const url = isBaseFile
? basePath + (anchor ? `/${anchor.slice(1).replace(/\s+/g, "-")}` : "")
: fp + anchor
const url = fp + anchor

Copilot uses AI. Check for mistakes.
Comment on lines +29 to +36
const [, target, anchor, alias] = match
return {
raw: trimmed,
target: target?.trim() ?? "",
anchor: anchor?.trim(),
alias: alias?.trim(),
embed: trimmed.startsWith("!"),
}
Copy link

Copilot AI Jan 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

parseWikilink captures the anchor text without a leading #, but downstream consumers (e.g. renderInternalLinkNode in base rendering) concatenate hrefBase + anchor and expect the anchor to already include #.... As a result, generated links will be missing the # separator (e.g. noteheading instead of note#heading). Consider normalizing anchor to include the leading # (and ideally applying the same slugging as splitAnchor in quartz/util/path.ts) when parsing wikilinks.

Copilot uses AI. Check for mistakes.
v === null ||
v === "" ||
(Array.isArray(v) && v.length === 0) ||
(typeof v === "object" && v !== null && !Array.isArray(v) && Object.keys(v).length === 0),
Copy link

Copilot AI Jan 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Variable 'v' is of type date, object or regular expression, but it is compared to an expression of type null.

Copilot uses AI. Check for mistakes.
@aarnphm
Copy link
Collaborator Author

aarnphm commented Jan 31, 2026

I realized that a lot of the wikilink resolution were based on the internal micromark-parser i wrote for wikilink. Maybe it is time to bring it to community fork haha

@aarnphm
Copy link
Collaborator Author

aarnphm commented Feb 11, 2026

Somehow missed this

  • Does this PR include all standard supported Bases views?
    • The Map view is technically a community plugin (although first-party). According to Kepano this is to not unnecessarily increase the bundle size and especially startup time for non-Bases users.

Yes

  • Do we support embedding Bases using ![[]] syntax?

I haven't ported the embedded baseview yet, bc this touches renderPage quite extensively

  • Do the views DOM match the views DOM in Obsidian?
    • This is mostly relevant for Quartz Themes. No biggie if it doesn't match, as I have to update the extraction mappings either way.

I don't think so. But maybe there is a case to be made there 🤔

  • How easily extensible is this? E.g. if any user wants to add an additional view or when Obsidian add more default views?

Hmm i haven't thought about this extensively yet. But it should just be rendering the hastView/hastTree here

@aarnphm
Copy link
Collaborator Author

aarnphm commented Feb 11, 2026

Maybe the base/render.ts needs some rethinking here

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: bases

2 participants