Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 11 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,22 +34,27 @@ The Author called [%author_name] is really funny!
```

You can also reference properties from another note by putting an Obsidian link
target inside the marker, followed by `#property`:
target inside the marker, followed by `@property`:

```markdown
Current status: [%[[Project]]#status]
Project owner: [%[[Projects/Website|Website project]]#owner]
Review date: [%[Website project](Projects/Website.md)#review_date]
Current status: [%[[Project]]@status]
Project owner: [%[[Projects/Website|Website project]]@owner]
Review date: [%[Website project](Projects/Website.md)@review_date]
```

The same target syntax works with double braces when that syntax format is
selected:

```markdown
Current status: {{[[Project]]#status}}
Review date: {{[Website project](Projects/Website.md)#review_date}}
Current status: {{[[Project]]@status}}
Review date: {{[Website project](Projects/Website.md)@review_date}}
```

> **Deprecation:** earlier releases used `#property` as the separator (e.g.
> `[[Project]]#status`). That form still renders but is **deprecated and will be
> removed in a future release**, because Obsidian indexes the trailing `#status`
> as a tag. Switch existing references to `@` to avoid stray tags.

The syntax markers are replaced in reading view and live preview. Source mode keeps
the syntax as plain text. If a local key, target note, or target property is
missing, the marker is left unchanged. Inline code and code blocks are ignored in
Expand Down
2 changes: 1 addition & 1 deletion manifest.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"id": "embed-metadata",
"name": "Embed Metadata",
"version": "0.7.1",
"version": "0.8.0",
"minAppVersion": "1.0.0",
"description": "Render frontmatter metadata (Properties) inside your notes with a lightweight inline syntax.",
"author": "Schemen",
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "embed-metadata",
"version": "0.7.1",
"version": "0.8.0",
"description": "Render frontmatter metadata inside Obsidian notes with lightweight inline syntax.",
"private": true,
"main": "main.js",
Expand Down
2 changes: 1 addition & 1 deletion src/markdown-render.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// Inline markdown renderer for metadata values (links, embeds, etc.)
import {App, Component, MarkdownRenderer} from "obsidian";

