@liminis/editor 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/README.md +77 -8
  2. package/dist/app/editor/LinkClickPlugin.js +5 -1
  3. package/dist/app/editor/WikiLinkExistencePlugin.d.ts +13 -3
  4. package/dist/app/editor/WikiLinkExistencePlugin.js +91 -32
  5. package/dist/app/editor/editorNodes.js +2 -1
  6. package/dist/app/editor/nodes/CustomLinkNode.d.ts +14 -0
  7. package/dist/app/editor/nodes/CustomLinkNode.js +37 -0
  8. package/dist/app/editor/nodes/TransclusionComponent.d.ts +8 -0
  9. package/dist/app/editor/nodes/TransclusionComponent.js +65 -0
  10. package/dist/app/editor/nodes/TransclusionNode.d.ts +48 -0
  11. package/dist/app/editor/nodes/TransclusionNode.js +141 -0
  12. package/dist/app/editor/nodes/index.d.ts +2 -0
  13. package/dist/app/editor/nodes/index.js +1 -0
  14. package/dist/app/editor/nodes/transclusion-loading.d.ts +27 -0
  15. package/dist/app/editor/nodes/transclusion-loading.js +29 -0
  16. package/dist/app/editor/nodes/transclusion-render.d.ts +49 -0
  17. package/dist/app/editor/nodes/transclusion-render.js +186 -0
  18. package/dist/app/mapper/lexicalToMdast.js +55 -6
  19. package/dist/app/mapper/mdastToLexical.js +67 -1
  20. package/dist/host/defaults.js +1 -0
  21. package/dist/host/messages.d.ts +7 -1
  22. package/dist/host/messages.js +3 -3
  23. package/dist/host/types.d.ts +14 -1
  24. package/dist/markdown/parse.js +225 -1
  25. package/dist/markdown/stringify.js +30 -10
  26. package/dist/markdown/vendor/mdast-util-wiki-link/README.md +17 -0
  27. package/dist/markdown/vendor/mdast-util-wiki-link/from-markdown.d.ts +8 -1
  28. package/dist/markdown/vendor/mdast-util-wiki-link/from-markdown.js +26 -1
  29. package/dist/markdown/vendor/mdast-util-wiki-link/to-markdown.d.ts +13 -7
  30. package/dist/markdown/vendor/mdast-util-wiki-link/to-markdown.js +3 -1
  31. package/dist/styles.css +48 -0
  32. package/dist/types.d.ts +1 -0
  33. package/docs/architecture.md +80 -0
  34. package/docs/decisions/adr-119-block-transclusion.md +247 -0
  35. package/docs/diagrams/architecture-1-dark.svg +1 -0
  36. package/docs/diagrams/architecture-1.svg +1 -0
  37. package/docs/diagrams/architecture-2-dark.svg +1 -0
  38. package/docs/diagrams/architecture-2.svg +1 -0
  39. package/docs/editor-api.md +1 -0
  40. package/docs/markdown-pipeline.md +110 -6
  41. package/package.json +6 -4
@@ -43,5 +43,6 @@ export function resolveHostServices(services) {
43
43
  resolveWikiLinks: services?.resolveWikiLinks,
44
44
  onScrollToAnchor: services?.onScrollToAnchor,
45
45
  corrections: services?.corrections,
46
+ resolveTransclusion: services?.resolveTransclusion,
46
47
  };
47
48
  }
@@ -19,7 +19,13 @@ export interface HostMessageApi {
19
19
  requestSettings: () => void;
20
20
  applyTextEdits: (edits: TextEdit[], reason: 'typing' | 'drag' | 'paste' | 'format') => void;
21
21
  writeAsset: (dataUri: string, suggestedName?: string) => void;
22
- openLink: (url: string) => void;
22
+ /**
23
+ * `blockId` is additive (#119): a host that hasn't implemented block-aware
24
+ * navigation still receives `url` and opens the file exactly as before —
25
+ * FR-004's "degrades no worse than today's file-only wikilink navigation"
26
+ * falls out for free, with no host-side change required.
27
+ */
28
+ openLink: (url: string, blockId?: string) => void;
23
29
  }
24
30
  export declare function createHostMessageApi(bridge: EditorHostBridge, log: EditorLogger): HostMessageApi;
25
31
  /** Hook form of {@link createHostMessageApi}, bound to the ambient host services. */
@@ -31,9 +31,9 @@ export function createHostMessageApi(bridge, log) {
31
31
  writeAsset: (dataUri, suggestedName) => {
32
32
  postMessage({ type: 'WRITE_ASSET', dataUri, suggestedName });
33
33
  },
34
- openLink: (url) => {
35
- log.debug('openLink', { url });
36
- postMessage({ type: 'OPEN_LINK', url });
34
+ openLink: (url, blockId) => {
35
+ log.debug('openLink', { url, blockId });
36
+ postMessage(blockId ? { type: 'OPEN_LINK', url, blockId } : { type: 'OPEN_LINK', url });
37
37
  },
38
38
  };
39
39
  }
