rapier-markdown-kit 1.2.20 → 1.2.22

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 CHANGED
@@ -44,7 +44,7 @@ const edited = replaceLedgerText(base, 'Draft, revised\n', {actor: {kind: 'human
44
44
  console.log(merge(edited, base).text, authorship(edited).runs);
45
45
  ```
46
46
 
47
- TypeScript declarations ship for `markdown-it`, `will`, `rapier-ledger`, `ledger/carried` and `pdf`.
47
+ TypeScript declarations ship for `markdown-it`, `parsed`, `will`, `rapier-ledger`, `ledger/carried` and `pdf`.
48
48
 
49
49
  ```js
50
50
  import {parseLayout} from 'rapier-markdown-kit/layout'; // the md-layout comment a picture carries
@@ -165,6 +165,7 @@ family name share the host's font namespace. Print retains its existing factory
165
165
  | Import | What it is |
166
166
  | --- | --- |
167
167
  | `rapier-markdown-kit/markdown-it` | `md.use(rapierMarkdown)`: the reference style's markup in markdown-it, in one line. |
168
+ | `rapier-markdown-kit/parsed` | A frozen, serializable view of completed Markdown tokens, bound to source, revision and grammar. |
168
169
  | `rapier-markdown-kit/layout` | The `md-layout:v1` comment a picture carries: parse it, validate it, write it back. |
169
170
  | `rapier-markdown-kit/marks` | Unified typography values, span writer and parser installer; preserved color/font comments, ink and page breaks. |
170
171
  | `rapier-markdown-kit/model` | Occupancy profiles and the line planner: the wrapping itself. |
@@ -180,6 +181,51 @@ family name share the host's font namespace. Print retains its existing factory
180
181
  The canonical inline form is ordinary HTML inside Markdown. CSS support varies; the host's font metrics still
181
182
  determine line layout (below).
182
183
 
184
+ ## Read a parsed document
185
+
186
+ `projectMarkdown` copies the tokens from your completed parse into JSON data. Pass the exact source and revision
187
+ captured for that parse, and a grammar identity that includes its parser, plugins and options. The function does not
188
+ parse again. Project before renderer hooks mutate the tokens; the resulting snapshot is detached from those later
189
+ changes. Import this optional subpath only where you need it; the package's main entry does not import it.
190
+
191
+ ```js
192
+ import MarkdownIt from 'markdown-it';
193
+ import {rapierMarkdown} from 'rapier-markdown-kit/markdown-it';
194
+ import {projectMarkdown, projectionMatches} from 'rapier-markdown-kit/parsed';
195
+
196
+ const source = '# Plan\n\nShips **Friday**.\n';
197
+ const revision = 7;
198
+ const options = {html: true};
199
+ const grammar = {id: 'my-app-markdown-1', options, plugins: ['rapierMarkdown']};
200
+ const md = new MarkdownIt(options).use(rapierMarkdown);
201
+ const env = {};
202
+ const tokens = md.parse(source, env); // The same tokens can feed your renderer.
203
+ const parsed = projectMarkdown({source, revision, grammar, tokens, env, complete: true});
204
+ const json = JSON.stringify(parsed);
205
+ console.log(projectionMatches(parsed, {source, revision, grammar})); // true
206
+ ```
207
+
208
+ Change the grammar identity when its implementation changes. Grammar options must be JSON data; describe a custom
209
+ rule or renderer by its identity, not by a function. If your parse excludes opening front matter, pass `bodyOffset`
210
+ from that split and keep `source` as the full original document. Token line maps must remain relative to the parsed body.
211
+
212
+ The source binding uses SHA-256 over its UTF-16LE code units, including BOM, original line endings and isolated
213
+ surrogates. `span` ranges are half-open UTF-16 offsets into that source. A span with `scope: 'lines'` covers the token's
214
+ source lines, including container prefixes and the final line ending. Inline delimiters and generated tokens without
215
+ recorded ranges have `span: null`; `coverage` and `omissions` report these missing ranges. Never infer an exact inline
216
+ edit boundary from a containing line span.
217
+
218
+ Recorded `meta.mdLayoutSource` positions are translated from the parser's normalized body into the same original
219
+ UTF-16 source coordinates and labeled `unit: 'utf16'`. Their marker text and any reason for an unmapped target remain
220
+ with the layout record. Other JSON metadata retains the grammar's values; internal probe identities are not exported.
221
+
222
+ The projection includes canonical token content, attributes, JSON metadata and reference, abbreviation and footnote
223
+ definitions. It omits original source gaps, interpreted front matter, rendered HTML and geometry, decoded pictures,
224
+ final heading IDs, and evaluated Will permissions. Non-JSON token metadata becomes `null` with a counted omission.
225
+ Token strings remain document data for your existing renderer and sanitizer. `projectionMatches` checks currentness;
226
+ it does not authenticate a projection received from another party or authorize an edit. Source transactions, Will,
227
+ Undo and Save stay with their existing owners.
228
+
183
229
  ## Refined paragraphs
184
230
 
185
231
  The existing comment expresses the paragraph's intent without storing font-dependent line breaks:
@@ -2,12 +2,14 @@
2
2
  import {parseLayout, imageStyle} from './layout.mjs';
3
3
  import {typography, isPageBreakBlock} from './marks.mjs';
4
4
  import {installMarkdownImages} from './assets.mjs';
5
+ import {tuneMarkdownEngine} from '../spec/md-engine.mjs';
5
6
 
6
7
  const PAGE_BREAK = '<div class="rapier-page-break" data-md-break="page" role="separator" aria-label="Page break"></div>\n';
7
8
 
8
9
  export function rapierMarkdown(md) {
9
10
  if (md.__rapierMarkdownInstalled) return md;
10
11
  md.__rapierMarkdownInstalled = true;
12
+ tuneMarkdownEngine(md);
11
13
  typography.install(installMarkdownImages(md));
12
14
  md.renderer.rules.table_open = (tokens, index, options, env, renderer) =>
13
15
  '<div class="table-scroll-wrap">\n' + renderer.renderToken(tokens, index, options);
@@ -15,10 +17,13 @@ export function rapierMarkdown(md) {
15
17
  renderer.renderToken(tokens, index, options) + '</div>\n';
16
18
  md.core.ruler.after('inline', 'rapier-layout', state => {
17
19
  for (let i = 1; i < state.tokens.length; i++) {
18
- const inline = state.tokens[i], owner = state.tokens[i - 1];
19
- if (inline.type !== 'inline' || !['paragraph_open', 'heading_open'].includes(owner.type)) continue;
20
- const children = inline.children, marker = children.at(-1);
21
- const layout = marker?.type === 'html_inline' ? parseLayout(marker.content) : null;
20
+ const inline = state.tokens[i];
21
+ if (inline.type !== 'inline') continue;
22
+ const children = inline.children, marker = children[children.length - 1];
23
+ if (marker?.type !== 'html_inline') continue;
24
+ const owner = state.tokens[i - 1];
25
+ if (owner.type !== 'paragraph_open' && owner.type !== 'heading_open') continue;
26
+ const layout = parseLayout(marker.content);
22
27
  if (!layout) continue;
23
28
  children.pop();
24
29
  if (layout.align) owner.attrSet('data-md-align', layout.align);
@@ -0,0 +1,78 @@
1
+ // SPDX-License-Identifier: MIT
2
+ export type Json = null | string | number | boolean | readonly Json[] | {readonly [key: string]: Json};
3
+ export type Revision = string | number;
4
+ export interface GrammarIdentity {
5
+ readonly id: string;
6
+ readonly options: {readonly [key: string]: Json};
7
+ readonly [key: string]: Json;
8
+ }
9
+ export interface Utf16Span { readonly start: number; readonly end: number; readonly unit: 'utf16'; }
10
+ export interface TokenSpan extends Utf16Span { readonly scope: 'lines'; }
11
+ export interface MarkdownTokenInput {
12
+ readonly type: string;
13
+ readonly tag?: string;
14
+ readonly nesting: -1 | 0 | 1;
15
+ readonly level?: number;
16
+ readonly block?: boolean;
17
+ readonly hidden?: boolean;
18
+ readonly content?: string;
19
+ readonly markup?: string;
20
+ readonly info?: string;
21
+ readonly attrs?: unknown;
22
+ readonly meta?: unknown;
23
+ readonly map?: readonly [number, number] | null;
24
+ readonly children?: readonly MarkdownTokenInput[] | null;
25
+ }
26
+ export interface ParsedMarkdown {
27
+ /** The exact full document, before normalization or front matter removal. */
28
+ readonly source: string;
29
+ readonly revision: Revision;
30
+ /** Includes every option or plugin setting that can change this parse's meaning. */
31
+ readonly grammar: GrammarIdentity;
32
+ readonly complete: true;
33
+ /** The shared front matter owner's body offset; zero if the parser read the full source.
34
+ * Token maps and recorded layout target offsets must be relative to that parsed body. */
35
+ readonly bodyOffset?: number;
36
+ readonly tokens: readonly MarkdownTokenInput[];
37
+ readonly env?: {
38
+ readonly references?: unknown;
39
+ readonly abbreviations?: unknown;
40
+ readonly footnotes?: {readonly list?: readonly {readonly label?: string; readonly count?: number}[]};
41
+ };
42
+ }
43
+ export interface ProjectedToken {
44
+ readonly type: string;
45
+ readonly tag: string;
46
+ readonly nesting: -1 | 0 | 1;
47
+ readonly level: number | null;
48
+ readonly block: boolean;
49
+ readonly hidden: boolean;
50
+ readonly content: string;
51
+ readonly markup: string;
52
+ readonly info: string;
53
+ readonly attrs: Json;
54
+ readonly meta: Json;
55
+ readonly span: TokenSpan | null;
56
+ readonly children: readonly ProjectedToken[] | null;
57
+ }
58
+ export interface MarkdownProjection {
59
+ readonly format: 'rapier-parsed/1';
60
+ readonly source: {
61
+ readonly revision: Revision;
62
+ readonly chars: number;
63
+ readonly hash: {readonly algorithm: 'sha256'; readonly encoding: 'utf16le'; readonly value: string};
64
+ };
65
+ readonly grammar: GrammarIdentity;
66
+ readonly body: Utf16Span;
67
+ readonly frontmatter: Utf16Span | null;
68
+ readonly tokens: readonly ProjectedToken[];
69
+ readonly definitions: Json;
70
+ readonly coverage: {readonly tokens: 'complete'; readonly tokenCount: number; readonly spans: 'complete' | 'partial'};
71
+ readonly omissions: readonly {readonly domain: string; readonly reason: string; readonly count?: number}[];
72
+ }
73
+ /** Project one completed parse. Does not parse again, normalize source, render or admit edits. */
74
+ export function projectMarkdown(parsed: ParsedMarkdown): MarkdownProjection;
75
+ /** Checks source, revision and grammar binding; does not authenticate an externally supplied projection. */
76
+ export function projectionMatches(projection: MarkdownProjection, current: {
77
+ readonly source: string; readonly revision: Revision; readonly grammar: GrammarIdentity;
78
+ }): boolean;
@@ -0,0 +1,157 @@
1
+ // SPDX-License-Identifier: MIT
2
+ import {sha256State} from './ledger/hash.mjs';
3
+ import {splitOpeningFrontmatter} from '../spec/frontmatter.mjs';
4
+
5
+ export function projectMarkdown(parsed) {
6
+ const {source, revision, grammar, tokens, env = {}, bodyOffset = 0, complete} = parsed || {};
7
+ requireSource(source, revision);
8
+ if (complete !== true || !Array.isArray(tokens)) throw new TypeError('A completed Markdown parse is required');
9
+ const front = splitOpeningFrontmatter(source);
10
+ if (!Number.isSafeInteger(bodyOffset) || bodyOffset < 0 || bodyOffset > source.length ||
11
+ bodyOffset !== 0 && bodyOffset !== front.bodyOffset) throw new TypeError('The parse must cover the document body');
12
+ const grammarCopy = copyGrammar(grammar), counts = new Map();
13
+ const omitted = (domain, reason) => {
14
+ const key = domain + ':' + reason;
15
+ const row = counts.get(key) || {domain, reason, count: 0};
16
+ row.count++; counts.set(key, row);
17
+ };
18
+ const lines = [bodyOffset], normalizedLines = [0];
19
+ let collapsed = 0;
20
+ for (const match of source.slice(bodyOffset).matchAll(/\r\n?|\n/g)) {
21
+ const end = match.index + match[0].length;
22
+ collapsed += match[0].length - 1;
23
+ lines.push(bodyOffset + end); normalizedLines.push(end - collapsed);
24
+ }
25
+ const originalOffset = offset => {
26
+ if (!Number.isSafeInteger(offset) || offset < 0 || offset > source.length - bodyOffset - collapsed)
27
+ throw new TypeError('A Markdown layout target has an invalid source offset');
28
+ let low = 0, high = normalizedLines.length;
29
+ while (low + 1 < high) {
30
+ const middle = (low + high) >>> 1;
31
+ if (normalizedLines[middle] <= offset) low = middle; else high = middle;
32
+ }
33
+ return lines[low] + offset - normalizedLines[low];
34
+ };
35
+ const metadata = token => {
36
+ const meta = copyJSON(token.meta ?? null, () => omitted('metadata', 'not-json'));
37
+ const target = meta?.mdLayoutSource;
38
+ if (!target) return meta;
39
+ const marker = target.marker ? Object.freeze({...target.marker, start: originalOffset(target.marker.start),
40
+ end: originalOffset(target.marker.end), unit: 'utf16'}) : null;
41
+ return Object.freeze({...meta, mdLayoutSource: Object.freeze({...target, start: originalOffset(target.start),
42
+ end: originalOffset(target.end), insert: target.insert == null ? null : originalOffset(target.insert), marker, unit: 'utf16'})});
43
+ };
44
+ const span = token => {
45
+ const map = token.map;
46
+ if (map == null) {
47
+ omitted('spans', token.block ? 'block-range-not-retained' : 'inline-range-not-retained');
48
+ return null;
49
+ }
50
+ if (!Array.isArray(map) || map.length !== 2 || map.some(at => !Number.isSafeInteger(at) || at < 0) ||
51
+ map[0] > map[1] || map[0] >= lines.length || map[1] > lines.length)
52
+ throw new TypeError('A Markdown token has an invalid line range');
53
+ if (map[0] === map[1]) { omitted('spans', token.block ? 'block-range-not-retained' : 'inline-range-not-retained'); return null; }
54
+ return Object.freeze({start: lines[map[0]], end: lines[map[1]] ?? source.length, unit: 'utf16', scope: 'lines'});
55
+ };
56
+ let count = 0;
57
+ const copyTokens = input => Object.freeze(input.map(token => {
58
+ if (!token || typeof token.type !== 'string' || ![-1, 0, 1].includes(token.nesting) ||
59
+ token.children != null && !Array.isArray(token.children)) throw new TypeError('Invalid Markdown token');
60
+ count++;
61
+ const scalar = key => {
62
+ if (token[key] == null) return '';
63
+ if (typeof token[key] !== 'string') throw new TypeError('Invalid Markdown token ' + key);
64
+ return token[key];
65
+ };
66
+ return Object.freeze({
67
+ type: token.type, tag: scalar('tag'), nesting: token.nesting,
68
+ level: Number.isSafeInteger(token.level) && token.level >= 0 ? token.level : null,
69
+ block: token.block === true, hidden: token.hidden === true,
70
+ content: scalar('content'), markup: scalar('markup'), info: scalar('info'),
71
+ attrs: copyJSON(token.attrs ?? null, () => omitted('attributes', 'not-json')),
72
+ meta: metadata(token),
73
+ span: span(token), children: token.children ? copyTokens(token.children) : null,
74
+ });
75
+ }));
76
+ const projected = copyTokens(tokens);
77
+ const definitions = copyJSON({
78
+ references: env.references || {}, abbreviations: env.abbreviations || {},
79
+ footnotes: (env.footnotes?.list || []).map(note => ({label: note.label ?? null, count: note.count ?? 0})),
80
+ }, () => omitted('definitions', 'not-json'));
81
+ const omissions = [
82
+ ...counts.values(),
83
+ {domain: 'source', reason: 'original-text-and-unrepresented-gaps-are-not-included'},
84
+ {domain: 'frontmatter', reason: 'metadata-is-not-interpreted'},
85
+ {domain: 'rendering', reason: 'html-dom-heading-ids-layout-and-decoded-assets-are-not-included'},
86
+ {domain: 'authority', reason: 'will-permissions-and-edit-admission-are-not-evaluated'},
87
+ {domain: 'environment', reason: 'only-reference-abbreviation-and-footnote-definitions-are-included'},
88
+ ];
89
+ return Object.freeze({
90
+ format: 'rapier-parsed/1',
91
+ source: Object.freeze({revision, chars: source.length,
92
+ hash: Object.freeze({algorithm: 'sha256', encoding: 'utf16le', value: sourceHash(source)})}),
93
+ grammar: grammarCopy,
94
+ body: Object.freeze({start: bodyOffset, end: source.length, unit: 'utf16'}),
95
+ frontmatter: bodyOffset ? Object.freeze({start: 0, end: bodyOffset, unit: 'utf16'}) : null,
96
+ tokens: projected, definitions,
97
+ coverage: Object.freeze({tokens: 'complete', tokenCount: count,
98
+ spans: counts.has('spans:block-range-not-retained') || counts.has('spans:inline-range-not-retained') ? 'partial' : 'complete'}),
99
+ omissions: Object.freeze(omissions.map(Object.freeze)),
100
+ });
101
+ }
102
+
103
+ export function projectionMatches(projection, {source, revision, grammar} = {}) {
104
+ requireSource(source, revision);
105
+ const expected = copyGrammar(grammar);
106
+ if (!(projection?.format === 'rapier-parsed/1' && projection.source?.revision === revision &&
107
+ projection.source.chars === source.length && projection.source.hash?.algorithm === 'sha256' &&
108
+ projection.source.hash.encoding === 'utf16le' && projection.source.hash.value === sourceHash(source))) return false;
109
+ try { return JSON.stringify(copyGrammar(projection.grammar)) === JSON.stringify(expected); }
110
+ catch (_) { return false; }
111
+ }
112
+
113
+ function requireSource(source, revision) {
114
+ if (typeof source !== 'string' || !(typeof revision === 'string' && revision.length ||
115
+ Number.isSafeInteger(revision) && revision >= 0)) throw new TypeError('Exact source and its revision are required');
116
+ }
117
+
118
+ function copyGrammar(grammar) {
119
+ const copy = copyJSON(grammar, () => { throw new TypeError('Grammar identity and options must be JSON data'); });
120
+ if (!copy || typeof copy.id !== 'string' || !copy.id || !copy.options ||
121
+ typeof copy.options !== 'object' || Array.isArray(copy.options))
122
+ throw new TypeError('A grammar identity and its options are required');
123
+ return copy;
124
+ }
125
+
126
+ function copyJSON(value, omit, ancestors = new Set()) {
127
+ if (typeof value === 'number' && Number.isFinite(value)) return value === 0 ? 0 : value;
128
+ if (value === null || typeof value === 'string' || typeof value === 'boolean') return value;
129
+ if (!value || typeof value !== 'object' || ancestors.has(value) ||
130
+ !Array.isArray(value) && ![Object.prototype, null].includes(Object.getPrototypeOf(value))) { omit(); return null; }
131
+ ancestors.add(value);
132
+ let result;
133
+ if (Array.isArray(value)) result = Array.from({length: value.length}, (_, index) => {
134
+ const property = Object.getOwnPropertyDescriptor(value, index);
135
+ if (!property || !Object.hasOwn(property, 'value')) { omit(); return null; }
136
+ return copyJSON(property.value, omit, ancestors);
137
+ });
138
+ else result = Object.fromEntries(Object.keys(value).sort().map(key => {
139
+ const property = Object.getOwnPropertyDescriptor(value, key);
140
+ if (!Object.hasOwn(property, 'value')) { omit(); return [key, null]; }
141
+ return [key, copyJSON(property.value, omit, ancestors)];
142
+ }));
143
+ ancestors.delete(value);
144
+ return Object.freeze(result);
145
+ }
146
+
147
+ function sourceHash(source) {
148
+ const hash = sha256State(), bytes = new Uint8Array(32768);
149
+ for (let at = 0; at < source.length;) {
150
+ let used = 0;
151
+ while (at < source.length && used < bytes.length) {
152
+ const code = source.charCodeAt(at++); bytes[used++] = code & 255; bytes[used++] = code >>> 8;
153
+ }
154
+ hash.update(bytes.subarray(0, used));
155
+ }
156
+ return hash.finish();
157
+ }
@@ -90,9 +90,11 @@ function initMarkdownIt() {
90
90
  md.renderer.rules.softbreak = () =>
91
91
  sourceToken('\n', '\u2060', 'rapier-source-token--softbreak');
92
92
  md.core.ruler.before('text_join', 'rapier_escape_token', state => {
93
- for (const token of state.tokens) {
93
+ for (let at = 0; at < state.tokens.length; at++) {
94
+ const token = state.tokens[at];
94
95
  if (token.type !== 'inline' || !token.children) continue;
95
- for (const child of token.children) {
96
+ for (let next = 0; next < token.children.length; next++) {
97
+ const child = token.children[next];
96
98
  if (child.type === 'text_special' && child.info === 'escape' && child.content.length === 1 && !/[\\*`[\]_]/.test(child.content)) child.type = 'rapier_escape';
97
99
  }
98
100
  }
@@ -251,7 +253,8 @@ function initMarkdownIt() {
251
253
 
252
254
  md.core.ruler.push('rapier_setext_rule', function (state) {
253
255
  let lines = null;
254
- for (const token of state.tokens) {
256
+ for (let at = 0; at < state.tokens.length; at++) {
257
+ const token = state.tokens[at];
255
258
  if (token.type !== 'heading_open') continue;
256
259
  if (token.markup !== '=' && token.markup !== '-') continue;
257
260
  if (!token.map) continue;
@@ -489,14 +492,14 @@ function _rapierInstallSemanticProbe(instance) {
489
492
  const nextId = (targetEnv.__rapierInlineProbeSeq || 0) + 1;
490
493
  targetEnv.__rapierInlineProbeSeq = nextId;
491
494
  targetEnv.__rapierInlineProbeId = nextId;
492
- try {
493
- Object.defineProperty(outTokens, '_rapierInlineProbeId', { value: nextId, configurable: true });
494
- } catch (_) { outTokens._rapierInlineProbeId = nextId; }
495
+ const candidateStart = targetEnv.__rapierInlineCandidates?.length || 0;
495
496
  try {
496
497
  const result = originalParse.call(this, source, parser, targetEnv, outTokens);
497
- for (const token of outTokens) {
498
- try { Object.defineProperty(token, '_rapierInlineProbeId', { value: nextId, configurable: true }); }
499
- catch (_) { token._rapierInlineProbeId = nextId; }
498
+ if ((targetEnv.__rapierInlineCandidates?.length || 0) > candidateStart) {
499
+ Object.defineProperty(outTokens, '_rapierInlineProbeId', { value: nextId, configurable: true });
500
+ for (const token of outTokens) {
501
+ Object.defineProperty(token, '_rapierInlineProbeId', { value: nextId, configurable: true });
502
+ }
500
503
  }
501
504
  return result;
502
505
  } finally { targetEnv.__rapierInlineProbeId = priorId; }
@@ -504,6 +507,8 @@ function _rapierInstallSemanticProbe(instance) {
504
507
 
505
508
  instance.inline.ruler.before('escape', 'rapier_semantic_probe', function (state, silent) {
506
509
  if (silent || !state || !state.env) return false;
510
+ const opener = state.src.charCodeAt(state.pos);
511
+ if (opener !== 0x5b && opener !== 0x21) return false;
507
512
  const inlineId = Number(state.env.__rapierInlineProbeId || 0);
508
513
  if (!inlineId) return false;
509
514
  const src = state.src;
@@ -561,6 +566,8 @@ function _rapierInstallSemanticProbe(instance) {
561
566
  if (src.charCodeAt(after) === 0x28) {
562
567
  let destinationPos = after + 1;
563
568
  while (destinationPos < max && /[ \t\n]/.test(src.charAt(destinationPos))) destinationPos++;
569
+ const first = src.charCodeAt(destinationPos);
570
+ if (first !== 0x23 && first !== 0x3c && first !== 0x5c && first !== 0x26) return false;
564
571
  const destination = state.md.helpers.parseLinkDestination(src, destinationPos, max);
565
572
  if (!destination || !destination.ok || !String(destination.str || '').startsWith('#')) return false;
566
573
  const rawDestination = String(destination.str || '');
@@ -23,6 +23,7 @@ export function installMarkdownImages(md) {
23
23
  const reference = md.block.ruler.__rules__.find(row => row.name === 'reference').fn;
24
24
  md.block.ruler.at('reference', (state, start, end, silent) => {
25
25
  if (silent) return reference(state, start, end, silent);
26
+ if (state.src.charCodeAt(state.bMarks[start] + state.tShift[start]) !== 0x5B) return false;
26
27
  const saved = state.env.references, local = Object.create(null);
27
28
  state.env.references = local;
28
29
  let accepted;
@@ -40,12 +41,12 @@ export function installMarkdownImages(md) {
40
41
  md.inline.ruler.at('image', (state, silent) => {
41
42
  const start = state.pos;
42
43
  if (state.src[start] !== '!' || state.src[start + 1] !== '[') return false;
43
- const close = state.md.helpers.parseLinkLabel(state, start + 1, false);
44
44
  const before = state.tokens.length;
45
45
  if (!image(state, silent)) return false;
46
46
  if (!silent && state.tokens.length > before) {
47
47
  const token = state.tokens[state.tokens.length - 1], source = state.src.slice(start, state.pos);
48
48
  if (token.type === 'image') {
49
+ const close = state.md.helpers.parseLinkLabel(state, start + 1, false);
49
50
  let reference = null;
50
51
  if (close >= 0 && state.src[close + 1] !== '(') {
51
52
  const explicit = state.src[close + 1] === '[' ? state.src.slice(close + 2, state.pos - 1) : '';
@@ -0,0 +1,90 @@
1
+ // SPDX-License-Identifier: MIT
2
+
3
+ const NEWLINES = /\r\n?/g, NULLS = /\0/g;
4
+
5
+ function lineScanner(Base, Token) {
6
+ function StateBlock(src, md, env, tokens) {
7
+ this.bMarks = []; this.eMarks = []; this.tShift = []; this.sCount = []; this.bsCount = [];
8
+ this.blkIndent = 0; this.line = 0; this.lineMax = 0; this.tight = false; this.listIndent = -1;
9
+ this.parentType = 'root'; this.level = 0; this.Token = Token;
10
+ this.src = src; this.md = md; this.env = env; this.tokens = tokens;
11
+ const length = src.length;
12
+ for (let start = 0; start < length;) {
13
+ let at = start, indent = 0, offset = 0;
14
+ for (; at < length; at++) {
15
+ const code = src.charCodeAt(at);
16
+ if (code === 0x20) offset++;
17
+ else if (code === 0x09) offset += 4 - offset % 4;
18
+ else break;
19
+ indent++;
20
+ }
21
+ if (at === length) break;
22
+ let end = src.indexOf('\n', at);
23
+ if (end < 0) end = length;
24
+ this.bMarks.push(start); this.eMarks.push(end); this.tShift.push(indent); this.sCount.push(offset); this.bsCount.push(0);
25
+ start = end + 1;
26
+ }
27
+ this.bMarks.push(length); this.eMarks.push(length); this.tShift.push(0); this.sCount.push(0); this.bsCount.push(0);
28
+ this.lineMax = this.bMarks.length - 1;
29
+ }
30
+ StateBlock.prototype = Base.prototype;
31
+ return StateBlock;
32
+ }
33
+
34
+ function backticks(state, silent) {
35
+ const start = state.pos, src = state.src;
36
+ if (src.charCodeAt(start) !== 0x60) return false;
37
+ const max = state.posMax;
38
+ let pos = start + 1;
39
+ while (pos < max && src.charCodeAt(pos) === 0x60) pos++;
40
+ const marker = src.slice(start, pos), length = marker.length, runs = state.backticks;
41
+ const scanned = state.backticksScanned;
42
+ if (!scanned || pos < scanned || (runs[length] ?? -1) >= pos) {
43
+ let matchStart, matchEnd = pos;
44
+ while ((matchStart = src.indexOf('`', matchEnd)) !== -1 && matchStart < max) {
45
+ matchEnd = matchStart + 1;
46
+ while (src.charCodeAt(matchEnd) === 0x60) matchEnd++;
47
+ if (matchEnd > max) break;
48
+ const run = matchEnd - matchStart;
49
+ if (run === length) {
50
+ if (!silent) {
51
+ const token = state.push('code_inline', 'code', 0);
52
+ token.markup = marker;
53
+ let content = src.slice(pos, matchStart).replace(/\n/g, ' ');
54
+ if (content.startsWith(' ') && content.endsWith(' ') && /[^ ]/.test(content)) content = content.slice(1, -1);
55
+ token.content = content;
56
+ }
57
+ state.pos = matchEnd;
58
+ return true;
59
+ }
60
+ if (!(runs[run] >= matchStart)) runs[run] = matchStart;
61
+ }
62
+ if (matchStart === -1 && (!scanned || pos < scanned)) state.backticksScanned = pos;
63
+ }
64
+ if (!silent) state.pending += marker;
65
+ state.pos = pos;
66
+ return true;
67
+ }
68
+
69
+ const CLEAN_WEB_LINK = /^https?:\/\/([a-z0-9-]{1,63}(?:\.[a-z0-9-]{1,63})*)(?:\/[A-Za-z0-9\-._~/]*)?$/;
70
+ function cleanWebLink(url) {
71
+ const match = CLEAN_WEB_LINK.exec(url);
72
+ return match !== null && match[1].length <= 255;
73
+ }
74
+
75
+ export function tuneMarkdownEngine(md) {
76
+ if (md.__rapierEngineTuned) return md;
77
+ md.__rapierEngineTuned = true;
78
+ md.core.ruler.at('normalize', state => {
79
+ let src = state.src;
80
+ if (src.indexOf('\r') >= 0) src = src.replace(NEWLINES, '\n');
81
+ if (src.indexOf('\0') >= 0) src = src.replace(NULLS, '�');
82
+ state.src = src;
83
+ });
84
+ const BaseBlock = md.block.State;
85
+ md.block.State = lineScanner(BaseBlock, new BaseBlock('', md, {}, []).Token);
86
+ md.inline.ruler.at('backticks', backticks);
87
+ const normalizeLink = md.normalizeLink;
88
+ md.normalizeLink = url => cleanWebLink(url) ? url : normalizeLink.call(md, url);
89
+ return md;
90
+ }
@@ -251,8 +251,9 @@ export function createTypography({normalizeFontFamily, fontFamilyCss, FONT_STACK
251
251
  if (md.__rapierTypography) return md;
252
252
  md.__rapierTypography = true;
253
253
  md.core.ruler.after('linkify', 'rapier-typography', state => {
254
- for (const block of state.tokens) {
255
- if (block.type !== 'inline' || !block.children) continue;
254
+ for (let at = 0; at < state.tokens.length; at++) {
255
+ const block = state.tokens[at];
256
+ if (block.type !== 'inline' || !block.children || block.content.indexOf('<') < 0) continue;
256
257
  const stack = []; let depth = 0, overflow = 0;
257
258
  for (const token of block.children) {
258
259
  if (token.type !== 'html_inline') { depth += token.nesting || 0; continue; }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rapier-markdown-kit",
3
- "version": "1.2.20",
3
+ "version": "1.2.22",
4
4
  "description": "Read, render and write Self-contained Markdown: one .md file that carries its pictures, drawings and layout, and that any Markdown app can read. HTML renderer, stylesheet, text layout, Will/1 agent permissions, and an edit history with authorship and offline merge. MIT, no dependencies.",
5
5
  "keywords": [
6
6
  "markdown",
@@ -44,6 +44,10 @@
44
44
  "types": "./dist/kit/markdown-it.d.mts",
45
45
  "default": "./dist/kit/markdown-it.mjs"
46
46
  },
47
+ "./parsed": {
48
+ "types": "./dist/kit/parsed.d.mts",
49
+ "default": "./dist/kit/parsed.mjs"
50
+ },
47
51
  "./layout": "./dist/kit/layout.mjs",
48
52
  "./marks": "./dist/kit/marks.mjs",
49
53
  "./model": "./dist/kit/model.mjs",