const markdownHintRegex = /(\[\[|!\[\[|`|\*|_|~|\[[^\]]+\]\([^)]+\)|#|\n)/;
const markdownHintRegex = /(\[\[|!\[\[|`|\*|_|~|\[[^\]]+\]\([^)]+\)|#|https?:\/\/|\n)/;

// Render a value as inline markdown
export function renderInlineMarkdown(
Expand Down
167 changes: 159 additions & 8 deletions src/metadata-renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
getSyntaxOpen,
metadataTargetCouldResolveTo,
parseMetadataReference,
type MetadataMarker,
type MetadataReference,
type MetadataResolution,
type MetadataResolver,
Expand All @@ -17,6 +18,11 @@ import {EmbedMetadataPlugin} from "./settings";
const VALUE_CLASS = "embed-metadata-value";
const UNRESOLVED_CLASS = "embed-metadata-unresolved";

// Contexts we never rewrite: raw editable text (handled by the CodeMirror
// plugin), code, and spans we have already rendered.
const EXCLUDED_SELECTOR =
`.cm-line, code, pre, .cm-inline-code, .cm-hmd-internal-code, .${VALUE_CLASS}, .${UNRESOLVED_CLASS}`;

// What each span last rendered, so refreshes can skip unchanged values.
const lastRendered = new WeakMap<HTMLElement, string>();

Expand Down Expand Up @@ -44,14 +50,20 @@ export function registerMetadataRenderer(plugin: EmbedMetadataPlugin): (changedF
);
const doc = el.ownerDocument;
const syntaxOpen = getSyntaxOpen(plugin.settings.syntaxStyle);

// First collapse markers Obsidian fragmented across inline elements
// (e.g. `{{[[Note]]@key}}` becomes text + link + text). The text-node
// pass below only matches markers that live inside a single text node.
replaceFragmentedMarkers(el, resolver, doc, syntaxOpen, file.path, plugin);

const walker = doc.createTreeWalker(el, NodeFilter.SHOW_TEXT, {
acceptNode(node) {
if (!node.nodeValue || !node.nodeValue.includes(syntaxOpen)) {
return NodeFilter.FILTER_SKIP;
}

const parent = node.parentElement;
if (!parent || parent.closest(`.cm-line, code, pre, .cm-inline-code, .cm-hmd-internal-code, .${VALUE_CLASS}, .${UNRESOLVED_CLASS}`)) {
if (!parent || parent.closest(EXCLUDED_SELECTOR)) {
return NodeFilter.FILTER_REJECT;
}

Expand Down Expand Up @@ -106,13 +118,7 @@ function replaceSyntaxInTextNode(
fragment.append(before);
}

const span = doc.createElement("span");
span.dataset.embedMetadataKey = marker.key;
span.dataset.embedMetadataReference = marker.raw;
span.dataset.embedMetadataMarker = marker.marker;
span.dataset.embedMetadataSourcePath = sourcePath;
applyResolution(span, resolver.resolve(marker), marker.marker, plugin);
fragment.append(span);
fragment.append(createMarkerSpan(marker, resolver, doc, sourcePath, plugin));

lastIndex = marker.to;
}
Expand All @@ -125,6 +131,151 @@ function replaceSyntaxInTextNode(
textNode.replaceWith(fragment);
}

// A child node of an inline container, with the source text it reconstructs to
// and its offset within the container's reconstructed source string.
type SourceSegment = {
node: ChildNode;
source: string;
start: number;
isText: boolean;
};

// Replace markers that Obsidian split across inline elements. Obsidian renders
// links and tags before our post-processor runs, so `{{[[Note]]@key}}` arrives
// as separate sibling nodes and never appears whole in any single text node.
function replaceFragmentedMarkers(
root: HTMLElement,
resolver: MetadataResolver,
doc: Document,
syntaxOpen: string,
sourcePath: string,
plugin: EmbedMetadataPlugin
): void {
const containers = new Set<HTMLElement>();
const walker = doc.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
acceptNode(node) {
if (!node.nodeValue || !node.nodeValue.includes(syntaxOpen)) {
return NodeFilter.FILTER_SKIP;
}

const parent = node.parentElement;
if (!parent || parent.closest(EXCLUDED_SELECTOR)) {
return NodeFilter.FILTER_REJECT;
}

return NodeFilter.FILTER_ACCEPT;
},
});

let node = walker.nextNode();
while (node) {
const parent = (node as Text).parentElement;
if (parent) {
containers.add(parent);
}
node = walker.nextNode();
}

for (const container of containers) {
reassembleContainer(container, resolver, doc, syntaxOpen, sourcePath, plugin);
}
}

// Rebuild the source for one inline container and replace any marker that spans
// more than one child node. Single-node markers are left to the text-node pass.
function reassembleContainer(
container: HTMLElement,
resolver: MetadataResolver,
doc: Document,
syntaxOpen: string,
sourcePath: string,
plugin: EmbedMetadataPlugin
): void {
const segments: SourceSegment[] = [];
let source = "";
for (const child of Array.from(container.childNodes)) {
const piece = reconstructSource(child);
segments.push({node: child, source: piece, start: source.length, isText: child.nodeType === Node.TEXT_NODE});
source += piece;
}

if (!source.includes(syntaxOpen)) {
return;
}

const markers = findMetadataMarkers(source, plugin.settings.syntaxStyle);

// Replace from the end so earlier offsets and untouched nodes stay valid.
for (let i = markers.length - 1; i >= 0; i -= 1) {
const marker = markers[i];
if (!marker) {
continue;
}
const startSeg = findSegmentAt(segments, marker.from);
const endSeg = findSegmentAt(segments, marker.to - 1);
if (!startSeg || !endSeg || startSeg === endSeg) {
continue;
}

// Marker delimiters (`{{`/`}}`) always sit in text; a non-text boundary
// means an unexpected layout we leave untouched rather than mangle.
if (!startSeg.isText || !endSeg.isText) {
continue;
}

const span = createMarkerSpan(marker, resolver, doc, sourcePath, plugin);
const range = doc.createRange();
range.setStart(startSeg.node, marker.from - startSeg.start);
range.setEnd(endSeg.node, marker.to - endSeg.start);
range.deleteContents();
range.insertNode(span);
}
}

// Reconstruct the markdown source a rendered node came from, so a fragmented
// marker can be parsed back into its original reference.
function reconstructSource(node: ChildNode): string {
if (node.nodeType === Node.TEXT_NODE) {
return node.nodeValue ?? "";
}
if (node instanceof HTMLElement) {
if (node.matches("a.internal-link") && node.dataset.href) {
// `data-href` keeps the link target including any `#subpath`.
return `[[${node.dataset.href}]]`;
}
// Tags (legacy `#key` form) and other inline markup map back to their text.
return node.textContent ?? "";
}
return node.textContent ?? "";
}

function findSegmentAt(segments: SourceSegment[], offset: number): SourceSegment | null {
for (const segment of segments) {
if (offset >= segment.start && offset < segment.start + segment.source.length) {
return segment;
}
}
return null;
}

// Build a placeholder span for a marker, tagged so targeted refreshes can find
// and re-resolve it in place when a dependency changes.
function createMarkerSpan(
marker: MetadataMarker,
resolver: MetadataResolver,
doc: Document,
sourcePath: string,
plugin: EmbedMetadataPlugin
): HTMLElement {
const span = doc.createElement("span");
span.dataset.embedMetadataKey = marker.key;
span.dataset.embedMetadataReference = marker.raw;
span.dataset.embedMetadataMarker = marker.marker;
span.dataset.embedMetadataSourcePath = sourcePath;
applyResolution(span, resolver.resolve(marker), marker.marker, plugin);
return span;
}

// Render a resolution into a span, skipping the DOM write when nothing changed.
function applyResolution(
span: HTMLElement,
Expand Down
7 changes: 7 additions & 0 deletions src/metadata-suggest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,13 @@ function getRemotePropertyTrigger(
return null;
}

// Only the canonical `@` separator gets autocomplete. The deprecated `#`
// form still renders, but withholding the assist nudges notes toward `@`.
const beforeKey = content.slice(0, content.length - reference.key.length).trimEnd();
if (!beforeKey.endsWith("@")) {
return null;
}

return {
query: reference.key,
target: reference.target,
Expand Down
5 changes: 4 additions & 1 deletion src/metadata-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -387,9 +387,12 @@ function parseRemoteReference(raw: string, allowEmptyKey: boolean): MetadataRefe
};
}

// `@` is the canonical key separator; `#` is deprecated and still parsed for
// back-compat, but will be removed in a future release. `@` avoids the key being
// indexed as an Obsidian tag the way a loose `#key` is.
function parseRemoteKey(raw: string, allowEmptyKey: boolean): string | null {
const trimmed = raw.trim();
if (!trimmed.startsWith("#")) {
if (!trimmed.startsWith("@") && !trimmed.startsWith("#")) {
return null;
}

Expand Down
9 changes: 9 additions & 0 deletions src/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,15 @@ export class EmbedMetadataSettingTab extends PluginSettingTab {
});
});

new Setting(containerEl)
.setName("Remote property syntax")
.setDesc(
"Reference another note's property with [[Note]]@key; autocomplete is offered after @."
+ " The older [[Note]]#key form still renders but is deprecated and will be removed in a"
+ " future release, because Obsidian indexes the #key as a tag. Switch existing references"
+ " to @ to avoid stray tags."
);

new Setting(containerEl)
.setName("Render in outline (experimental)")
.setDesc("Render metadata markers in the outline view.")
Expand Down
3 changes: 2 additions & 1 deletion versions.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,6 @@
"0.5.2": "0.15.0",
"0.6.0": "1.0.0",
"0.7.0": "1.0.0",
"0.7.1": "1.0.0"
"0.7.1": "1.0.0",
"0.8.0": "1.0.0"
}
Loading