@@ -70,6 +70,19 @@ export interface EditorHostServices {
70
70
  notifyError?: (message: string, description?: string) => void;
71
71
  /** Correction persistence + knowledge-graph services. */
72
72
  corrections?: CorrectionHostServices;
73
+ /**
74
+ * Resolve a workspace-global block reference (`file#^blockId`, #119) to
75
+ * that block's current markdown content. Backs both transclusion
76
+ * (`![[file#^id]]`) and existence-checking for block-scoped links
77
+ * (`[[file#^id]]`) — see `TransclusionComponent`/`WikiLinkExistencePlugin`.
78
+ *
79
+ * Returns `null` when the file or block id does not resolve (FR-009); the
80
+ * two are not distinguished here — a host that cares to tell them apart
81
+ * can encode that in its own lookup, but the contract only needs
82
+ * resolved-vs-not. Absent entirely (the default), transclusion renders an
83
+ * "unresolved" placeholder rather than throwing (FR-008).
84
+ */
85
+ resolveTransclusion?: (file: string, blockId: string) => Promise<string | null>;
73
86
  }
74
87
  /** `EditorHostServices` with every member resolved to a concrete implementation. */
75
- export type ResolvedEditorHostServices = Required<Pick<EditorHostServices, 'bridge' | 'logger' | 'notifyError'>> & Pick<EditorHostServices, 'resolveWikiLinks' | 'onScrollToAnchor' | 'corrections'>;
88
+ export type ResolvedEditorHostServices = Required<Pick<EditorHostServices, 'bridge' | 'logger' | 'notifyError'>> & Pick<EditorHostServices, 'resolveWikiLinks' | 'onScrollToAnchor' | 'corrections' | 'resolveTransclusion'>;
@@ -14,6 +14,29 @@ import * as wikiLinkMdast from './vendor/mdast-util-wiki-link/index.js';
14
14
  // Wiki-link options: use | as the alias divider (Obsidian/Foam style)
15
15
  const wikiLinkOptions = { aliasDivider: '|' };
16
16
  const EMPTY_ALIAS_SENTINEL = '__EMPTY_ALIAS__';
