rapier-markdown-kit 1.2.32 → 1.2.33

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
@@ -57,7 +57,7 @@ import {parseAssets} from 'rapier-markdown-kit/assets'; // the picture appendi
57
57
  ## Choose a Rapier workflow
58
58
 
59
59
  Rapier is a phone-first Markdown editor for notes, diagrams, drawing and watercolor painting. It runs offline
60
- as one HTML file on Android, Web and Windows.
60
+ as one HTML file on Android, Web, Windows and Linux.
61
61
 
62
62
  1. **Work together live.** Connect to `https://mcp.rapier.website/door/mcp`, then call `open` to share one document with the person.
63
63
  2. **Deliver an offline editor.** Run `npx rapier-html notes.md` to put the document and editor in one HTML file ([rapier-html](https://github.com/jackskip22/rapier-plugins/tree/main/npm/rapier-html)).
@@ -272,13 +272,167 @@ function textDeclarations(units) {
272
272
  return [...definitions, ...continuations, ...(text.match(/<!--[\s\S]*?(?:-->|$)/g) || [])].join('\0');
273
273
  }
274
274
 
275
+ function* textGroups(units, a, b) {
276
+ const changes = [...a.map(e => ({...e, side: 0})), ...b.map(e => ({...e, side: 1}))].sort((x, y) => x.start - y.start || x.end - y.end || x.side - y.side);
277
+ for (let i = 0; i < changes.length;) {
278
+ const group = [changes[i++]], start = group[0].start; let end = group[0].end;
279
+ while (i < changes.length && (changes[i].start < end || group.some(e => {
280
+ const next = changes[i];
281
+ const afterBlock = (edit, insert) => insert.start === insert.end && insert.start === edit.end && edit.start < edit.end && units.slice(edit.start, edit.end).every(unit => textEnvelopeAt(unit));
282
+ if (afterBlock(e, next) || afterBlock(next, e)) return false;
283
+ if (e.start === e.end && next.start <= e.start && e.start <= next.end) return true;
284
+ if (next.start === next.end && e.start <= next.start && next.start <= e.end) return true;
285
+ return next.start >= e.end && units.slice(e.start, e.end).every(textSpace) &&
286
+ units.slice(e.end, next.start).every(textSpace) && units.slice(next.start, next.end).every(textSpace);
287
+ }))) {
288
+ group.push(changes[i]); end = Math.max(end, changes[i++].end);
289
+ }
290
+ const before = units.slice(start, end).join('');
291
+ const version = side => {
292
+ let value = '', at = start;
293
+ for (const e of group.filter(e => e.side === side)) { value += units.slice(at, e.start).join('') + e.text; at = e.end; }
294
+ return value + units.slice(at, end).join('');
295
+ };
296
+ yield {start, end, before, av: version(0), bv: version(1)};
297
+ }
298
+ }
299
+
300
+ function textMoves(groups) {
301
+ const blank = value => !value.trim();
302
+ const side = (g, which) => which === 0 ? g.av : g.bv;
303
+ for (const g of groups) {
304
+ if (g.resolved !== undefined || blank(g.before)) continue;
305
+ for (const gone of [0, 1]) {
306
+ const removed = side(g, gone), other = side(g, 1 - gone);
307
+ if (!blank(removed) || other === g.before) continue;
308
+ if (other.split(g.before).length === 2) { g.resolved = other.replace(g.before, ''); break; }
309
+ const homes = groups.filter(h => h !== g && h.resolved === undefined && blank(h.before) && blank(side(h, 1 - gone)) && side(h, gone).trim() === g.before.trim());
310
+ if (homes.length === 1) {
311
+ const trailing = side(homes[0], gone).match(/\s*$/)[0], edited = other.replace(/\s*$/, '');
312
+ g.resolved = ''; homes[0].resolved = side(homes[0], gone).match(/^\s*/)[0] + edited + trailing; break;
313
+ }
314
+ }
315
+ }
316
+ return groups;
317
+ }
318
+
319
+ const textLines = text => text.match(/[^\r\n]*(?:\r\n|\r|\n)|[^\r\n]+$/g) || [];
320
+ const textWords = text => text.match(/\s+|\S+/g) || [];
321
+ const TEXT_ITEM = /^( {0,3})(?:[-+*]|\d{1,9}[.)])[ \t]+\S/;
322
+ const TEXT_TAGS = /^(tags|aliases):[ \t]*\[([^\[\]\r\n]*)\][ \t]*(\r\n|\r|\n|$)/;
323
+ function textCounts(text) {
324
+ const counts = new Map();
325
+ for (const word of text.match(/[\p{L}\p{N}_]+/gu) || []) counts.set(word, (counts.get(word) || 0) + 1);
326
+ return counts;
327
+ }
328
+ function textConserved(before, av, bv, merged) {
329
+ const [b, a, t, m] = [before, av, bv, merged].map(textCounts);
330
+ for (const word of new Set([...b.keys(), ...a.keys(), ...t.keys(), ...m.keys()])) {
331
+ const base = b.get(word) || 0, da = (a.get(word) || 0) - base, dt = (t.get(word) || 0) - base, got = (m.get(word) || 0) - base;
332
+ const both = [da + dt, da, dt];
333
+ if (da * dt > 0 ? got < Math.min(...both) || got > Math.max(...both) : got !== da + dt) return false;
334
+ }
335
+ return true;
336
+ }
337
+ function textRefine(before, av, bv, options, level = 'line') {
338
+ const merged = textRefineLevel(before, av, bv, options, level);
339
+ return merged !== null && textConserved(before, av, bv, merged) ? merged : null;
340
+ }
341
+ function textRefineLevel(before, av, bv, options, level) {
342
+ const split = level === 'line' ? textLines : textWords;
343
+ const units = split(before), oursUnits = split(av), theirsUnits = split(bv);
344
+ const reordered = textPermuted(units, oursUnits, theirsUnits);
345
+ if (reordered !== null) return reordered;
346
+ const a = textEdits(units, oursUnits), b = textEdits(units, theirsUnits);
347
+ if (!a || !b) return null;
348
+ let out = '', pos = 0;
349
+ for (const g of textGroups(units, a, b)) {
350
+ out += units.slice(pos, g.start).join('');
351
+ if (g.av === g.bv) out += g.av;
352
+ else if (g.av === g.before) out += g.bv;
353
+ else if (g.bv === g.before) out += g.av;
354
+ else {
355
+ const fine = level === 'line' ? textRefineGroup(g, options) : null;
356
+ if (fine === null) return null;
357
+ out += fine;
358
+ }
359
+ pos = g.end;
360
+ }
361
+ return out + units.slice(pos).join('');
362
+ }
363
+ function textPermuted(units, ours, theirs) {
364
+ const solid = list => list.filter(unit => !textSpace(unit));
365
+ const baseSolid = solid(units), known = new Map(baseSolid.map((unit, k) => [unit, k]));
366
+ if (known.size !== baseSolid.length) return null;
367
+ const reorders = side => { const x = solid(side); return x.length === baseSolid.length && x.every(unit => known.has(unit)) && new Set(x).size === x.length && side.join('') !== units.join(''); };
368
+ const [first, second] = [reorders(ours), reorders(theirs)];
369
+ if (first === second) return null;
370
+ const [moved, changed] = first ? [ours, theirs] : [theirs, ours];
371
+ const edits = textEdits(units, changed);
372
+ if (!edits) return null;
373
+ const replace = new Map(), after = new Map();
374
+ for (const e of edits) {
375
+ const inner = []; for (let i = e.start; i < e.end; i++) if (!textSpace(units[i])) inner.push(i);
376
+ if (!inner.length) {
377
+ if (!e.text.trim()) continue;
378
+ let k = -1; for (let i = e.start - 1; i >= 0; i--) if (!textSpace(units[i])) { k = known.get(units[i]); break; }
379
+ const gap = e.start > 0 && textSpace(units[e.start - 1]);
380
+ after.set(k, [...(after.get(k) || []), {text: e.text, gap}]);
381
+ } else if (inner.length === 1) {
382
+ if (!e.text.trim()) replace.set(known.get(units[inner[0]]), units.slice(e.start, e.end).some(textSpace) ? ' ' : '');
383
+ else if (units.slice(e.start, e.end).some(textSpace)) return null;
384
+ else replace.set(known.get(units[inner[0]]), e.text);
385
+ } else return null;
386
+ }
387
+ const out = [], place = (k, from) => {
388
+ const rows = after.get(k) || [];
389
+ return rows.filter(row => row.gap === from).map(row => row.text);
390
+ };
391
+ out.push(...place(-1, false));
392
+ for (let i = 0; i < moved.length; i++) {
393
+ const unit = moved[i];
394
+ if (textSpace(unit)) { out.push(unit); continue; }
395
+ const k = known.get(unit);
396
+ if (replace.get(k) === '' || replace.get(k) === ' ') {
397
+ if (replace.get(k) === ' ') { if (i + 1 < moved.length && textSpace(moved[i + 1])) i++; else if (out.length && textSpace(out[out.length - 1])) out.pop(); }
398
+ } else out.push(replace.has(k) ? replace.get(k) : unit);
399
+ out.push(...place(k, false));
400
+ if (after.get(k)?.some(row => row.gap)) { if (i + 1 < moved.length && textSpace(moved[i + 1])) out.push(moved[++i]); out.push(...place(k, true)); }
401
+ }
402
+ return out.join('');
403
+ }
404
+
405
+ function textRefineGroup({before, av, bv}, options) {
406
+ const [first, second] = textDeviceOptions(options), ids = first < second ? [0, 1] : [1, 0];
407
+ const added = (side, base) => side.startsWith(base) ? side.slice(base.length) : null;
408
+ const oursAdded = added(av, before), theirsAdded = added(bv, before);
409
+ if (oursAdded !== null && theirsAdded !== null && textLines(oursAdded + theirsAdded).every(line => TEXT_ITEM.test(line))) {
410
+ const tail = value => value && !/[\r\n]$/.test(value) ? value + (before.includes('\r\n') ? '\r\n' : '\n') : value;
411
+ const parts = [tail(oursAdded), tail(theirsAdded)];
412
+ const last = tail(before) === before ? '' : (before.includes('\r\n') ? '\r\n' : '\n');
413
+ return before + last + parts[ids[0]] + parts[ids[1]];
414
+ }
415
+ const lines = textLines(before);
416
+ if (lines.length === 1) {
417
+ const tags = TEXT_TAGS.exec(before), x = TEXT_TAGS.exec(av), y = TEXT_TAGS.exec(bv);
418
+ if (tags && x && y && x[1] === tags[1] && y[1] === tags[1]) {
419
+ const list = row => row[2].split(',').map(item => item.trim()).filter(Boolean);
420
+ const base = list(tags), ours = list(x), theirs = list(y);
421
+ const kept = base.filter(item => ours.includes(item) && theirs.includes(item));
422
+ const added = [ours, theirs].map(list => list.filter(item => !base.includes(item)));
423
+ const joined = [...new Set([...kept, ...added[ids[0]], ...added[ids[1]]])];
424
+ return tags[1] + ': [' + joined.join(', ') + ']' + tags[3];
425
+ }
426
+ }
427
+ return textRefineLevel(before, av, bv, options, 'word');
428
+ }
275
429
  export function mergeText(base, ours, theirs, options = {}) {
276
430
  canonicalNote(ours); canonicalNote(theirs); if (base !== null) canonicalNote(base);
277
431
  const unchanged = value => ({text: value, clean: !value.includes(TEXT_CONFLICT), conflicts: value.includes(TEXT_CONFLICT) ? [{kind: 'existing-conflict', advisory: true}] : []});
278
432
  if (ours === theirs) return unchanged(ours);
279
433
  if (base === ours) return unchanged(theirs);
280
434
  if (base === theirs) return unchanged(ours);
281
- const conflicts = []; let text = '';
435
+ const conflicts = []; let text = '', a, b;
282
436
  const conflict = (before, a, b, kind) => {
283
437
  const ab = inspectTextConflicts(a), bb = inspectTextConflicts(b);
284
438
  const au = textUnits(a), bu = textUnits(b); let first = 0, last = 0;
@@ -316,38 +470,21 @@ export function mergeText(base, ours, theirs, options = {}) {
316
470
  if (base === null) conflict(null, ours, theirs, 'no-ancestor');
317
471
  else if (textDeclarations(oursUnits) !== declared || textDeclarations(theirsUnits) !== declared) conflict(base, ours, theirs, 'document-bindings');
318
472
  else {
319
- const a = textEdits(units, oursUnits), b = textEdits(units, theirsUnits);
320
- if (!a || !b) conflict(base, ours, theirs, 'ambiguous-alignment-or-budget');
473
+ const reordered = textPermuted(units, oursUnits, theirsUnits);
474
+ if (reordered !== null && textConserved(base, ours, theirs, reordered)) text += reordered;
475
+ else if (!(a = textEdits(units, oursUnits)) || !(b = textEdits(units, theirsUnits))) conflict(base, ours, theirs, 'ambiguous-alignment-or-budget');
321
476
  else {
322
- const changes = [...a.map(e => ({...e, side: 0})), ...b.map(e => ({...e, side: 1}))].sort((x, y) => x.start - y.start || x.end - y.end || x.side - y.side);
323
477
  let pos = 0;
324
- for (let i = 0; i < changes.length;) {
325
- const group = [changes[i++]], start = group[0].start; let end = group[0].end;
326
- while (i < changes.length && (changes[i].start < end || group.some(e => {
327
- const next = changes[i];
328
- const afterBlock = (edit, insert) => insert.start === insert.end && insert.start === edit.end && edit.start < edit.end && units.slice(edit.start, edit.end).every(unit => textEnvelopeAt(unit));
329
- if (afterBlock(e, next) || afterBlock(next, e)) return false;
330
- if (e.start === e.end && next.start <= e.start && e.start <= next.end) return true;
331
- if (next.start === next.end && e.start <= next.start && next.start <= e.end) return true;
332
- return next.start >= e.end && units.slice(e.start, e.end).every(textSpace) &&
333
- units.slice(e.end, next.start).every(textSpace) && units.slice(next.start, next.end).every(textSpace);
334
- }))) {
335
- group.push(changes[i]); end = Math.max(end, changes[i++].end);
336
- }
478
+ for (const {start, end, before, av, bv, resolved} of textMoves([...textGroups(units, a, b)])) {
337
479
  text += units.slice(pos, start).join('');
338
- const before = units.slice(start, end).join('');
339
- const version = side => {
340
- let value = '', at = start;
341
- for (const e of group.filter(e => e.side === side)) { value += units.slice(at, e.start).join('') + e.text; at = e.end; }
342
- return value + units.slice(at, end).join('');
343
- };
344
- const av = version(0), bv = version(1);
345
- if (av === bv) text += av;
480
+ if (resolved !== undefined) text += resolved;
481
+ else if (av === bv) text += av;
346
482
  else if (av === before) text += bv;
347
483
  else if (bv === before) text += av;
348
484
  else {
349
485
  const tick = end === start + 1 ? textTick(before, av, bv) : null;
350
- if (tick !== null) text += tick; else conflict(before, av, bv, 'text');
486
+ const fine = tick === null ? textRefine(before, av, bv, options) : null;
487
+ if (tick !== null) text += tick; else if (fine !== null) text += fine; else conflict(before, av, bv, 'text');
351
488
  }
352
489
  pos = end;
353
490
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rapier-markdown-kit",
3
- "version": "1.2.32",
3
+ "version": "1.2.33",
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",
package/style.css CHANGED
@@ -418,6 +418,7 @@
418
418
  min-height:var(--md-ol-circle);
419
419
  padding-top:min(0.15 * var(--md-unit), 0.6 * max(0px, var(--md-ol-circle) - var(--md-item-line)));
420
420
  }
421
+ .md-render{--md-ol-dot-line:radial-gradient(circle closest-side, var(--md-color-line) calc(100% - 0.5px), transparent 100%) center top / 3px 6px repeat-y}
421
422
  .md-render ol>li::before{
422
423
  content:counter(rapier-ol);
423
424
  position:absolute;
@@ -441,16 +442,21 @@
441
442
 
442
443
  inset-inline-start:calc(calc(-1.75 * var(--md-unit)) - 1.5px + var(--md-marker, 0px));
443
444
  /* From just below this circle into the next one: at least to the next item's top (--md-item-gap is the gap between
444
- items the spacing settings give), and at least a quarter line, which the next circle covers. */
445
+ items the spacing settings give), and at least a quarter line, which the next circle covers. Where the circles
446
+ nearly touch (one-line items) there is no room for dots, so none are drawn: the line shows only when it spans at
447
+ least half a line (100% is this item's height). */
445
448
  top:calc(var(--md-ol-top) + 1.0875 * var(--md-ol-circle));
446
- bottom:calc(-1 * max(var(--md-item-gap, 0px), var(--md-line) / 4));
447
- border-inline-start:3px dotted var(--md-color-line);
449
+ --md-ol-dots:calc(100% - var(--md-ol-top) - 1.0875 * var(--md-ol-circle) + max(var(--md-item-gap, 0px), var(--md-line) / 4));
450
+ height:clamp(0px, (var(--md-ol-dots) - var(--md-line) / 2) * 9999, var(--md-ol-dots));
451
+ /* Round dots, 3px across every 6px: a dotted border draws squares in Chrome. */
452
+ width:3px;
453
+ background:var(--md-ol-dot-line);
448
454
  z-index:0;
449
455
  }
450
456
  /* A heading between two parts of one numbered list (the second part's start follows the first's last number;
451
457
  _rapierMarkListContinuations marks them): the heading stands on the margin the item text stands on, and the dotted
452
458
  line runs on, out of the first part's last item, down the heading's own height, to the next circle. */
453
- .md-render ol[data-rapier-list-continues]>li:last-child::after{bottom:calc(-1 * var(--md-list-heading-before,var(--md-gap-heading-before)))}
459
+ .md-render ol[data-rapier-list-continues]>li:last-child::after{height:auto;bottom:calc(-1 * var(--md-list-heading-before,var(--md-gap-heading-before)))}
454
460
  /* A wrapped picture set the next circle aside: the dotted line ends with this item, as at a list's last. */
455
461
  .md-render ol>li[data-rapier-wrap-step]::after{content:none!important}
456
462
  .md-render :is(h1,h2,h3,h4,h5,h6)[data-rapier-list-through]{position:relative;padding-inline-start:calc(2.75 * var(--md-unit))}
@@ -460,7 +466,8 @@
460
466
  inset-inline-start:calc(calc(1 * var(--md-unit)) - 1.5px);
461
467
  top:0;
462
468
  bottom:calc(-1 * var(--md-list-heading-after,var(--md-gap-heading-after)));
463
- border-inline-start:3px dotted var(--md-color-line);
469
+ width:3px;
470
+ background:var(--md-ol-dot-line);
464
471
  z-index:0;
465
472
  }
466
473
  .md-render :is(h1,h2,h3,h4,h5,h6)[data-rapier-list-through]:has(+:is(h1,h2,h3,h4,h5,h6)){--md-list-heading-after:var(--md-gap-heading-before)}