1909 lines
80 KiB
JavaScript
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 */ |