17
+ // Private-Use-Area sentinel marking a `!` that immediately precedes `[[`
18
+ // (candidate transclusion/embed marker, #119). The next free codepoint after
19
+ // `annotate-sentinels.ts`'s E000-E003 range and `stringify.ts`'s E004.
20
+ //
21
+ // Why substitute at all, rather than just checking "does the wikiLink node
22
+ // have a `!` text sibling" after parsing: the micromark wiki-link tokenizer
23
+ // is not vendored (see the vendor README) and only hooks the `[` character.
24
+ // A literal `!` immediately before `[[` is claimed *first* by the default
25
+ // image-label-start construct (hooked on `!`), and when that construct fails
26
+ // to find a following `(url)`/`[ref]` — which it always will here, since
27
+ // `[[target]]` is not image syntax — CommonMark's own bracket-resolution
28
+ // falls the *entire* `![[target]]` span back to one literal text node,
29
+ // without ever giving the wiki-link tokenizer a chance to fire on the inner
30
+ // `[[`. A backslash-escaped `\!` sidesteps the image construct entirely (it
31
+ // is consumed as a plain escaped character, not a construct trigger) and
32
+ // `[[target]]` parses normally — confirmed empirically against
33
+ // `micromark-extension-wiki-link@0.0.4`'s tokenizer. Swapping the raw `!`
34
+ // for this sentinel *before* parsing reproduces that same escape-shaped
35
+ // bypass without a real backslash reaching the output, so `[[target]]` parses
36
+ // as a normal wikiLink node with the sentinel left on the preceding text
37
+ // node — which `resolveWikiEmbeds` (below) then reads to decide embed vs.
38
+ // plain link, and always strips before the tree leaves `parseMarkdown`.
39
+ const EMBED_MARKER_SENTINEL = '\u{E005}';
17
40
  /**
18
41
  * Escape pipes inside wiki-links: [[target|alias]] → [[target\|alias]]
19
42
  * This prevents GFM table parsing from splitting wiki-links at the pipe.
@@ -57,6 +80,200 @@ function escapeWikiLinkPipes(text) {
57
80
  result += text.slice(cursor);
58
81
  return { text: result, replacements };
59
82
  }
83
+ /**
84
+ * Swap a `!` for {@link EMBED_MARKER_SENTINEL}, but only when it is
85
+ * immediately followed by a *complete, single-line* `[[...]]` span with no
86
+ * internal `]` — i.e. exactly the shape the wiki-link tokenizer's own
87
+ * `consumeTarget`/`consumeAlias` states require to succeed (a bare `]` not
88
+ * immediately followed by a second `]` aborts the whole construct; so does a
89
+ * line ending). One codepoint swapped for one codepoint, so — unlike
90
+ * `escapeWikiLinkPipes`/`normalizeWikiLinks` above — this never shifts any
91
+ * subsequent offset and needs no `Replacement` tracking of its own.
92
+ *
93
+ * The "complete span" requirement is load-bearing, not a nicety: an `!`
94
+ * immediately before `[` is *also* how a real image's alt text starting with
95
+ * a literal `[` looks at the character level (`![[leading] bracket](x.png)`
96
+ * is `![` + alt text `[leading] bracket` + `](x.png)`, i.e. contains the raw
97
+ * substring `![[`). Matching on `!(?=\[\[)` alone — with no lookahead past
98
+ * the second `[` — can't tell that case apart from a genuine embed candidate
99
+ * and would substitute inside it, preventing the image construct (which
100
+ * needs the literal `!`) from ever being tried and corrupting the image
101
+ * (caught by the `903-image-alt-leading-bracket` regression fixture).
102
+ * Requiring the run between `[[` and `]]` to contain no internal `]` rules
103
+ * that case out: `[leading] bracket](x.png)` hits an un-doubled `]` right
104
+ * after `leading`, so the pattern below never matches there, `!` survives
105
+ * untouched, and the image construct parses exactly as before this feature
106
+ * existed (FR-014).
107
+ *
108
+ * A `!` that is already backslash-escaped (`\![[...]]`) is left alone: that
109
+ * spelling already means "literal `!`, then a normal wiki-link" with no
110
+ * substitution needed (CommonMark consumes the escape before the image
111
+ * construct ever sees the `!`) — see FR-013's requirement that an author can
112
+ * explicitly opt out of transclusion for a `#^id`-bearing target.
113
+ *
114
+ * "Escaped" here means CommonMark backslash-*parity*, not merely "a `\`
115
+ * immediately precedes the `!`": an even run of backslashes (`\\!`, `\\\\!`,
116
+ * ...) pairs off into literal backslashes and does not escape the `!`, while
117
+ * an odd run (`\!`, `\\\!`, ...) does. A naive one-character lookbehind gets
118
+ * every even run ≥ 2 wrong (treats the `!` as escaped when it isn't), so the
119
+ * preceding backslash run is captured and its length checked explicitly.
120
+ *
121
+ * A match inside a fenced code block or inline code span must be skipped
122
+ * entirely, not just left un-embedded: inside those constructs the wiki-link
123
+ * tokenizer never runs at all (code content is verbatim), so a substituted
124
+ * sentinel would never get a `wikiLink` node to attach to and restore from —
125
+ * `resolveWikiEmbeds`'s leftover-sentinel sweep only walks `text` nodes, not
126
+ * a `code`/`inlineCode` node's `value`, so the sentinel would otherwise leak
127
+ * through as a literal, invisible Private-Use-Area character in the final
128
+ * output instead of being restored to `!`. {@link findProtectedRanges}
129
+ * identifies those spans so the substitution can leave them untouched.
130
+ */
131
+ function substituteEmbedMarker(text) {
132
+ const protectedRanges = findProtectedRanges(text);
133
+ return text.replace(/(\\*)!(\[\[[^\]\n]*\]\])/g, (match, backslashes, bracketed, offset) => {
134
+ if (protectedRanges.some((r) => offset >= r.start && offset < r.end)) {
135
+ return match;
136
+ }
137
+ return backslashes.length % 2 === 1
138
+ ? `${backslashes}!${bracketed}`
139
+ : `${backslashes}${EMBED_MARKER_SENTINEL}${bracketed}`;
140
+ });
141
+ }
142
+ /**
143
+ * Finds fenced code blocks and inline code spans in raw markdown text, so
144
+ * {@link substituteEmbedMarker} can avoid mutating their (verbatim) content.
145
+ * Deliberately approximate rather than a full CommonMark tokenizer — good
146
+ * enough to protect the common cases (``` fences, `inline` spans) without
147
+ * duplicating the real tokenizer this file elsewhere avoids vendoring.
148
+ */
149
+ function findProtectedRanges(text) {
150
+ const ranges = [];
151
+ // Fenced code blocks: a line of (up to 3 leading spaces then) 3+ backticks
152
+ // or tildes opens one; it's closed by a later line of at least as many of
153
+ // the same character (optionally indented, nothing else on the line).
154
+ const fenceOpenRe = /^ {0,3}(`{3,}|~{3,})/;
155
+ let fenceChar = null;
156
+ let fenceLen = 0;
157
+ let fenceStart = -1;
158
+ let cursor = 0;
159
+ for (const line of text.split('\n')) {
160
+ const lineEnd = cursor + line.length;
161
+ if (fenceChar === null) {
162
+ const m = fenceOpenRe.exec(line);
163
+ if (m) {
164
+ fenceChar = m[1][0];
165
+ fenceLen = m[1].length;
166
+ fenceStart = cursor;
167
+ }
168
+ }
169
+ else {
170
+ const closeRe = new RegExp(`^ {0,3}\\${fenceChar}{${fenceLen},}\\s*$`);
171
+ if (closeRe.test(line)) {
172
+ ranges.push({ start: fenceStart, end: lineEnd });
173
+ fenceChar = null;
174
+ fenceLen = 0;
175
+ fenceStart = -1;
176
+ }
177
+ }
178
+ cursor = lineEnd + 1; // +1 for the '\n' consumed by split
179
+ }
180
+ if (fenceChar !== null) {
181
+ ranges.push({ start: fenceStart, end: text.length });
182
+ }
183
+ // Inline code spans, outside any fenced block already found: a backtick
184
+ // run opens a span, closed by the next run of the *same* length (a run of
185
+ // a different length is span content, not a delimiter) — CommonMark's own
186
+ // code-span rule. An opening run with no matching close is not a code
187
+ // span at all (its backticks are literal), so it protects nothing.
188
+ const isInFence = (pos) => ranges.some((r) => pos >= r.start && pos < r.end);
189
+ const backtickRun = /`+/g;
190
+ let pendingOpen = null;
191
+ let match;
192
+ while ((match = backtickRun.exec(text)) !== null) {
193
+ if (isInFence(match.index))
194
+ continue;
195
+ const len = match[0].length;
196
+ if (pendingOpen === null) {
197
+ pendingOpen = { start: match.index, len };
198
+ }
199
+ else if (len === pendingOpen.len) {
200
+ ranges.push({ start: pendingOpen.start, end: match.index + len });
201
+ pendingOpen = null;
202
+ }
203
+ }
204
+ return ranges;
205
+ }
206
+ /**
207
+ * Retype a `wikiLink` node to `wikiEmbed` when it was immediately preceded by
208
+ * an {@link EMBED_MARKER_SENTINEL} *and* carries a `data.blockId` (#119).
209
+ *
210
+ * Two outcomes when a sentinel-terminated text node precedes a `wikiLink`:
211
+ * - **Has `blockId`**: this is a genuine transclusion. Strip the sentinel
212
+ * off the preceding text (dropping the text node entirely if it was the
213
+ * sentinel alone) and retype the node to `wikiEmbed`, carrying the same
214
+ * `value`/`data`.
215
+ * - **No `blockId`**: FR-013 — `![[file]]` with no anchor is not a supported
216
+ * construct in v1. Restore the literal `!` in the preceding text and leave
217
+ * the node as an ordinary `wikiLink`, so it degrades to ordinary
218
+ * file-only link treatment (no embedding), matching what plain
219
+ * `\![[file]]` already does without any sentinel involved.
220
+ *
221
+ * A final sweep restores any sentinel left over from a `!` that didn't end up
222
+ * immediately before a completed `wikiLink` (e.g. the target never closed) —
223
+ * this sentinel must never leak into rendered/round-tripped content.
224
+ */
225
+ function resolveWikiEmbeds(root) {
226
+ function walkChildren(children) {
227
+ const result = [];
228
+ for (const rawChild of children) {
229
+ const child = walk(rawChild);
230
+ if (child?.type === 'wikiLink' && result.length > 0) {
231
+ const prevIndex = result.length - 1;
232
+ const prev = result[prevIndex];
233
+ if (prev.type === 'text' && typeof prev.value === 'string' && prev.value.endsWith(EMBED_MARKER_SENTINEL)) {
234
+ const blockId = child.data?.blockId;
235
+ const hasBlockId = typeof blockId === 'string' && blockId.length > 0;
236
+ const strippedValue = prev.value.slice(0, -1);
237
+ if (hasBlockId) {
238
+ if (strippedValue.length === 0) {
239
+ result.pop();
240
+ }
241
+ else {
242
+ result[prevIndex] = { ...prev, value: strippedValue };
243
+ }
244
+ result.push({ ...child, type: 'wikiEmbed' });
245
+ continue;
246
+ }
247
+ result[prevIndex] = { ...prev, value: strippedValue + '!' };
248
+ result.push(child);
249
+ continue;
250
+ }
251
+ }
252
+ result.push(child);
253
+ }
254
+ return result;
255
+ }
256
+ function walk(node) {
257
+ if (!node || typeof node !== 'object')
258
+ return node;
259
+ if (node.children && Array.isArray(node.children)) {
260
+ return { ...node, children: walkChildren(node.children) };
261
+ }
262
+ return node;
263
+ }
264
+ function restoreLeftoverSentinels(node) {
265
+ if (!node || typeof node !== 'object')
266
+ return node;
267
+ if (node.type === 'text' && typeof node.value === 'string' && node.value.includes(EMBED_MARKER_SENTINEL)) {
268
+ return { ...node, value: node.value.split(EMBED_MARKER_SENTINEL).join('!') };
269
+ }
270
+ if (node.children && Array.isArray(node.children)) {
271
+ return { ...node, children: node.children.map(restoreLeftoverSentinels) };
272
+ }
273
+ return node;
274
+ }
275
+ return restoreLeftoverSentinels(walk(root));
276
+ }
60
277
  /**
61
278
  * Combine two sets of replacements from sequential preprocessing steps.
62
279
  * The second set of replacements refers to positions in text AFTER the first set was applied.
@@ -434,8 +651,12 @@ function addCheckboxesToOrderedLists(root) {
434
651
  }
435
652
  export function parseMarkdown(text, _options = {}) {
436
653
  // Pre-process to handle edge cases
654
+ // Step 0: Swap a `!` immediately before `[[` for a sentinel so the
655
+ // wiki-link tokenizer gets a chance to fire (#119; see EMBED_MARKER_SENTINEL).
656
+ // Same-length, so it needs no offset-replacement tracking of its own.
657
+ const embedMarked = substituteEmbedMarker(text);
437
658
  // Step 1: Escape pipes inside wiki-links to protect from GFM table parsing
438
- const { text: pipesEscaped, replacements: pipeReplacements } = escapeWikiLinkPipes(text);
659
+ const { text: pipesEscaped, replacements: pipeReplacements } = escapeWikiLinkPipes(embedMarked);
439
660
  // Step 2: Normalize empty aliases (existing logic)
440
661
  const { text: normalizedText, replacements: aliasReplacements } = normalizeWikiLinks(pipesEscaped);
441
662
  // Combine replacements for offset mapping (pipeReplacements first, then adjust aliasReplacements)
@@ -458,6 +679,9 @@ export function parseMarkdown(text, _options = {}) {
458
679
  root = stripEscapedPipeFromWikiLinks(root);
459
680
  // Post-process: mark wiki-links that had empty aliases in the source
460
681
  root = markEmptyAliasWikiLinks(root);
682
+ // Post-process: retype a sentinel-preceded wiki-link with a blockId to a
683
+ // transclusion/embed node, and restore the literal `!` everywhere else (#119)
684
+ root = resolveWikiEmbeds(root);
461
685
  // Post-process: annotate emphasis/strong marker characters from original source
462
686
  root = annotateEmphasisMarkers(root, text, replacements);
463
687
  // Post-process: split out backslash-escaped punctuation so its escape can
@@ -214,7 +214,7 @@ function normalizeWikiLinkNodes(node) {
214
214
  if (!node || typeof node !== 'object') {
215
215
  return node;
216
216
  }
217
- if (node.type === 'wikiLink') {
217
+ if (node.type === 'wikiLink' || node.type === 'wikiEmbed') {
218
218
  const data = node.data && typeof node.data === 'object' ? { ...node.data } : {};
219
219
  return {
220
220
  ...node,
@@ -289,6 +289,27 @@ function widenTableDelimiterDashes(markdown) {
289
289
  }
290
290
  return lines.join('\n');
291
291
  }
292
+ /**
293
+ * Format the `[[target...]]` (or `[[target...|alias]]`) body shared by the
294
+ * `wikiLink` and `wikiEmbed` handlers below — everything between the double
295
+ * brackets, minus the brackets themselves and (for `wikiEmbed`) the leading
296
+ * `!`. `data.blockId`, when present, is re-appended as `#^blockId` (#119),
297
+ * mirroring the vendored `mdast-util-wiki-link/to-markdown.ts` — duplicated
298
+ * rather than shared because this handler otherwise diverges from the
299
+ * vendored one already (see the module comment above `stringifyMarkdown`).
300
+ */
301
+ function formatWikiLinkBody(node) {
302
+ const value = node.value ?? '';
303
+ const data = node.data && typeof node.data === 'object' ? node.data : {};
304
+ const blockId = typeof data.blockId === 'string' && data.blockId.length > 0 ? data.blockId : null;
305
+ const target = blockId ? `${value}#^${blockId}` : value;
306
+ const alias = typeof data.alias === 'string' ? data.alias : '';
307
+ const hasAlias = alias.length > 0 && alias !== value;
308
+ const emptyAlias = data._emptyAlias === true;
309
+ const divider = wikiLinkOptions.aliasDivider;
310
+ const aliasPart = hasAlias ? `${divider}${alias}` : emptyAlias ? divider : '';
311
+ return `${target}${aliasPart}`;
312
+ }
292
313
  export function stringifyMarkdown(root, options = {}) {
293
314
  // Pre-process: add checkbox text to ordered list items (GFM only outputs for unordered)
294
315
  let processedRoot = addCheckboxTextToOrderedLists(root);
@@ -327,15 +348,14 @@ export function stringifyMarkdown(root, options = {}) {
327
348
  return marker + content + marker;
328
349
  },
329
350
  escapedChar: (node) => `${FORCE_ESCAPE_PLACEHOLDER}${node.value}${FORCE_ESCAPE_PLACEHOLDER}`,
330
- wikiLink: (node) => {
331
- const value = node.value ?? '';
332
- const data = node.data && typeof node.data === 'object' ? node.data : {};
333
- const alias = typeof data.alias === 'string' ? data.alias : '';
334
- const hasAlias = alias.length > 0 && alias !== value;
335
- const emptyAlias = data._emptyAlias === true;
336
- const aliasPart = hasAlias ? `${wikiLinkOptions.aliasDivider}${alias}` : emptyAlias ? `${wikiLinkOptions.aliasDivider}` : '';
337
- return `[[${value}${aliasPart}]]`;
338
- },
351
+ wikiLink: (node) => `[[${formatWikiLinkBody(node)}]]`,
352
+ // Transclusion/embed (#119): the `!`-prefixed form of a block-scoped
353
+ // wiki-link. `formatWikiLinkBody` requires `data.blockId` be present
354
+ // to have been produced by `resolveWikiEmbeds` in parse.ts in the
355
+ // first place (FR-013 never lets a blockId-less node reach this
356
+ // type), but nothing here depends on that — this handler just emits
357
+ // whatever the node carries, `!` and all.
358
+ wikiEmbed: (node) => `![[${formatWikiLinkBody(node)}]]`,
339
359
  // Override mdast-util-definition-list's default (`:` + 3 spaces, i.e. a
340
360
  // 4-char marker matching a 4-space continuation indent) with the
341
361
  // single-space `: ` marker convention used throughout PHP Markdown
@@ -37,5 +37,22 @@ upstream pulled in.
37
37
 
38
38
  3. **Types.** The `any`-typed option and node shapes are given real types.
39
39
 
40
+ 4. **`#^blockId` fragment splitting (#119).** `from-markdown.ts` splits a
41
+ trailing `#^blockId` fragment off the target into `data.blockId` before
42
+ `pageResolver` runs, so `data.permalink`/`data.exists` are computed from
43
+ the file target alone; `to-markdown.ts` re-appends it on the way back out.
44
+ An ordinary heading anchor (`[[file#heading]]`, no caret) is untouched —
45
+ only the caret-prefixed Obsidian block-reference form matches. This is
46
+ deliberately scoped to the `wikiLink` node shape only: the `!`-prefixed
47
+ transclusion/embed form (`![[file#^id]]`) is *not* handled here — that
48
+ detection lives in `src/markdown/parse.ts`/`stringify.ts` (a same-length
49
+ sentinel substitution around the unvendored micromark tokenizer, since a
50
+ leading `!` before `[[` today makes the *whole* `![[...]]` span fall back
51
+ to inert text — see the tokenizer's `text: {33: ...}` image-label
52
+ precedence). A raw `./markdown`-subpath consumer therefore gets
53
+ `data.blockId` for free but not embed/transclusion detection; this
54
+ asymmetry is deliberate (documented in `docs/markdown-pipeline.md`) rather
55
+ than duplicating the sentinel machinery into a second vendored package.
56
+
40
57
  Parse and serialize behaviour is otherwise unchanged. Parity is covered by
41
58
  `src/markdown/__tests__/vendor-wiki-link.test.ts`.
@@ -8,7 +8,7 @@
8
8
  * package would silently get different — and wrong — wiki-link behaviour inside
9
9
  * markdown tables (#347). Vendoring makes the package self-contained.
10
10
  *
11
- * Four deliberate divergences from upstream:
11
+ * Five deliberate divergences from upstream:
12
12
  * 1. The trailing-backslash strip (see `exitWikiLink`), previously carried as
13
13
  * `liminis-app/patches/mdast-util-wiki-link@0.1.2.patch` in `verveguy/liminis`.
14
14
  * 2. Real types instead of `any` on the public option and node shapes.
@@ -16,6 +16,11 @@
16
16
  * closure variable shared by every handler in one `fromMarkdown()` call.
17
17
  * 4. `top()` asserts the stack frame is actually a wiki-link, so the
18
18
  * cannot-nest assumption fails loudly rather than corrupting a sibling node.
19
+ * 5. A trailing `#^blockId` fragment (Obsidian block-reference syntax, #119)
20
+ * is split off `value` into `data.blockId` before `pageResolver` runs, so
21
+ * `data.permalink`/`data.exists` are computed from the file target alone.
22
+ * An ordinary heading anchor (`#heading`, no caret) is untouched — this
23
+ * only matches the caret-prefixed block-id form.
19
24
  *
20
25
  * Nothing else about the parse behaviour changes: `value`, `data.alias`,
21
26
  * `data.permalink`, `data.exists`, `data.hName`, `data.hProperties` and
@@ -37,6 +42,8 @@ interface WikiLinkNode {
37
42
  alias: string | null;
38
43
  permalink: string | null;
39
44
  exists: boolean | null;
45
+ /** Obsidian-style `#^blockId` fragment split off `value`, if present (#119). */
46
+ blockId?: string | null;
40
47
  hName?: string;
41
48
  hProperties?: {
42
49
  className: string;
@@ -8,7 +8,7 @@
8
8
  * package would silently get different — and wrong — wiki-link behaviour inside
9
9
  * markdown tables (#347). Vendoring makes the package self-contained.
10
10
  *
11
- * Four deliberate divergences from upstream:
11
+ * Five deliberate divergences from upstream:
12
12
  * 1. The trailing-backslash strip (see `exitWikiLink`), previously carried as
13
13
  * `liminis-app/patches/mdast-util-wiki-link@0.1.2.patch` in `verveguy/liminis`.
14
14
  * 2. Real types instead of `any` on the public option and node shapes.
@@ -16,11 +16,24 @@
16
16
  * closure variable shared by every handler in one `fromMarkdown()` call.
17
17
  * 4. `top()` asserts the stack frame is actually a wiki-link, so the
18
18
  * cannot-nest assumption fails loudly rather than corrupting a sibling node.
19
+ * 5. A trailing `#^blockId` fragment (Obsidian block-reference syntax, #119)
20
+ * is split off `value` into `data.blockId` before `pageResolver` runs, so
21
+ * `data.permalink`/`data.exists` are computed from the file target alone.
22
+ * An ordinary heading anchor (`#heading`, no caret) is untouched — this
23
+ * only matches the caret-prefixed block-id form.
19
24
  *
20
25
  * Nothing else about the parse behaviour changes: `value`, `data.alias`,
21
26
  * `data.permalink`, `data.exists`, `data.hName`, `data.hProperties` and
22
27
  * `data.hChildren` are computed exactly as upstream computes them.
23
28
  */
29
+ /**
30
+ * Matches a trailing `#^blockId` fragment — the Obsidian block-reference
31
+ * convention — at the end of a wiki-link target. Deliberately narrower than a
32
+ * general `#fragment` match: an ordinary heading anchor (`[[file#heading]]`)
33
+ * has no caret and must keep flowing through the pre-existing same-file-anchor
34
+ * handling in the mapper untouched (FR-014).
35
+ */
36
+ const BLOCK_ID_PATTERN = /#\^([^\s\]#]+)$/;
24
37
  /**
25
38
  * The node currently being built, read off the compile stack.
26
39
  *
@@ -57,6 +70,7 @@ export function fromMarkdown(opts = {}) {
57
70
  alias: null,
58
71
  permalink: null,
59
72
  exists: null,
73
+ blockId: null,
60
74
  },
61
75
  };
62
76
  this.enter(node, token);
@@ -92,6 +106,17 @@ export function fromMarkdown(opts = {}) {
92
106
  wikiLink.value = wikiLink.value.slice(0, -1);
93
107
  }
94
108
  // --------------------------------------------------------------------------
109
+ // --- Liminis divergence from upstream (#119) ------------------------------
110
+ // Split a trailing `#^blockId` fragment off the target before resolving,
111
+ // so `data.permalink`/`data.exists` are derived from the file target
112
+ // alone and a block-scoped link (`[[file#^id]]`) resolves exactly like
113
+ // today's file-only `[[file]]` for existence-checking purposes.
114
+ const blockIdMatch = wikiLink.value ? BLOCK_ID_PATTERN.exec(wikiLink.value) : null;
115
+ if (blockIdMatch) {
116
+ wikiLink.data.blockId = blockIdMatch[1];
117
+ wikiLink.value = wikiLink.value.slice(0, blockIdMatch.index);
118
+ }
119
+ // --------------------------------------------------------------------------
95
120
  const pagePermalinks = pageResolver(wikiLink.value);
96
121
  const target = pagePermalinks.find((p) => permalinks.includes(p));
97
122
  const exists = target !== undefined;
@@ -2,13 +2,19 @@
2
2
  * Vendored from `mdast-util-wiki-link@0.1.2` (MIT, Mark Hudnall — see LICENSE
3
3
  * in this directory).
4
4
  *
5
- * One deliberate divergence from upstream: upstream imports
6
- * `mdast-util-to-markdown/lib/util/safe` from **v0.6.5** of that package, a
7
- * deep import into a v0 duplicate of the v2 `mdast-util-to-markdown` this
8
- * package already uses. That duplicate (and upstream's `@babel/runtime`
9
- * dependency) exists solely to serve this file. Here the same escaping is done
10
- * through v2's `state.safe()`, which is the supported API and produces the same
11
- * result for the `{ before: '[', after: ']' }` case this handler uses.
5
+ * Two deliberate divergences from upstream:
6
+ * 1. Upstream imports `mdast-util-to-markdown/lib/util/safe` from **v0.6.5**
7
+ * of that package, a deep import into a v0 duplicate of the v2
8
+ * `mdast-util-to-markdown` this package already uses. That duplicate
9
+ * (and upstream's `@babel/runtime` dependency) exists solely to serve
10
+ * this file. Here the same escaping is done through v2's `state.safe()`,
11
+ * which is the supported API and produces the same result for the
12
+ * `{ before: '[', after: ']' }` case this handler uses.
13
+ * 2. A `data.blockId` fragment (#119) is re-appended as `#^blockId` after
14
+ * the target, mirroring the split `from-markdown.ts` performs on the way
15
+ * in — so a raw `./markdown`-subpath consumer building `wikiLink` nodes
16
+ * by hand (not just `parseMarkdown`) gets byte-identical round-tripping
17
+ * of the block-id fragment.
12
18
  */
13
19
  import type { Options as ToMarkdownExtension } from 'mdast-util-to-markdown';
14
20
  export interface WikiLinkToMarkdownOptions {
@@ -4,6 +4,8 @@ export function toMarkdown(opts = {}) {
4
4
  const wikiLink = node;
5
5
  const exit = state.enter('wikiLink');
6
6
  const nodeValue = state.safe(wikiLink.value, { before: '[', after: ']' });
7
+ const blockId = wikiLink.data?.blockId;
8
+ const targetText = typeof blockId === 'string' && blockId.length > 0 ? `${nodeValue}#^${blockId}` : nodeValue;
7
9
  // Second deliberate divergence from upstream. Upstream passes the alias
8
10
  // through `safe()` unconditionally; `safe(undefined)` yields `''`, which is
9
11
  // then unequal to a non-empty target, so a node carrying *no* alias
@@ -17,7 +19,7 @@ export function toMarkdown(opts = {}) {
17
19
  const rawAlias = wikiLink.data?.alias;
18
20
  const hasAlias = typeof rawAlias === 'string' && rawAlias.length > 0;
19
21
  const nodeAlias = hasAlias ? state.safe(rawAlias, { before: '[', after: ']' }) : nodeValue;
20
- const value = nodeAlias !== nodeValue ? `[[${nodeValue}${aliasDivider}${nodeAlias}]]` : `[[${nodeValue}]]`;
22
+ const value = nodeAlias !== nodeValue ? `[[${targetText}${aliasDivider}${nodeAlias}]]` : `[[${targetText}]]`;
21
23
  exit();
22
24
  return value;
23
25
  };
package/dist/styles.css CHANGED
@@ -669,6 +669,54 @@ body {
669
669
  opacity: 0.8;
670
670
  }
671
671
 
672
+ /* Block transclusion (#119): live-rendered content from `![[file#^id]]`. Five
673
+ states — do not collapse "unresolved"/"circular"/"depth-exceeded" into one
674
+ style; each needs to read as a distinct failure mode at a glance. */
675
+ .editor-transclusion-content {
676
+ display: inline;
677
+ padding: 0 0.2em;
678
+ border-left: 2px solid var(--liminis-editor-border);
679
+ background: var(--liminis-editor-code-bg);
680
+ }
681
+
682
+ .editor-transclusion-loading {
683
+ color: var(--liminis-editor-foreground-muted);
684
+ font-style: italic;
685
+ }
686
+
687
+ .editor-transclusion-unresolved,
688
+ .editor-transclusion-circular,
689
+ .editor-transclusion-depth-exceeded {
690
+ color: var(--liminis-editor-errorForeground);
691
+ border: 1px dashed var(--liminis-editor-errorForeground);
692
+ border-radius: 3px;
693
+ padding: 0 0.3em;
694
+ font-style: italic;
695
+ font-size: 0.9em;
696
+ }
697
+
698
+ .editor-transclusion-paragraph {
699
+ display: inline;
700
+ }
701
+
702
+ .editor-transclusion-list,
703
+ .editor-transclusion-list-item {
704
+ display: inline;
705
+ }
706
+
707
+ .editor-transclusion-list-item + .editor-transclusion-list-item::before {
708
+ content: ' ';
709
+ }
710
+
711
+ .editor-transclusion-list-item input[type='checkbox'] {
712
+ margin-right: 0.2em;
713
+ vertical-align: middle;
714
+ }
715
+
716
+ .editor-transclusion-heading {
717
+ font-weight: 600;
718
+ }
719
+
672
720
  /* Tables */
673
721
  .editor-table {
674
722
  width: 100%;
package/dist/types.d.ts CHANGED
@@ -290,6 +290,7 @@ export type UIToHostMessage = {
290
290
  } | {
291
291
  type: 'OPEN_LINK';
292
292
  url: string;
293
+ blockId?: string;
293
294
  };
294
295
  export declare function validateHostToUIMessage(data: unknown): HostToUIMessage | null;
295
296
  export type BlockType = 'paragraph' | 'heading1' | 'heading2' | 'heading3' | 'bulletList' | 'numberedList' | 'todoList' | 'quote' | 'code' | 'divider' | 'image' | 'table' | 'toggle' | 'callout' | 'link';