'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: " — 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 */