@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.
- package/README.md +77 -8
- package/dist/app/editor/LinkClickPlugin.js +5 -1
- package/dist/app/editor/WikiLinkExistencePlugin.d.ts +13 -3
- package/dist/app/editor/WikiLinkExistencePlugin.js +91 -32
- package/dist/app/editor/editorNodes.js +2 -1
- package/dist/app/editor/nodes/CustomLinkNode.d.ts +14 -0
- package/dist/app/editor/nodes/CustomLinkNode.js +37 -0
- package/dist/app/editor/nodes/TransclusionComponent.d.ts +8 -0
- package/dist/app/editor/nodes/TransclusionComponent.js +65 -0
- package/dist/app/editor/nodes/TransclusionNode.d.ts +48 -0
- package/dist/app/editor/nodes/TransclusionNode.js +141 -0
- package/dist/app/editor/nodes/index.d.ts +2 -0
- package/dist/app/editor/nodes/index.js +1 -0
- package/dist/app/editor/nodes/transclusion-loading.d.ts +27 -0
- package/dist/app/editor/nodes/transclusion-loading.js +29 -0
- package/dist/app/editor/nodes/transclusion-render.d.ts +49 -0
- package/dist/app/editor/nodes/transclusion-render.js +186 -0
- package/dist/app/mapper/lexicalToMdast.js +55 -6
- package/dist/app/mapper/mdastToLexical.js +67 -1
- package/dist/host/defaults.js +1 -0
- package/dist/host/messages.d.ts +7 -1
- package/dist/host/messages.js +3 -3
- package/dist/host/types.d.ts +14 -1
- package/dist/markdown/parse.js +225 -1
- package/dist/markdown/stringify.js +30 -10
- package/dist/markdown/vendor/mdast-util-wiki-link/README.md +17 -0
- package/dist/markdown/vendor/mdast-util-wiki-link/from-markdown.d.ts +8 -1
- package/dist/markdown/vendor/mdast-util-wiki-link/from-markdown.js +26 -1
- package/dist/markdown/vendor/mdast-util-wiki-link/to-markdown.d.ts +13 -7
- package/dist/markdown/vendor/mdast-util-wiki-link/to-markdown.js +3 -1
- package/dist/styles.css +48 -0
- package/dist/types.d.ts +1 -0
- package/docs/architecture.md +80 -0
- package/docs/decisions/adr-119-block-transclusion.md +247 -0
- package/docs/diagrams/architecture-1-dark.svg +1 -0
- package/docs/diagrams/architecture-1.svg +1 -0
- package/docs/diagrams/architecture-2-dark.svg +1 -0
- package/docs/diagrams/architecture-2.svg +1 -0
- package/docs/editor-api.md +1 -0
- package/docs/markdown-pipeline.md +110 -6
- package/package.json +6 -4
package/dist/host/defaults.js
CHANGED
package/dist/host/messages.d.ts
CHANGED
|
@@ -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
|
-
|
|
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. */
|
package/dist/host/messages.js
CHANGED
|
@@ -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
|
}
|
package/dist/host/types.d.ts
CHANGED
|
@@ -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'>;
|
package/dist/markdown/parse.js
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
6
|
-
* `mdast-util-to-markdown/lib/util/safe` from **v0.6.5**
|
|
7
|
-
* deep import into a v0 duplicate of the v2
|
|
8
|
-
* package already uses. That duplicate
|
|
9
|
-
* dependency) exists solely to serve
|
|
10
|
-
* through v2's `state.safe()`,
|
|
11
|
-
*
|
|
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 ? `[[${
|
|
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';
|