Files
cours/.obsidian/plugins/qmd-as-md-obsidian/main.js
T

1909 lines
80 KiB
JavaScript

'use strict';
var obsidian = require('obsidian');
var child_process = require('child_process');
var path = require('path');
var electron = require('electron');
var state = require('@codemirror/state');
var view = require('@codemirror/view');
var commands = require('@codemirror/commands');
function _interopNamespaceDefault(e) {
var n = Object.create(null);
if (e) {
Object.keys(e).forEach(function (k) {
if (k !== 'default') {
var d = Object.getOwnPropertyDescriptor(e, k);
Object.defineProperty(n, k, d.get ? d : {
enumerable: true,
get: function () { return e[k]; }
});
}
});
}
n.default = e;
return Object.freeze(n);
}
var path__namespace = /*#__PURE__*/_interopNamespaceDefault(path);
// --- Quarto outline -------------------------------------------------------
//
// Obsidian's core Outline panel reads headings from metadataCache, which
// only parses .md files — a .qmd opened via registerExtensions still gets
// no heading cache, so the panel stays blank (issue #3). parseQmdHeadings
// scans the file text directly: ATX headings (`# ...`, up to 3 spaces of
// indent per CommonMark) only — setext headings (underlined with === / ---)
// are intentionally not supported, they are vanishingly rare in Quarto and
// the --- form collides with YAML/frontmatter syntax. The scan skips the
// YAML frontmatter block and fenced code blocks (``` / ~~~) so a `#` line
// inside an R/Python cell is not mistaken for a heading.
const QMD_OUTLINE_VIEW = 'qmd-outline-view';
// Nest a flat heading list into a tree so the outline can fold sub-trees,
// matching Obsidian's core Outline panel. A heading owns every later heading
// of a deeper level until one at its own level or shallower appears. Levels
// may skip (h1 -> h3); the level-stack handles that without synthetic nodes.
function buildHeadingTree(headings) {
const roots = [];
const stack = [];
for (const h of headings) {
const node = { ...h, children: [] };
while (stack.length && stack[stack.length - 1].level >= h.level)
stack.pop();
(stack.length ? stack[stack.length - 1].children : roots).push(node);
stack.push(node);
}
return roots;
}
function parseQmdHeadings(content) {
const lines = content.split(/\r?\n/);
const headings = [];
let inFrontmatter = false;
// Open code-fence state. Per CommonMark, a fence closes only on the same
// marker char with a run at least as long as the opener — so a longer
// ```` inside a ``` block, or a ~~~ inside a ``` block, does not close it.
let fenceMarker = null; // '`' or '~' while inside a code block
let fenceLength = 0; // length of the run that opened the current block
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
// YAML frontmatter is only frontmatter when --- is the very first line.
if (i === 0 && /^---\s*$/.test(line)) {
inFrontmatter = true;
continue;
}
if (inFrontmatter) {
if (/^(---|\.\.\.)\s*$/.test(line))
inFrontmatter = false;
continue;
}
// Fenced code block: a run of >=3 backticks or tildes, up to 3 spaces
// of indent.
const fence = line.match(/^\s{0,3}(`{3,}|~{3,})/);
if (fence) {
const run = fence[1];
const marker = run[0];
if (fenceMarker === null) {
fenceMarker = marker;
fenceLength = run.length;
}
else if (marker === fenceMarker && run.length >= fenceLength) {
fenceMarker = null;
fenceLength = 0;
}
continue;
}
if (fenceMarker !== null)
continue;
const h = line.match(/^\s{0,3}(#{1,6})\s+(.+?)\s*$/);
if (h) {
// Drop a trailing pandoc/quarto attribute block: `## Title {#id .cls}`.
const text = h[2].replace(/\s*\{[^}]*\}\s*$/, '').trim();
if (text)
headings.push({ level: h[1].length, text, line: i });
}
}
return headings;
}
class QmdOutlineView extends obsidian.ItemView {
constructor(leaf, plugin) {
super(leaf);
// Headings whose children are folded away. Keyed by a text-path (the chain
// of ancestor heading texts) so a fold survives the re-render that an edit
// elsewhere in the file triggers — a line-based key would drift. Lives for
// the view's lifetime; cleared only when the view is closed.
this.collapsed = new Set();
this.plugin = plugin;
}
getViewType() {
return QMD_OUTLINE_VIEW;
}
getDisplayText() {
return 'Quarto outline';
}
getIcon() {
return 'list';
}
async onOpen() {
// Enforce singleton: detach any other outline leaves so only this one
// remains. Covers manual splits, popouts, or duplicate spawns.
for (const leaf of this.app.workspace.getLeavesOfType(QMD_OUTLINE_VIEW)) {
if (leaf !== this.leaf)
leaf.detach();
}
// The outline may already be the active leaf at this point (opened via
// command/setting), so capture the underlying .qmd before rendering.
this.plugin.trackActiveQuartoFile();
this.render();
}
// Find the open markdown view for a file, regardless of which leaf is
// active. .qmd files open as 'markdown' leaves (registerExtensions).
markdownViewFor(file) {
for (const leaf of this.app.workspace.getLeavesOfType('markdown')) {
if (leaf.view instanceof obsidian.MarkdownView && leaf.view.file?.path === file.path) {
return leaf.view;
}
}
return null;
}
render() {
const container = this.contentEl;
container.empty();
container.addClass('qmd-outline');
const file = this.plugin.lastActiveQuartoFile;
if (!file) {
container.createDiv({
cls: 'qmd-outline-empty',
text: this.plugin.settings.outlineMarkdownFiles
? 'No Quarto (.qmd) or Markdown (.md) file is active.'
: 'No Quarto (.qmd) file is active.',
});
return;
}
// Read live content from the open editor rather than the active leaf —
// clicking inside this sidebar makes it the active leaf.
const mdView = this.markdownViewFor(file);
if (!mdView) {
container.createDiv({
cls: 'qmd-outline-empty',
text: `Open ${file.name} to see its outline.`,
});
return;
}
const headings = parseQmdHeadings(mdView.editor.getValue());
if (headings.length === 0) {
container.createDiv({
cls: 'qmd-outline-empty',
text: 'No headings in this file.',
});
return;
}
const list = container.createDiv({ cls: 'qmd-outline-list' });
for (const node of buildHeadingTree(headings)) {
this.renderNode(list, node, file, '');
}
}
// Render one heading row plus, recursively, its sub-tree. A heading with
// children gets a chevron that folds them away; the fold state is kept in
// this.collapsed so it persists across re-renders.
renderNode(parentEl, node, file, parentPath) {
// Path = ancestor heading texts chained. A newline cannot occur in a
// heading's text, so it is a collision-free separator between levels.
const path = `${parentPath}\n${node.level}:${node.text}`;
const hasChildren = node.children.length > 0;
let isCollapsed = hasChildren && this.collapsed.has(path);
const item = parentEl.createDiv({
cls: 'qmd-outline-item',
// Keyboard-accessible: focusable, announced as a link, and the keydown
// handler below makes Enter/Space jump and Left/Right fold.
attr: { tabindex: '0', role: 'link' },
});
// Indentation is driven by CSS off this attribute — no inline styles.
item.dataset.level = String(node.level);
// Toggle slot is always present (even leaf headings) so heading text
// lines up regardless of whether a chevron is shown.
const toggle = item.createSpan({ cls: 'qmd-outline-toggle' });
item.createSpan({ cls: 'qmd-outline-item-text', text: node.text });
let childrenEl = null;
if (hasChildren) {
obsidian.setIcon(toggle, 'chevron-down');
toggle.addClass('is-clickable');
childrenEl = parentEl.createDiv({ cls: 'qmd-outline-children' });
for (const child of node.children) {
this.renderNode(childrenEl, child, file, path);
}
}
const applyFold = () => {
item.toggleClass('is-collapsed', isCollapsed);
childrenEl?.toggleClass('is-collapsed', isCollapsed);
};
const setFold = (collapse) => {
isCollapsed = collapse;
if (collapse)
this.collapsed.add(path);
else
this.collapsed.delete(path);
applyFold();
};
applyFold(); // reflect any persisted fold state on first paint
const jumpTo = () => {
// Resolve the editor by file, not by "active leaf" — the click itself
// just moved focus to this sidebar.
const view = this.markdownViewFor(file);
if (!view)
return;
const pos = { line: node.line, ch: 0 };
this.app.workspace.setActiveLeaf(view.leaf, { focus: true });
view.editor.setCursor(pos);
view.editor.scrollIntoView({ from: pos, to: pos }, true);
view.editor.focus();
};
item.addEventListener('click', jumpTo);
item.addEventListener('keydown', (evt) => {
if (evt.key === 'Enter' || evt.key === ' ') {
evt.preventDefault();
jumpTo();
}
else if (hasChildren && evt.key === 'ArrowRight' && isCollapsed) {
evt.preventDefault();
setFold(false);
}
else if (hasChildren && evt.key === 'ArrowLeft' && !isCollapsed) {
evt.preventDefault();
setFold(true);
}
});
if (hasChildren) {
toggle.addEventListener('click', (evt) => {
// Fold instead of jumping; the click would otherwise bubble to the
// row's jump handler.
evt.stopPropagation();
setFold(!isCollapsed);
});
}
}
}
const QMD_YAML_VIEW = 'qmd-yaml-view';
const QMD_LUA_VIEW = 'qmd-lua-view';
const yamlHighlightField = state.StateField.define({
create(state) {
return buildYamlDecorations(state.doc);
},
update(decorations, transaction) {
if (transaction.docChanged) {
return buildYamlDecorations(transaction.state.doc);
}
return decorations.map(transaction.changes);
},
provide: (field) => view.EditorView.decorations.from(field),
});
function buildYamlDecorations(doc) {
const builder = new state.RangeSetBuilder();
for (let lineNumber = 1; lineNumber <= doc.lines; lineNumber++) {
const line = doc.line(lineNumber);
decorateYamlLine(builder, line.from, line.text);
}
return builder.finish();
}
function decorateYamlLine(builder, lineStart, line) {
const indent = line.match(/^\s*/)?.[0] ?? '';
const content = line.slice(indent.length);
const contentStart = lineStart + indent.length;
if (!content)
return;
if (content.startsWith('#')) {
markYamlToken(builder, contentStart, lineStart + line.length, 'qmd-yaml-comment');
return;
}
const markerMatch = content.match(/^(-\s+)(.*)$/);
if (markerMatch) {
const markerLength = markerMatch[1].length;
markYamlToken(builder, contentStart, contentStart + markerLength, 'qmd-yaml-list-marker');
decorateYamlSegment(builder, contentStart + markerLength, markerMatch[2]);
return;
}
decorateYamlSegment(builder, contentStart, content);
}
function decorateYamlSegment(builder, segmentStart, segment) {
const docMatch = segment.match(/^(\.{3}|-{3})(\s*(#.*)?)$/);
if (docMatch) {
const markerLength = docMatch[1].length;
markYamlToken(builder, segmentStart, segmentStart + markerLength, 'qmd-yaml-doc-marker');
decorateYamlValue(builder, segmentStart + markerLength, docMatch[2] ?? '');
return;
}
const colon = findYamlKeyColon(segment);
if (colon !== -1) {
markYamlToken(builder, segmentStart, segmentStart + colon, 'qmd-yaml-key');
markYamlToken(builder, segmentStart + colon, segmentStart + colon + 1, 'qmd-yaml-colon');
decorateYamlValue(builder, segmentStart + colon + 1, segment.slice(colon + 1));
return;
}
decorateYamlValue(builder, segmentStart, segment);
}
function decorateYamlValue(builder, valueStart, value) {
const leading = value.match(/^\s*/)?.[0] ?? '';
const rest = value.slice(leading.length);
const restStart = valueStart + leading.length;
if (!rest)
return;
const commentIndex = findYamlComment(rest);
const scalar = commentIndex === -1 ? rest : rest.slice(0, commentIndex);
decorateYamlScalar(builder, restStart, scalar);
if (commentIndex !== -1) {
markYamlToken(builder, restStart + commentIndex, restStart + rest.length, 'qmd-yaml-comment');
}
}
function decorateYamlScalar(builder, scalarStart, scalar) {
const trailing = scalar.match(/\s*$/)?.[0] ?? '';
const token = trailing ? scalar.slice(0, scalar.length - trailing.length) : scalar;
if (!token)
return;
const className = yamlScalarClass(token);
markYamlToken(builder, scalarStart, scalarStart + token.length, className);
}
function yamlScalarClass(token) {
if (/^['"].*['"]$/.test(token))
return 'qmd-yaml-string';
if (/^[&*][A-Za-z0-9_-]+$/.test(token))
return 'qmd-yaml-anchor';
// YAML 1.2 (what Quarto/Pandoc use): only true/false/null/~ are
// booleans/null. yes/no/on/off are plain scalars, not booleans.
if (/^(true|false|null|~)$/i.test(token))
return 'qmd-yaml-boolean';
if (/^[-+]?(?:\d+\.?\d*|\.\d+)(?:e[-+]?\d+)?$/i.test(token))
return 'qmd-yaml-number';
if (/^[>|][+-]?$/.test(token))
return 'qmd-yaml-block';
if (/^(html|pdf|typst|latex|beamer|revealjs|docx|odt|epub|gfm|jats|dashboard)$/i.test(token)) {
return 'qmd-yaml-quarto-format';
}
return 'qmd-yaml-scalar';
}
function findYamlKeyColon(segment) {
let singleQuoted = false;
let doubleQuoted = false;
for (let i = 0; i < segment.length; i++) {
const char = segment[i];
const prev = i > 0 ? segment[i - 1] : '';
if (char === "'" && !doubleQuoted) {
singleQuoted = !singleQuoted;
}
else if (char === '"' && !singleQuoted && prev !== '\\') {
doubleQuoted = !doubleQuoted;
}
else if (char === ':' && !singleQuoted && !doubleQuoted) {
const next = segment[i + 1] ?? '';
const key = segment.slice(0, i).trim();
if (key && (!next || /\s/.test(next)))
return i;
}
}
return -1;
}
function findYamlComment(value) {
let singleQuoted = false;
let doubleQuoted = false;
for (let i = 0; i < value.length; i++) {
const char = value[i];
const prev = i > 0 ? value[i - 1] : '';
if (char === "'" && !doubleQuoted) {
singleQuoted = !singleQuoted;
}
else if (char === '"' && !singleQuoted && prev !== '\\') {
doubleQuoted = !doubleQuoted;
}
else if (char === '#' && !singleQuoted && !doubleQuoted && (i === 0 || /\s/.test(prev))) {
return i;
}
}
return -1;
}
function markYamlToken(builder, from, to, className) {
if (to <= from)
return;
builder.add(from, to, view.Decoration.mark({ class: className }));
}
// --- Lua highlighting -----------------------------------------------------
//
// Minimal Lua syntax highlighting for the Lua file view — enough to make
// pandoc/Quarto filter scripts readable. A single forward scan over the
// whole document text marks comments, strings, numbers and keywords;
// everything else is left unstyled. The forward scan guarantees the
// RangeSetBuilder receives ranges in ascending, non-overlapping order.
const LUA_KEYWORDS = new Set([
'and', 'break', 'do', 'else', 'elseif', 'end', 'false', 'for', 'function',
'goto', 'if', 'in', 'local', 'nil', 'not', 'or', 'repeat', 'return', 'then',
'true', 'until', 'while',
]);
const luaHighlightField = state.StateField.define({
create(state) {
return buildLuaDecorations(state.doc);
},
update(decorations, transaction) {
if (transaction.docChanged) {
return buildLuaDecorations(transaction.state.doc);
}
return decorations.map(transaction.changes);
},
provide: (field) => view.EditorView.decorations.from(field),
});
function buildLuaDecorations(doc) {
const builder = new state.RangeSetBuilder();
const s = doc.toString();
const len = s.length;
const mark = (from, to, className) => {
if (to > from)
builder.add(from, to, view.Decoration.mark({ class: className }));
};
// If a Lua long bracket opens at `open` (`[`, `[=[`, `[==[`, …), return the
// index just past its matching close; an unterminated bracket runs to EOF.
// Returns -1 when `open` is not a long-bracket opener.
const longBracketEnd = (open) => {
if (s[open] !== '[')
return -1;
let j = open + 1;
let level = 0;
while (s[j] === '=') {
level++;
j++;
}
if (s[j] !== '[')
return -1;
const close = ']' + '='.repeat(level) + ']';
const idx = s.indexOf(close, j + 1);
return idx === -1 ? len : idx + close.length;
};
let i = 0;
while (i < len) {
const c = s[i];
// comment: line (`-- …`) or block (`--[[ … ]]`, `--[==[ … ]==]`)
if (c === '-' && s[i + 1] === '-') {
const block = longBracketEnd(i + 2);
if (block !== -1) {
mark(i, block, 'qmd-lua-comment');
i = block;
continue;
}
let j = i + 2;
while (j < len && s[j] !== '\n')
j++;
mark(i, j, 'qmd-lua-comment');
i = j;
continue;
}
// long-bracket string
if (c === '[') {
const long = longBracketEnd(i);
if (long !== -1) {
mark(i, long, 'qmd-lua-string');
i = long;
continue;
}
}
// quoted string (single or double); does not span lines
if (c === '"' || c === "'") {
let j = i + 1;
while (j < len && s[j] !== c && s[j] !== '\n') {
if (s[j] === '\\')
j++;
j++;
}
if (j < len && s[j] === c)
j++;
mark(i, j, 'qmd-lua-string');
i = j;
continue;
}
// number (decimal, hex, fractional, exponent)
if (/[0-9]/.test(c) || (c === '.' && /[0-9]/.test(s[i + 1] ?? ''))) {
let j = i;
if (c === '0' && (s[i + 1] === 'x' || s[i + 1] === 'X')) {
j = i + 2;
while (j < len && /[0-9a-fA-F.]/.test(s[j]))
j++;
}
else {
while (j < len && /[0-9.]/.test(s[j]))
j++;
if (s[j] === 'e' || s[j] === 'E') {
j++;
if (s[j] === '+' || s[j] === '-')
j++;
while (j < len && /[0-9]/.test(s[j]))
j++;
}
}
mark(i, j, 'qmd-lua-number');
i = j;
continue;
}
// identifier — only keywords get marked
if (/[A-Za-z_]/.test(c)) {
let j = i + 1;
while (j < len && /[A-Za-z0-9_]/.test(s[j]))
j++;
if (LUA_KEYWORDS.has(s.slice(i, j)))
mark(i, j, 'qmd-lua-keyword');
i = j;
continue;
}
i++;
}
return builder.finish();
}
const YAML_CODE_VIEW = {
label: 'YAML file',
ariaLabel: 'YAML file contents',
highlightField: yamlHighlightField,
};
const LUA_CODE_VIEW = {
label: 'Lua file',
ariaLabel: 'Lua file contents',
highlightField: luaHighlightField,
};
// A minimal CodeMirror-backed file view, shared by the YAML and Lua file
// views. The only per-language difference is the highlight StateField and
// the labels, supplied through CodeViewConfig.
//
// getViewType() is left abstract on purpose: Obsidian's View constructor
// calls getViewType() *during* super(), before subclass constructor params
// and field initializers have run, so it cannot read instance state. Each
// concrete subclass returns a module-level literal instead.
class QmdCodeFileView extends obsidian.TextFileView {
constructor(leaf, config) {
super(leaf);
this.config = config;
this.editorView = null;
this.settingViewData = false;
}
getDisplayText() {
return this.file?.name ?? this.config.label;
}
getIcon() {
return 'file-code';
}
onload() {
super.onload();
this.contentEl.empty();
this.contentEl.addClass('qmd-code-view');
this.editorView = new view.EditorView({
parent: this.contentEl,
state: state.EditorState.create({
doc: this.data ?? '',
extensions: [
state.EditorState.tabSize.of(2),
this.config.highlightField,
view.EditorView.contentAttributes.of({
'aria-label': this.config.ariaLabel,
autocapitalize: 'off',
autocomplete: 'off',
spellcheck: 'false',
}),
view.keymap.of([
{ key: 'Tab', run: commands.indentMore },
{ key: 'Shift-Tab', run: commands.indentLess },
]),
view.EditorView.updateListener.of((update) => {
if (!update.docChanged || this.settingViewData)
return;
this.data = update.state.doc.toString();
this.requestSave();
}),
],
}),
});
this.register(() => {
this.editorView?.destroy();
this.editorView = null;
});
}
getViewData() {
return this.editorView?.state.doc.toString() ?? this.data ?? '';
}
setViewData(data) {
this.data = data;
const view = this.editorView;
if (!view)
return;
const current = view.state.doc.toString();
if (current === data)
return;
this.settingViewData = true;
try {
view.dispatch({
changes: {
from: 0,
to: current.length,
insert: data,
},
});
}
finally {
this.settingViewData = false;
}
}
clear() {
this.setViewData('');
}
}
class QmdYamlFileView extends QmdCodeFileView {
constructor(leaf) {
super(leaf, YAML_CODE_VIEW);
}
getViewType() {
return QMD_YAML_VIEW;
}
}
class QmdLuaFileView extends QmdCodeFileView {
constructor(leaf) {
super(leaf, LUA_CODE_VIEW);
}
getViewType() {
return QMD_LUA_VIEW;
}
}
// Shared Typst styling injected into both built-in Typst presets via
// `include-in-header`. Kept as one constant so the two presets cannot drift
// out of sync. Indentation matches the YAML position where it is interpolated
// (10 spaces, sitting inside ` include-in-header:` → ` - text: |`).
const TYPST_STYLING_HEADER = ` include-in-header:
- text: |
// Single accent color used throughout
#let accent = rgb("#2E5C8A")
// Page header: current top-level section + page number
#set page(header: context {
let heads = query(selector(heading.where(level: 1)).before(here()))
let t = if heads.len() > 0 { heads.last().body } else [ ]
text(size: 9pt, fill: gray, [#t #h(1fr) #counter(page).display()])
})
// Link color
#show link: set text(fill: accent)
// Boxed code blocks
#show raw.where(block: true): it => block(
fill: rgb("#f5f5f5"),
inset: 10pt,
radius: 4pt,
width: 100%,
it,
)
// Block quotes: colored left bar + muted italic
#show quote.where(block: true): it => block(
stroke: (left: 3pt + accent),
inset: (left: 12pt, top: 4pt, bottom: 4pt),
text(style: "italic", fill: gray.darken(20%), it.body),
)
// H1 headings: subtle accent rule
#show heading.where(level: 1): it => [
#it
#v(-0.5em)
#line(length: 100%, stroke: 0.5pt + accent.lighten(40%))
]`;
// Built-in presets. v0: hard-coded; later versions will let users edit/add
// these in plugin settings (same shape, persisted in QmdPluginSettings).
const DEFAULT_PRESETS = [
{
id: 'empty',
name: 'Empty',
description: 'Minimal front-matter, no format specified.',
source: 'built-in',
body: `---
title: ""
---
# Untitled
`,
},
{
id: 'docx',
name: 'Word (.docx)',
description: 'format: docx with table of contents and numbered sections.',
source: 'built-in',
body: `---
title: ""
author: ""
date: today
format:
docx:
toc: true
number-sections: true
# reference-doc: reference.docx
---
# Untitled
`,
},
{
id: 'typst-notes',
name: 'Typst PDF — Notes (Eisvogel-style)',
description: 'Eisvogel-inspired Typst PDF: TOC, numbered sections, page header, boxed code.',
source: 'built-in',
body: `---
title: ""
author: ""
date: today
toc: false
toc-depth: 2
number-sections: true
format:
typst:
papersize: a4
margin:
x: 2cm
y: 2.5cm
fontsize: 11pt
section-numbering: "1.1.1"
# mainfont: "Libertinus Serif"
# sansfont: "Libertinus Sans"
# monofont: "JetBrains Mono"
${TYPST_STYLING_HEADER}
---
# Untitled
`,
},
{
id: 'typst-report',
name: 'Typst PDF — Report (Eisvogel-style)',
description: 'Eisvogel-inspired Typst report: cover metadata, TOC, numbered sections, boxed code, bibliography hints.',
source: 'built-in',
body: `---
title: ""
subtitle: ""
author: ""
date: today
# abstract: |
# Short summary of the report.
# keywords: [keyword1, keyword2]
# bibliography: references.bib
# csl: ieee.csl
toc: true
toc-depth: 3
number-sections: true
format:
typst:
papersize: a4
margin:
x: 2.5cm
y: 2.5cm
fontsize: 11pt
section-numbering: "1.1.1"
# mainfont: "Libertinus Serif"
# sansfont: "Libertinus Sans"
# monofont: "JetBrains Mono"
${TYPST_STYLING_HEADER}
---
# Untitled
`,
},
];
class PresetSuggestModal extends obsidian.SuggestModal {
constructor(app, presets, onChoose) {
super(app);
this.presets = presets;
this.onChoose = onChoose;
this.setPlaceholder('Pick a Quarto file preset…');
}
getSuggestions(query) {
const q = query.toLowerCase();
if (!q)
return this.presets;
return this.presets.filter((p) => p.name.toLowerCase().includes(q) ||
p.description.toLowerCase().includes(q));
}
renderSuggestion(preset, el) {
const title = preset.source === 'built-in'
? `${preset.name} (built-in)`
: preset.name;
el.createEl('div', { text: title, cls: 'qmd-preset-name' });
el.createEl('small', { text: preset.description, cls: 'qmd-preset-desc' });
}
onChooseSuggestion(preset) {
this.onChoose(preset);
}
}
class FilenameModal extends obsidian.Modal {
constructor(app, preset, folderPath, onSubmit) {
super(app);
this.preset = preset;
this.folderPath = folderPath;
this.onSubmit = onSubmit;
this.value = 'untitled';
}
onOpen() {
const { contentEl } = this;
contentEl.empty();
contentEl.createEl('h3', { text: `New Quarto file: ${this.preset.name}` });
contentEl.createEl('p', {
text: `Folder: ${this.folderPath || '(vault root)'}`,
cls: 'setting-item-description',
});
new obsidian.Setting(contentEl)
.setName('Filename')
.setDesc('".qmd" is added automatically if omitted.')
.addText((text) => {
text
.setPlaceholder('untitled')
.setValue(this.value)
.onChange((v) => (this.value = v));
text.inputEl.focus();
text.inputEl.select();
text.inputEl.addEventListener('keydown', (e) => {
// Skip during IME composition (Japanese/Chinese/Korean input),
// and let modifier combos pass through to the OS / Obsidian.
if (e.key === 'Enter' &&
!e.isComposing &&
!e.shiftKey &&
!e.metaKey &&
!e.ctrlKey &&
!e.altKey) {
e.preventDefault();
this.submit();
}
});
});
new obsidian.Setting(contentEl).addButton((btn) => btn
.setButtonText('Create')
.setCta()
.onClick(() => this.submit()));
}
submit() {
const raw = this.value.trim();
if (!raw) {
new obsidian.Notice('Filename is required.');
return;
}
this.close();
this.onSubmit(raw);
}
onClose() {
this.contentEl.empty();
}
}
// Sanitize a user-typed base filename so vault.create can't choke and the
// result still works after syncing to Windows / macOS / Linux:
// - strip a trailing ".qmd" (case-insensitive); caller re-adds it
// - replace path separators with "-"
// - drop characters Windows forbids in filenames: : * ? " < > |
// - trim leading/trailing whitespace and dots (Windows strips them anyway,
// and a leading "." would make the file hidden on POSIX)
function sanitizeBaseName(input) {
const withoutExt = input.replace(/\.qmd$/i, '');
const noSlashes = withoutExt.replace(/[\\/]/g, '-');
const noReserved = noSlashes.replace(/[:*?"<>|]/g, '');
return noReserved.replace(/^[\s.]+|[\s.]+$/g, '');
}
// Pick a target path next to the active file, falling back to vault root.
// Existing files are not overwritten — appends "-1", "-2", … until free.
async function buildTargetPath(app, folderPath, base) {
const safe = sanitizeBaseName(base) || 'untitled';
let candidate = obsidian.normalizePath(folderPath ? `${folderPath}/${safe}.qmd` : `${safe}.qmd`);
let n = 1;
while (app.vault.getAbstractFileByPath(candidate)) {
candidate = obsidian.normalizePath(folderPath ? `${folderPath}/${safe}-${n}.qmd` : `${safe}-${n}.qmd`);
n += 1;
}
return candidate;
}
function activeFolderPath(app) {
const active = app.workspace.getActiveFile();
if (active?.parent)
return active.parent.path === '/' ? '' : active.parent.path;
return '';
}
// Read every top-level .qmd inside `folderPath` and turn each into a user
// preset. Subfolders are skipped — keeps the picker flat and predictable.
// Missing folder or read errors degrade silently to "no user presets"; the
// built-ins still show up so the command never appears broken.
async function loadUserPresets(app, folderPath) {
const trimmed = folderPath.trim();
if (!trimmed)
return [];
const folder = app.vault.getAbstractFileByPath(obsidian.normalizePath(trimmed));
if (!(folder instanceof obsidian.TFolder))
return [];
const out = [];
for (const child of folder.children) {
if (!(child instanceof obsidian.TFile))
continue;
if (child.extension.toLowerCase() !== 'qmd')
continue;
try {
const body = await app.vault.cachedRead(child);
out.push({
id: `user:${child.path}`,
name: child.basename,
description: `From ${child.path}`,
source: 'user',
body,
});
}
catch (err) {
console.warn(`[qmd-as-md] Skipped template ${child.path}:`, err);
}
}
out.sort((a, b) => a.name.localeCompare(b.name));
return out;
}
async function newQmdFromPreset(app, templatesFolder) {
const userPresets = await loadUserPresets(app, templatesFolder);
// User presets first — they're the ones the user actively curated; built-ins
// fall to the bottom as fallback options.
const presets = [...userPresets, ...DEFAULT_PRESETS];
const modal = new PresetSuggestModal(app, presets, (preset) => {
const folder = activeFolderPath(app);
new FilenameModal(app, preset, folder, async (filename) => {
try {
const target = await buildTargetPath(app, folder, filename);
const parent = target.includes('/')
? target.slice(0, target.lastIndexOf('/'))
: '';
if (parent) {
const existing = app.vault.getAbstractFileByPath(parent);
if (existing instanceof obsidian.TFile) {
// A file already occupies the would-be parent folder path —
// createFolder would surface a confusing low-level error; bail
// with a precise message instead.
new obsidian.Notice(`Cannot create folder "${parent}": a file with that name already exists.`);
return;
}
if (!(existing instanceof obsidian.TFolder)) {
await app.vault.createFolder(parent);
}
}
const file = await app.vault.create(target, preset.body);
if (file instanceof obsidian.TFile) {
await app.workspace.getLeaf(false).openFile(file);
}
new obsidian.Notice(`Created ${target}`);
}
catch (err) {
const msg = err instanceof Error ? err.message : String(err);
new obsidian.Notice(`Failed to create file: ${msg}`);
}
}).open();
});
modal.open();
}
// --- Quarto output plumbing -----------------------------------------------
//
// Node's spawn-stream chunks don't align with line boundaries — a single
// data event can contain a partial line, and a logical line can be split
// across two events. Build a per-stream processor that buffers the
// trailing partial line and only emits whole lines. Call .flush() on the
// close handler to release any final partial line.
//
// logQuartoLine routes a single line to console by severity prefix:
// "ERROR:" -> console.error, "WARNING:"/"WARN:" -> console.warn,
// everything else -> console.log. Centralised so both the preview and
// render paths stay in sync and new prefixes only need handling here.
function logQuartoLine(prefix, line) {
if (/^ERROR:/.test(line)) {
console.error(`${prefix}: ${line}`);
}
else if (/^WARN(ING)?:/.test(line)) {
console.warn(`${prefix}: ${line}`);
}
else {
console.log(`${prefix}: ${line}`);
}
}
function makeLineProcessor(handle) {
let buffer = '';
const proc = ((chunk) => {
buffer += chunk;
const lines = buffer.split(/\r?\n/);
// Last element is the trailing fragment after the final newline
// (or the whole chunk if there was no newline at all). Keep it
// for the next chunk.
buffer = lines.pop() ?? '';
for (const line of lines) {
if (line)
handle(line);
}
});
proc.flush = () => {
if (buffer) {
handle(buffer);
buffer = '';
}
};
return proc;
}
const ANSI_ESCAPE_RE = new RegExp(String.fromCharCode(27) + '\\[[0-?]*[ -/]*[@-~]', 'g');
function stripAnsiCodes(text) {
return text.replace(ANSI_ESCAPE_RE, '');
}
function previewUrlFromLine(line) {
const match = stripAnsiCodes(line).match(/Browse at\s+(https?:\/\/\S+)/);
return match?.[1] ?? null;
}
const DEFAULT_SETTINGS = {
quartoPath: 'quarto',
enableQmdLinking: true,
quartoTypst: '',
openPdfInObsidian: false,
previewInObsidian: true,
previewMarkdownFiles: false,
outlineMarkdownFiles: false,
showYamlFiles: false,
showLuaFiles: false,
showOutline: false,
templatesFolder: '',
};
class QmdAsMdPlugin extends obsidian.Plugin {
constructor() {
super(...arguments);
this.activePreviewProcesses = new Map();
// The .qmd file the outline should describe. Tracked separately from the
// active leaf: clicking inside the outline sidebar makes *it* the active
// leaf, so the outline must remember the last real .qmd rather than ask
// "what is active now?" each render.
this.lastActiveQuartoFile = null;
}
async onload() {
try {
await this.loadSettings();
this.registerView(QMD_OUTLINE_VIEW, (leaf) => new QmdOutlineView(leaf, this));
this.registerView(QMD_YAML_VIEW, (leaf) => new QmdYamlFileView(leaf));
this.registerView(QMD_LUA_VIEW, (leaf) => new QmdLuaFileView(leaf));
if (this.settings.enableQmdLinking) {
this.registerQmdExtension();
}
if (this.settings.showYamlFiles) {
this.registerYamlExtensions();
}
if (this.settings.showLuaFiles) {
this.registerLuaExtensions();
}
this.addSettingTab(new QmdSettingTab(this.app, this));
this.addRibbonIcon('eye', 'Toggle Quarto preview', async () => {
const file = this.getActiveQuartoCommandFile();
if (file)
await this.togglePreview(file);
});
this.addCommand({
id: 'toggle-quarto-preview',
name: 'Toggle Quarto preview',
callback: async () => {
const file = this.getActiveQuartoCommandFile();
if (file)
await this.togglePreview(file);
},
});
this.addCommand({
id: 'toggle-quarto-preview-in-obsidian',
name: 'Toggle Quarto preview in Obsidian',
callback: async () => {
const file = this.getActiveQuartoCommandFile();
if (file)
await this.togglePreview(file, 'obsidian');
},
});
this.addCommand({
id: 'toggle-quarto-preview-external',
name: 'Toggle Quarto preview in external browser',
callback: async () => {
const file = this.getActiveQuartoCommandFile();
if (file)
await this.togglePreview(file, 'external');
},
});
this.addRibbonIcon('file-output', 'Render Quarto to PDF', async () => {
const file = this.getActiveQuartoCommandFile();
if (file)
await this.renderPdf(file);
});
this.registerRenderCommand('render-quarto-pdf', 'Render Quarto (use format defined in YAML)');
this.registerRenderCommand('render-quarto-pdf-typst', 'Render Quarto to PDF (Typst engine)', 'typst');
this.registerRenderCommand('render-quarto-pdf-latex', 'Render Quarto to PDF (LaTeX engine)', 'pdf');
this.addCommand({
id: 'open-quarto-outline',
name: 'Open Quarto outline',
callback: () => this.activateOutlineView(),
});
this.addCommand({
id: 'new-quarto-file-from-preset',
name: 'New Quarto file from preset',
icon: 'file-plus-2',
callback: () => newQmdFromPreset(this.app, this.settings.templatesFolder),
});
// Keep any open outline view in sync with the focused file and its
// edits. Debounced so a burst of keystrokes re-parses once it settles.
const refresh = obsidian.debounce(() => {
this.trackActiveQuartoFile();
this.refreshOutlineViews();
}, 250, true);
this.registerEvent(this.app.workspace.on('active-leaf-change', refresh));
this.registerEvent(this.app.workspace.on('editor-change', refresh));
// Opt-in: only auto-open the outline when the user enabled it. The
// command above always works regardless of this setting.
if (this.settings.showOutline) {
this.app.workspace.onLayoutReady(() => this.activateOutlineView());
}
}
catch (error) {
console.error('Error loading plugin:', error);
new obsidian.Notice('Failed to load the QMD as md plugin. Check the developer console for details.');
}
}
onunload() {
this.stopAllPreviews();
}
async loadSettings() {
const loaded = (await this.loadData());
this.settings = Object.assign({}, DEFAULT_SETTINGS, loaded);
}
async saveSettings() {
await this.saveData(this.settings);
}
isQuartoFile(file) {
return file.extension === 'qmd';
}
isMarkdownFile(file) {
return file.extension === 'md';
}
hasQuartoProjectConfigInPath(file) {
let dir = file.parent?.path ?? '';
while (true) {
const configPath = obsidian.normalizePath(dir ? `${dir}/_quarto.yml` : '_quarto.yml');
if (this.app.vault.getAbstractFileByPath(configPath) instanceof obsidian.TFile) {
return true;
}
if (!dir)
return false;
const slash = dir.lastIndexOf('/');
dir = slash === -1 ? '' : dir.slice(0, slash);
}
}
getActiveQuartoCommandFile() {
const activeView = this.app.workspace.getActiveViewOfType(obsidian.MarkdownView);
const file = activeView?.file;
if (!file) {
new obsidian.Notice(this.settings.previewMarkdownFiles
? 'Quarto commands require an active .qmd or .md file.'
: 'Quarto commands require an active .qmd file.');
return null;
}
if (this.isQuartoFile(file)) {
return file;
}
if (this.isMarkdownFile(file)) {
if (!this.settings.previewMarkdownFiles) {
new obsidian.Notice('Markdown support is off. Enable it in settings to preview or render .md files with Quarto.');
return null;
}
if (!this.hasQuartoProjectConfigInPath(file)) {
new obsidian.Notice('Markdown Quarto commands require _quarto.yml in this file folder or an ancestor up to the vault root.');
return null;
}
return file;
}
if (this.settings.previewMarkdownFiles) {
new obsidian.Notice('Quarto commands require an active .qmd or .md file.');
}
else {
new obsidian.Notice('Quarto commands require an active .qmd file.');
}
return null;
}
getVaultFullPath(file) {
const adapter = this.app.vault.adapter;
if (adapter instanceof obsidian.FileSystemAdapter) {
return adapter.getFullPath(file.path);
}
new obsidian.Notice('Vault is not on a local filesystem; cannot run Quarto.');
return null;
}
pdfPathFor(file) {
return file.path.replace(/\.(qmd|md)$/i, '.pdf');
}
// Pull the TFile a leaf currently shows, without resorting to
// `as any`. The built-in PDF view (and most file-backed views)
// extend FileView, which exposes a typed `file: TFile | null`.
leafFile(leaf) {
return leaf.view instanceof obsidian.FileView ? leaf.view.file : null;
}
// Open (or reuse) a leaf showing the given vault-relative PDF path in
// Obsidian's native PDF viewer. Returns the leaf so callers can keep
// refreshing it on subsequent preview compiles.
//
// Leaf-resolution order:
// 1. Caller's captured ref, if still attached to the workspace.
// 2. Any open 'pdf' leaf already showing this exact file (user may
// have opened it manually, or renderPdf may have opened it).
// 3. New vertical split.
async openOrRefreshPdfPreview(vaultPath, existingLeaf) {
const pdfTFile = await this.waitForVaultFile(vaultPath);
if (!pdfTFile) {
new obsidian.Notice(`Quarto preview produced ${vaultPath} but it did not appear in the vault within the timeout.`);
return null;
}
try {
const reusable = existingLeaf?.parent != null
? existingLeaf
: this.app.workspace
.getLeavesOfType('pdf')
.find((l) => this.leafFile(l)?.path === pdfTFile.path) ?? null;
const leaf = reusable ?? this.app.workspace.getLeaf('split', 'vertical');
// Skip the openFile call when the leaf already shows this file —
// calling openFile in that case is harmless for the file display
// but still triggers a reveal/focus shuffle the user does not want.
// Obsidian's PDF viewer picks up the file rewrite via its own
// mtime watcher, so live reload still works without our help.
const currentFile = this.leafFile(leaf);
if (!currentFile || currentFile.path !== pdfTFile.path) {
await leaf.openFile(pdfTFile, { active: false });
}
await this.app.workspace.revealLeaf(leaf);
return leaf;
}
catch (err) {
console.error('[qmd-as-md] Failed to open PDF preview in native viewer:', err);
new obsidian.Notice(`Could not open ${vaultPath} in Obsidian's PDF viewer.`);
return null;
}
}
async openPreviewUrl(url, mode) {
console.log('[qmd-as-md][diag] openPreviewUrl called. url:', url, 'mode:', mode);
new obsidian.Notice(`Preview available at ${url}`);
if (mode === 'external') {
// Quarto is launched with --no-browser in every mode; this opens the
// captured URL once while leaving Quarto's live-reload client in charge
// after the page is loaded.
try {
await electron.shell.openExternal(url);
}
catch (err) {
console.error('[qmd-as-md] Failed to open external preview:', err);
new obsidian.Notice(`Could not open external browser. Preview URL: ${url}`, 10000);
}
return;
}
// The "Web viewer" core plugin (Obsidian 1.8+) registers the
// 'webviewer' view type. If the user has it disabled, setViewState
// silently fails / leaves an empty leaf, and the user is left
// wondering why nothing opened. Detect and report instead.
const internalPlugins = this.app.internalPlugins;
const webviewerOn = internalPlugins?.getEnabledPluginById?.('webviewer') != null ||
internalPlugins?.plugins?.webviewer?.enabled === true;
if (!webviewerOn) {
new obsidian.Notice('Obsidian core plugin "Web viewer" is disabled — cannot show preview in-app. ' +
'Enable it in Settings → Core plugins, or use "Toggle Quarto preview in external browser" instead. ' +
'Falling back to your external browser.', 10000);
console.warn('[qmd-as-md] webviewer core plugin disabled; preview URL was:', url);
void electron.shell.openExternal(url);
return;
}
try {
const leaf = this.app.workspace.getLeaf('tab');
await leaf.setViewState({
type: 'webviewer',
active: true,
state: { url },
});
await this.app.workspace.revealLeaf(leaf);
}
catch (err) {
console.error('[qmd-as-md] Failed to open preview in webviewer:', err);
new obsidian.Notice("Could not open preview in Obsidian's web viewer. Falling back to external browser.");
void electron.shell.openExternal(url);
}
}
registerRenderCommand(id, name, toFormat) {
this.addCommand({
id,
name,
icon: 'file-output',
callback: async () => {
const file = this.getActiveQuartoCommandFile();
if (file)
await this.renderPdf(file, toFormat);
},
});
}
registerQmdExtension() {
this.registerExtensions(['qmd'], 'markdown');
}
registerYamlExtensions() {
this.registerExtensions(['yml', 'yaml'], QMD_YAML_VIEW);
}
registerLuaExtensions() {
this.registerExtensions(['lua'], QMD_LUA_VIEW);
}
// Open the Quarto outline in the right sidebar, reusing an existing
// outline leaf if one is already open.
async activateOutlineView() {
const { workspace } = this.app;
// Capture the current .qmd before opening the outline — setViewState
// with active:true makes the outline the active leaf, after which the
// active markdown view is gone.
this.trackActiveQuartoFile();
let leaf = workspace.getLeavesOfType(QMD_OUTLINE_VIEW)[0] ?? null;
if (!leaf) {
leaf = workspace.getRightLeaf(false);
await leaf?.setViewState({ type: QMD_OUTLINE_VIEW, active: true });
}
if (leaf)
await workspace.revealLeaf(leaf);
this.refreshOutlineViews();
}
// Remember the active file the outline should describe. Called whenever the
// active leaf changes; an active leaf the outline cannot describe (a non-.qmd
// file, or a .md file when outlineMarkdownFiles is off, including the outline
// sidebar itself) leaves the last value untouched so the outline keeps
// describing that file.
trackActiveQuartoFile() {
const view = this.app.workspace.getActiveViewOfType(obsidian.MarkdownView);
if (!view?.file)
return;
if (this.isQuartoFile(view.file) ||
(this.settings.outlineMarkdownFiles && this.isMarkdownFile(view.file))) {
this.lastActiveQuartoFile = view.file;
}
}
// Re-render every open outline view. No-op when none are open.
refreshOutlineViews() {
for (const leaf of this.app.workspace.getLeavesOfType(QMD_OUTLINE_VIEW)) {
if (leaf.view instanceof QmdOutlineView) {
leaf.view.render();
}
}
}
// Close any open outline views — used when the user turns the setting off.
detachOutlineViews() {
for (const leaf of this.app.workspace.getLeavesOfType(QMD_OUTLINE_VIEW)) {
leaf.detach();
}
}
defaultPreviewMode() {
return this.settings.previewInObsidian ? 'obsidian' : 'external';
}
async togglePreview(file, mode = this.defaultPreviewMode()) {
const activePreview = this.activePreviewProcesses.get(file.path);
if (activePreview?.mode === mode) {
await this.stopPreview(file);
}
else {
if (activePreview) {
await this.stopPreview(file);
}
await this.startPreview(file, mode);
}
}
async startPreview(file, mode = this.defaultPreviewMode()) {
const activePreview = this.activePreviewProcesses.get(file.path);
if (activePreview?.mode === mode) {
return; // Preview already running for this file in this mode.
}
if (activePreview) {
await this.stopPreview(file);
}
try {
const abstractFile = this.app.vault.getAbstractFileByPath(file.path);
if (!abstractFile || !(abstractFile instanceof obsidian.TFile)) {
new obsidian.Notice(`File ${file.path} not found`);
return;
}
const filePath = this.getVaultFullPath(abstractFile);
if (!filePath)
return;
const workingDir = path__namespace.dirname(filePath);
const envVars = { ...process.env };
if (this.settings.quartoTypst.trim()) {
envVars.QUARTO_TYPST = this.settings.quartoTypst.trim();
}
// Always suppress Quarto's own browser launch. The plugin opens the
// captured URL exactly once for the selected target, which avoids
// duplicate tabs and avoids Quarto-managed browser navigation closing
// the preview process on subsequent source changes.
const args = ['preview', filePath, '--no-browser'];
// detached: `quarto preview` forks a separate long-lived server
// process. Making the spawned process a process-group leader (POSIX)
// lets killPreviewProcess signal the whole group — a plain kill() of
// the wrapper would orphan the server, leaving it serving and
// recompiling after "stop". No process groups on Windows; the kill
// there goes through taskkill instead.
const quartoProcess = child_process.spawn(this.settings.quartoPath, args, {
cwd: workingDir,
env: envVars,
detached: process.platform !== 'win32',
});
let previewUrl = null;
// PDF-preview state. Quarto emits "Output created: foo.pdf" on
// every recompile (often several times per recompile); the
// handler dedups so we don't spawn tabs on every save.
//
// leaf: current PDF tab, if any.
// path: the path that leaf is showing (and the path of an
// in-flight open call, recorded synchronously when it
// is scheduled). Also gates the preview-URL skip logic
// on the "Browse at" branch below — when a PDF
// preview is active, we don't open Quarto's PDF.js
// wrapper page in the webviewer too.
// busy: a call to openOrRefreshPdfPreview is in flight.
//
// Schedule open when any of:
// - leaf is detached (user closed the tab manually)
// - the new output path differs from the tracked one
// (multi-format project, or rename)
// - we never opened in this session
// Skip when busy (bursts of emissions during recompile dedup
// automatically; the final emission of a burst wins because it
// arrives after busy clears).
let pdfPreviewLeaf = null;
let pdfPreviewPath = null;
let pdfPreviewBusy = false;
const schedulePdfPreview = (vaultPath) => {
if (pdfPreviewBusy)
return;
const leafAttached = pdfPreviewLeaf?.parent != null;
const pathSame = pdfPreviewPath === vaultPath;
if (leafAttached && pathSame)
return;
pdfPreviewBusy = true;
pdfPreviewPath = vaultPath;
this.openOrRefreshPdfPreview(vaultPath, pdfPreviewLeaf)
.then((leaf) => {
if (leaf)
pdfPreviewLeaf = leaf;
})
.catch((err) => {
console.error('[qmd-as-md] PDF preview open failed:', err);
pdfPreviewLeaf = null;
pdfPreviewPath = null;
})
.finally(() => {
pdfPreviewBusy = false;
});
};
// Quarto "ERROR:" lines from this preview run. Used both to surface
// recompile failures live (preview keeps running, so the close
// handler never fires) and to explain a startup exit in the Notice.
const errorLines = [];
// Dedupe: a single failed recompile emits the same ERROR: block on
// every save until fixed — only Notice when the error text changes.
let lastErrorShown = '';
// Per-line handler: log the line, then look for the two markers
// we care about ("Output created:" and "Browse at").
const handlePreviewLine = (line) => {
logQuartoLine('Quarto Preview', line);
if (/^ERROR:/.test(line)) {
errorLines.push(line);
if (line !== lastErrorShown) {
lastErrorShown = line;
new obsidian.Notice(`Quarto preview error:\n${line}`, 15000);
}
return;
}
// A clean compile clears the dedupe guard so the same error
// reappearing after a good build is surfaced again.
if (line.includes('Output created:')) {
lastErrorShown = '';
}
// Detect "Output created: <path>" — quarto prints this on every
// compile in preview mode. If the output is a PDF, route to
// Obsidian's native PDF viewer rather than the webviewer page
// Quarto serves at /web/viewer.html. Subsequent compiles refresh
// the same leaf so live reload still works.
const outMatch = line.match(/Output created:\s*(.+?)\s*$/);
if (outMatch && /\.pdf$/i.test(outMatch[1].trim()) && mode === 'obsidian') {
const outBasename = path__namespace.basename(outMatch[1].trim());
const sourceDir = file.parent?.path ?? '';
const vaultPath = obsidian.normalizePath(sourceDir ? `${sourceDir}/${outBasename}` : outBasename);
schedulePdfPreview(vaultPath);
return;
}
const matchedPreviewUrl = previewUrlFromLine(line);
if (!previewUrl && matchedPreviewUrl) {
console.log('[qmd-as-md][diag] Browse-at line seen.', 'matched:', matchedPreviewUrl, 'pdfPreviewPath:', pdfPreviewPath, 'mode:', mode);
previewUrl = matchedPreviewUrl;
// If we already opened a native PDF preview, skip the
// webviewer URL — Quarto's PDF.js wrapper would just be
// a worse version of the same content.
if (pdfPreviewPath) {
new obsidian.Notice(`PDF preview opened natively. Server URL: ${previewUrl}`);
}
else {
void this.openPreviewUrl(previewUrl, mode);
}
}
};
// One buffered processor per stream — stdout and stderr each
// need their own partial-line buffer, or interleaved fragments
// from the two streams would be spliced into synthetic lines.
const previewStdout = makeLineProcessor(handlePreviewLine);
const previewStderr = makeLineProcessor(handlePreviewLine);
quartoProcess.stdout?.on('data', (data) => previewStdout(data.toString()));
quartoProcess.stderr?.on('data', (data) => previewStderr(data.toString()));
// child_process.spawn does not throw on a missing binary; it emits
// an 'error' event later. Without this listener an ENOENT just
// produced a silent "exit 1" close with no output to console.
quartoProcess.on('error', (err) => {
console.error('[qmd-as-md] Failed to spawn quarto for preview:', err);
new obsidian.Notice(`Failed to spawn '${this.settings.quartoPath}': ${err.message}. ` +
'Check the Quarto path setting and that Quarto is on PATH.');
if (this.activePreviewProcesses.get(file.path)?.process === quartoProcess) {
this.activePreviewProcesses.delete(file.path);
}
});
quartoProcess.on('close', (code, signal) => {
previewStdout.flush(); // release any final partial line
previewStderr.flush();
if (code !== null && code !== 0) {
const reason = errorLines.length > 0
? errorLines.join('\n')
: 'Check the developer console for details.';
new obsidian.Notice(`Quarto preview exited with code ${code}.\n${reason}`, 15000);
}
else if (code === null && signal && signal !== 'SIGTERM' && signal !== 'SIGKILL') {
// SIGTERM/SIGKILL come from our own stopPreview / onunload — silent.
new obsidian.Notice(`Quarto preview process was terminated by ${signal}`);
}
if (this.activePreviewProcesses.get(file.path)?.process === quartoProcess) {
this.activePreviewProcesses.delete(file.path);
}
});
this.activePreviewProcesses.set(file.path, { process: quartoProcess, mode });
new obsidian.Notice(`Quarto preview started (${mode === 'obsidian' ? 'Obsidian' : 'external browser'})`);
}
catch (error) {
console.error('Failed to start Quarto preview:', error);
new obsidian.Notice('Failed to start Quarto preview');
}
}
// `quarto preview` forks a long-lived server as a child of the spawned
// process, so killing only the wrapper leaves that server running. Signal
// the whole process tree: the process group on POSIX (the child was
// spawned detached, see startPreview), or taskkill /t on Windows.
killPreviewProcess(quartoProcess) {
if (quartoProcess.killed || quartoProcess.pid === undefined)
return;
if (process.platform === 'win32') {
child_process.spawn('taskkill', ['/pid', String(quartoProcess.pid), '/t', '/f']);
return;
}
try {
// Negative PID targets the whole process group.
process.kill(-quartoProcess.pid, 'SIGTERM');
}
catch {
// Group already gone, or never became a leader — best-effort direct kill.
try {
quartoProcess.kill('SIGTERM');
}
catch {
/* already dead */
}
}
}
async stopPreview(file) {
const activePreview = this.activePreviewProcesses.get(file.path);
if (activePreview) {
this.killPreviewProcess(activePreview.process);
this.activePreviewProcesses.delete(file.path);
new obsidian.Notice('Quarto preview stopped');
}
}
stopAllPreviews() {
const hadPreviews = this.activePreviewProcesses.size > 0;
this.activePreviewProcesses.forEach((activePreview, filePath) => {
this.killPreviewProcess(activePreview.process);
this.activePreviewProcesses.delete(filePath);
});
if (hadPreviews) {
new obsidian.Notice('All Quarto previews stopped');
}
}
async renderPdf(file, toFormat) {
try {
const abstractFile = this.app.vault.getAbstractFileByPath(file.path);
if (!abstractFile || !(abstractFile instanceof obsidian.TFile)) {
new obsidian.Notice(`File ${file.path} not found`);
return;
}
// A running `quarto preview` keeps recompiling the same source and
// writes to overlapping output paths. Stop it before a one-shot
// render so the two Quarto processes do not fight over the output.
if (this.activePreviewProcesses.has(file.path)) {
await this.stopPreview(file);
}
const filePath = this.getVaultFullPath(abstractFile);
if (!filePath)
return;
const workingDir = path__namespace.dirname(filePath);
const envVars = { ...process.env };
if (this.settings.quartoTypst.trim()) {
envVars.QUARTO_TYPST = this.settings.quartoTypst.trim();
}
const engineLabel = toFormat === 'typst' ? 'Typst' : toFormat === 'pdf' ? 'LaTeX' : 'format defined in YAML';
new obsidian.Notice(`Rendering Quarto (${engineLabel})...`);
// Best-guess path used for the pre-render leaf-capture (so we can
// reuse an existing PDF tab on recompile). The authoritative path
// comes from quarto's "Output created:" stdout line, parsed below.
const guessedPdfPath = this.pdfPathFor(file);
const existingLeaf = this.app.workspace
.getLeavesOfType('pdf')
.find((l) => this.leafFile(l)?.path === guessedPdfPath);
const args = ['render', filePath];
if (toFormat)
args.push('--to', toFormat);
const quartoProcess = child_process.spawn(this.settings.quartoPath, args, {
cwd: workingDir,
env: envVars,
});
let detectedOutputBasename = null;
// Quarto prints the human-readable cause on "ERROR:" lines (bad YAML,
// missing engine, etc.). Keep them so a failing close can surface the
// real reason in the Notice instead of a bare exit code.
const errorLines = [];
// Per-line handler: log the line, then watch for "Output created:".
const handleRenderLine = (line) => {
logQuartoLine('Quarto', line);
const match = line.match(/Output created:\s*(.+?)\s*$/);
if (match) {
detectedOutputBasename = path__namespace.basename(match[1].trim());
}
if (/^ERROR:/.test(line)) {
errorLines.push(line);
}
};
// One buffered processor per stream — stdout and stderr each
// need their own partial-line buffer, or interleaved fragments
// from the two streams would be spliced into synthetic lines.
const renderStdout = makeLineProcessor(handleRenderLine);
const renderStderr = makeLineProcessor(handleRenderLine);
quartoProcess.stdout?.on('data', (data) => renderStdout(data.toString()));
quartoProcess.stderr?.on('data', (data) => renderStderr(data.toString()));
// child_process.spawn does not throw on a missing binary; it emits
// an 'error' event later. Without this listener an ENOENT just
// produced a silent "exit 1" close with no output to console.
quartoProcess.on('error', (err) => {
console.error('[qmd-as-md] Failed to spawn quarto for render:', err);
new obsidian.Notice(`Failed to spawn '${this.settings.quartoPath}': ${err.message}. ` +
'Check the Quarto path setting and that Quarto is on PATH.');
});
quartoProcess.on('close', (code, signal) => {
void (async () => {
renderStdout.flush(); // release any final partial line
renderStderr.flush();
// A clean exit is code 0. Anything else is a failure, except a
// termination by SIGTERM/SIGKILL — that means the process was
// intentionally cancelled (matching the preview handler, which
// suppresses notices for those signals). Stay quiet then.
if (code === 0) {
// fall through to the success path below
}
else if (code === null && (signal === 'SIGTERM' || signal === 'SIGKILL')) {
console.error(`[qmd-as-md] Quarto render cancelled (${signal}).`);
return;
}
else {
const exitLabel = code !== null
? `exit ${code}`
: signal
? `terminated by ${signal}`
: 'terminated';
// The full output was already streamed line-by-line through
// console.log / console.error as it arrived — no need to
// re-dump it. Surface the actual ERROR: line(s) in the Notice so
// the user sees the cause (bad YAML, missing engine, ...) without
// having to open the developer console.
console.error(`[qmd-as-md] Quarto render failed (${exitLabel}).`);
const reason = errorLines.length > 0
? errorLines.join('\n')
: 'Check the developer console for details.';
new obsidian.Notice(`Quarto render failed (${exitLabel}).\n${reason}`, 15000);
return;
}
const sourceDir = file.parent?.path ?? '';
const outputVaultPath = obsidian.normalizePath(detectedOutputBasename
? (sourceDir ? `${sourceDir}/${detectedOutputBasename}` : detectedOutputBasename)
: guessedPdfPath);
const outputTFile = await this.waitForVaultFile(outputVaultPath);
if (!outputTFile) {
new obsidian.Notice(`Quarto rendered, but ${outputVaultPath} did not appear in the vault within the timeout. Check Quarto's output-dir or vault sync.`);
return;
}
const isPdf = outputVaultPath.toLowerCase().endsWith('.pdf');
if (!this.settings.openPdfInObsidian || !isPdf) {
new obsidian.Notice(isPdf
? `PDF rendered: ${outputVaultPath}`
: `Rendered: ${outputVaultPath} (Obsidian's built-in viewer only handles PDFs).`);
return;
}
try {
const leaf = existingLeaf?.parent != null
? existingLeaf
: this.app.workspace.getLeaf('split', 'vertical');
await leaf.openFile(outputTFile, { active: false });
await this.app.workspace.revealLeaf(leaf);
new obsidian.Notice(`Opened ${outputVaultPath}`);
}
catch (err) {
console.error('Failed to open PDF in Obsidian:', err);
new obsidian.Notice(`PDF rendered at ${outputVaultPath}, but Obsidian could not open it (no PDF viewer registered?).`);
}
})().catch((err) => {
console.error('[qmd-as-md] Quarto render close handler failed:', err);
});
});
}
catch (error) {
console.error('Failed to render Quarto PDF:', error);
new obsidian.Notice('Failed to render Quarto PDF');
}
}
async waitForVaultFile(vaultPath, timeoutMs = 5000) {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
const f = this.app.vault.getAbstractFileByPath(vaultPath);
if (f instanceof obsidian.TFile)
return f;
await new Promise((r) => window.setTimeout(r, 200));
}
return null;
}
}
class QmdSettingTab extends obsidian.PluginSettingTab {
constructor(app, plugin) {
super(app, plugin);
this.plugin = plugin;
}
display() {
const { containerEl } = this;
containerEl.empty();
new obsidian.Setting(containerEl)
.setName('Quarto path')
.setDesc('Path to the Quarto executable (e.g. quarto, /usr/local/bin/quarto)')
.addText((text) => text
.setPlaceholder('quarto')
.setValue(this.plugin.settings.quartoPath)
.onChange(async (value) => {
this.plugin.settings.quartoPath = value;
await this.plugin.saveSettings();
}));
new obsidian.Setting(containerEl)
.setName('Templates folder')
.setDesc('Vault folder containing your own .qmd template files for "New Quarto file from preset". ' +
'Each .qmd in this folder (top level only — subfolders are ignored) becomes a preset; ' +
'the file name is used as the preset name and the file contents are inserted verbatim. ' +
'Leave empty to show only the built-in presets.')
.addText((text) => text
.setPlaceholder('e.g. _quarto-templates')
.setValue(this.plugin.settings.templatesFolder)
.onChange(async (value) => {
this.plugin.settings.templatesFolder = value;
await this.plugin.saveSettings();
}));
new obsidian.Setting(containerEl)
.setName('Enable editing Quarto files')
.setDesc('When on, .qmd files open in the Markdown editor. Turn off if another plugin handles .qmd editing.')
.addToggle((toggle) => toggle
.setValue(this.plugin.settings.enableQmdLinking)
.onChange(async (value) => {
this.plugin.settings.enableQmdLinking = value;
if (value) {
this.plugin.registerQmdExtension();
}
await this.plugin.saveSettings();
}));
new obsidian.Setting(containerEl)
.setName('QUARTO_TYPST variable')
.setDesc('Value for the QUARTO_TYPST environment variable (leave empty to unset).')
.addText((text) => text
.setPlaceholder('e.g. typst_path')
.setValue(this.plugin.settings.quartoTypst)
.onChange(async (value) => {
this.plugin.settings.quartoTypst = value;
await this.plugin.saveSettings();
}));
new obsidian.Setting(containerEl)
.setName('Open compiled PDF in Obsidian')
.setDesc("When rendering to PDF, open the resulting file inside Obsidian using the built-in PDF viewer. The .qmd source must live in the vault so the rendered PDF is accessible.")
.addToggle((toggle) => toggle
.setValue(this.plugin.settings.openPdfInObsidian)
.onChange(async (value) => {
this.plugin.settings.openPdfInObsidian = value;
await this.plugin.saveSettings();
}));
new obsidian.Setting(containerEl)
.setName('Open Quarto preview in Obsidian')
.setDesc('Default target for the generic Toggle Quarto preview command and ribbon icon. ' +
"When on: PDF previews (format: typst / pdf) open in Obsidian's native PDF viewer; " +
"non-PDF previews (HTML, etc.) open in Obsidian 1.8's built-in web viewer. " +
'When off, the plugin opens Quarto\'s preview URL in your default external browser. ' +
'Use the explicit preview commands to choose a target without changing this setting.')
.addToggle((toggle) => toggle
.setValue(this.plugin.settings.previewInObsidian)
.onChange(async (value) => {
this.plugin.settings.previewInObsidian = value;
await this.plugin.saveSettings();
}));
new obsidian.Setting(containerEl)
.setName('Preview and render Markdown files with Quarto')
.setDesc('When on, Quarto preview and render commands also accept .md files that have _quarto.yml in their folder or an ancestor up to the vault root. Leave off to restrict commands to .qmd files.')
.addToggle((toggle) => toggle
.setValue(this.plugin.settings.previewMarkdownFiles)
.onChange(async (value) => {
this.plugin.settings.previewMarkdownFiles = value;
await this.plugin.saveSettings();
}));
new obsidian.Setting(containerEl)
.setName('Show YAML files')
.setDesc('When on, .yml and .yaml files appear in Obsidian using a CodeMirror editor with Quarto-oriented YAML highlighting. Turn off and reload the plugin to hide them again.')
.addToggle((toggle) => toggle
.setValue(this.plugin.settings.showYamlFiles)
.onChange(async (value) => {
this.plugin.settings.showYamlFiles = value;
if (value) {
this.plugin.registerYamlExtensions();
}
else {
new obsidian.Notice('Reload the plugin to hide YAML files again.');
}
await this.plugin.saveSettings();
}));
new obsidian.Setting(containerEl)
.setName('Show Lua files')
.setDesc('When on, .lua files appear in Obsidian using a CodeMirror editor with minimal Lua syntax highlighting — handy for editing Quarto/pandoc filter scripts. Turn off and reload the plugin to hide them again.')
.addToggle((toggle) => toggle
.setValue(this.plugin.settings.showLuaFiles)
.onChange(async (value) => {
this.plugin.settings.showLuaFiles = value;
if (value) {
this.plugin.registerLuaExtensions();
}
else {
new obsidian.Notice('Reload the plugin to hide Lua files again.');
}
await this.plugin.saveSettings();
}));
new obsidian.Setting(containerEl)
.setName('Show Quarto outline')
.setDesc("Add a sidebar outline of the active .qmd file's headings (Obsidian's " +
'core Outline panel cannot read .qmd files). Active file only — ' +
'headings from included files are not listed. The "Open Quarto ' +
'outline" command works regardless of this toggle.')
.addToggle((toggle) => toggle
.setValue(this.plugin.settings.showOutline)
.onChange(async (value) => {
this.plugin.settings.showOutline = value;
await this.plugin.saveSettings();
if (value) {
await this.plugin.activateOutlineView();
}
else {
this.plugin.detachOutlineViews();
}
}));
new obsidian.Setting(containerEl)
.setName('Outline Markdown files')
.setDesc('When on, the Quarto outline also lists headings of the active .md file, not just .qmd files. ' +
"Obsidian's core Outline panel already covers .md files — enable this only if you prefer the Quarto outline for both.")
.addToggle((toggle) => toggle
.setValue(this.plugin.settings.outlineMarkdownFiles)
.onChange(async (value) => {
this.plugin.settings.outlineMarkdownFiles = value;
await this.plugin.saveSettings();
// Drop a now-ineligible .md target so the outline does not keep
// describing a file it is no longer allowed to.
if (!value &&
this.plugin.lastActiveQuartoFile &&
this.plugin.isMarkdownFile(this.plugin.lastActiveQuartoFile)) {
this.plugin.lastActiveQuartoFile = null;
}
this.plugin.trackActiveQuartoFile();
this.plugin.refreshOutlineViews();
}));
}
}
module.exports = QmdAsMdPlugin;
/* nosourcemap */