@elabs-ai/components-editor 4.0.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/LICENSE +21 -0
- package/README.md +149 -0
- package/dist/chunk-LBC5VJBD.js +3748 -0
- package/dist/chunk-LBC5VJBD.js.map +1 -0
- package/dist/index.css +233 -0
- package/dist/index.css.map +1 -0
- package/dist/index.d.ts +199 -0
- package/dist/index.js +362 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/monaco-environment.d.ts +2 -0
- package/dist/lib/monaco-environment.js +44 -0
- package/dist/lib/monaco-environment.js.map +1 -0
- package/dist/markdown/frontmatter.d.ts +11 -0
- package/dist/markdown/frontmatter.js +33 -0
- package/dist/markdown/frontmatter.js.map +1 -0
- package/dist/markdown/index.css +233 -0
- package/dist/markdown/index.css.map +1 -0
- package/dist/markdown/index.d.ts +1486 -0
- package/dist/markdown/index.js +4882 -0
- package/dist/markdown/index.js.map +1 -0
- package/dist/markdown/parse.d.ts +25 -0
- package/dist/markdown/parse.js +14 -0
- package/dist/markdown/parse.js.map +1 -0
- package/dist/markdown-editor-DfBZibAn.d.ts +702 -0
- package/package.json +101 -0
- package/src/ai-objects/ai-objects-integration.test.tsx +128 -0
- package/src/ai-objects/ai-objects.stories.tsx +233 -0
- package/src/ai-objects/ai-objects.test.tsx +205 -0
- package/src/ai-objects/decision-card.tsx +178 -0
- package/src/ai-objects/directives.ts +136 -0
- package/src/ai-objects/entity.tsx +181 -0
- package/src/ai-objects/index.ts +34 -0
- package/src/ai-objects/knowledge-card.tsx +143 -0
- package/src/calc-block/calc-block.stories.tsx +297 -0
- package/src/calc-block/calc-block.test.tsx +164 -0
- package/src/calc-block/calc-block.tsx +398 -0
- package/src/calc-block/calc-editor-monaco.ts +214 -0
- package/src/calc-block/calc-editor-prose.ts +128 -0
- package/src/calc-block/calc-editor.css +80 -0
- package/src/calc-block/calc-editor.stories.tsx +272 -0
- package/src/calc-block/calc-editor.test.ts +228 -0
- package/src/calc-block/calc-editor.ts +270 -0
- package/src/calc-block/calc-inline.stories.tsx +44 -0
- package/src/calc-block/calc-inline.test.tsx +44 -0
- package/src/calc-block/calc-inline.tsx +75 -0
- package/src/calc-block/index.ts +20 -0
- package/src/calc-block/types.ts +164 -0
- package/src/code-editor/code-editor.stories.tsx +218 -0
- package/src/code-editor/code-editor.test.tsx +134 -0
- package/src/code-editor/code-editor.tsx +250 -0
- package/src/code-editor/index.ts +6 -0
- package/src/code-workspace/code-workspace.stories.tsx +51 -0
- package/src/code-workspace/code-workspace.test.tsx +146 -0
- package/src/code-workspace/code-workspace.tsx +253 -0
- package/src/code-workspace/index.ts +6 -0
- package/src/copy-button/copy-button.tsx +51 -0
- package/src/copy-button/index.ts +1 -0
- package/src/css.d.ts +3 -0
- package/src/diff-editor/diff-editor.stories.tsx +44 -0
- package/src/diff-editor/diff-editor.test.tsx +68 -0
- package/src/diff-editor/diff-editor.tsx +151 -0
- package/src/diff-editor/index.ts +1 -0
- package/src/editor-context-menu/editor-context-menu.tsx +120 -0
- package/src/editor-context-menu/index.ts +1 -0
- package/src/editor-toolbar/editor-toolbar.tsx +83 -0
- package/src/editor-toolbar/index.ts +1 -0
- package/src/index.ts +48 -0
- package/src/lib/editor-completions-monaco.test.ts +206 -0
- package/src/lib/editor-completions-monaco.ts +127 -0
- package/src/lib/editor-completions.test.ts +156 -0
- package/src/lib/editor-completions.ts +153 -0
- package/src/lib/editor-content-access-prose.test.ts +411 -0
- package/src/lib/editor-content-access-prose.ts +172 -0
- package/src/lib/editor-content-access.test.ts +182 -0
- package/src/lib/editor-content-access.ts +109 -0
- package/src/lib/languages.ts +32 -0
- package/src/lib/markdown/diff.test.ts +80 -0
- package/src/lib/markdown/diff.ts +194 -0
- package/src/lib/markdown/directives.ts +342 -0
- package/src/lib/markdown/frontmatter.ts +50 -0
- package/src/lib/markdown/markdown-scale.test.ts +65 -0
- package/src/lib/markdown/markdown-scale.ts +62 -0
- package/src/lib/markdown/merge.test.ts +64 -0
- package/src/lib/markdown/merge.ts +158 -0
- package/src/lib/markdown/slugify.test.ts +156 -0
- package/src/lib/markdown/slugify.ts +47 -0
- package/src/lib/monaco-environment.ts +103 -0
- package/src/lib/monaco-theme-bridge.ts +293 -0
- package/src/lib/use-data-theme.ts +61 -0
- package/src/markdown/frontmatter.ts +14 -0
- package/src/markdown/index.ts +323 -0
- package/src/markdown/parse.test.ts +60 -0
- package/src/markdown/parse.ts +38 -0
- package/src/markdown-academic/citations.test.ts +77 -0
- package/src/markdown-academic/citations.tsx +442 -0
- package/src/markdown-academic/footnotes.tsx +235 -0
- package/src/markdown-academic/index.ts +32 -0
- package/src/markdown-academic/math.tsx +163 -0
- package/src/markdown-academic/toc.tsx +88 -0
- package/src/markdown-editor/completions/completions-menu.tsx +96 -0
- package/src/markdown-editor/completions/completions-prose.test.ts +356 -0
- package/src/markdown-editor/completions/completions-prose.ts +313 -0
- package/src/markdown-editor/completions/completions-widget.tsx +75 -0
- package/src/markdown-editor/completions/index.ts +37 -0
- package/src/markdown-editor/directive-nodes.ts +151 -0
- package/src/markdown-editor/directive-views.test.tsx +189 -0
- package/src/markdown-editor/directive-views.tsx +795 -0
- package/src/markdown-editor/exit-keymap.test.ts +88 -0
- package/src/markdown-editor/exit-keymap.ts +87 -0
- package/src/markdown-editor/index.ts +6 -0
- package/src/markdown-editor/markdown-editor.css +250 -0
- package/src/markdown-editor/markdown-editor.directives.test.tsx +105 -0
- package/src/markdown-editor/markdown-editor.fill.test.ts +34 -0
- package/src/markdown-editor/markdown-editor.paste-embed.test.tsx +297 -0
- package/src/markdown-editor/markdown-editor.stories.tsx +560 -0
- package/src/markdown-editor/markdown-editor.strictmode.test.tsx +38 -0
- package/src/markdown-editor/markdown-editor.table.test.tsx +287 -0
- package/src/markdown-editor/markdown-editor.tsx +560 -0
- package/src/markdown-editor/milkdown-react/editor.tsx +33 -0
- package/src/markdown-editor/milkdown-react/index.ts +8 -0
- package/src/markdown-editor/milkdown-react/types.ts +27 -0
- package/src/markdown-editor/milkdown-react/use-editor.ts +27 -0
- package/src/markdown-editor/milkdown-react/use-get-editor.ts +56 -0
- package/src/markdown-editor/milkdown-react/use-instance.ts +23 -0
- package/src/markdown-editor/paste-embed.ts +355 -0
- package/src/markdown-editor/slash/brand-slash-commands.test.ts +194 -0
- package/src/markdown-editor/slash/brand-slash-commands.ts +324 -0
- package/src/markdown-editor/slash/brand-slash-plugin.test.ts +254 -0
- package/src/markdown-editor/slash/brand-slash-plugin.ts +385 -0
- package/src/markdown-editor/slash/index.ts +107 -0
- package/src/markdown-editor/slash/insert-directive.test.ts +263 -0
- package/src/markdown-editor/slash/insert-directive.ts +345 -0
- package/src/markdown-editor/slash/monaco-slash-menu.test.tsx +198 -0
- package/src/markdown-editor/slash/monaco-slash-menu.tsx +356 -0
- package/src/markdown-editor/slash/shortcut-monaco.ts +69 -0
- package/src/markdown-editor/slash/shortcut.test.ts +157 -0
- package/src/markdown-editor/slash/shortcut.ts +57 -0
- package/src/markdown-editor/slash/slash-menu.stories.tsx +139 -0
- package/src/markdown-editor/slash/slash-menu.tsx +115 -0
- package/src/markdown-editor/slash/slash-scroll.test.tsx +52 -0
- package/src/markdown-editor/slash/slash-widget.tsx +97 -0
- package/src/markdown-editor/slash/source-slash-trigger.test.ts +42 -0
- package/src/markdown-editor/slash/source-slash-trigger.ts +46 -0
- package/src/markdown-editor/table-view.tsx +249 -0
- package/src/markdown-iteration/directive.tsx +92 -0
- package/src/markdown-iteration/edit-context.ts +56 -0
- package/src/markdown-iteration/index.ts +24 -0
- package/src/markdown-iteration/iteration-block.test.tsx +138 -0
- package/src/markdown-iteration/iteration-builder-dialog.stories.tsx +293 -0
- package/src/markdown-iteration/iteration-builder-dialog.tsx +312 -0
- package/src/markdown-iteration/iteration-builder.test.ts +252 -0
- package/src/markdown-iteration/iteration-builder.ts +303 -0
- package/src/markdown-iteration/iteration.test.ts +56 -0
- package/src/markdown-iteration/iteration.tsx +333 -0
- package/src/markdown-iteration/template-dialog.stories.tsx +63 -0
- package/src/markdown-iteration/template-dialog.test.tsx +104 -0
- package/src/markdown-iteration/template-dialog.tsx +116 -0
- package/src/markdown-outline/document-outline.stories.tsx +76 -0
- package/src/markdown-outline/document-outline.tsx +83 -0
- package/src/markdown-outline/index.ts +2 -0
- package/src/markdown-outline/markdown-outline.test.ts +49 -0
- package/src/markdown-outline/markdown-outline.ts +60 -0
- package/src/markdown-preview/code-fence.tsx +193 -0
- package/src/markdown-preview/index.ts +8 -0
- package/src/markdown-preview/markdown-preview-academic.stories.tsx +203 -0
- package/src/markdown-preview/markdown-preview-academic.test.tsx +257 -0
- package/src/markdown-preview/markdown-preview-iteration-calc.test.tsx +71 -0
- package/src/markdown-preview/markdown-preview-iteration-nested.test.tsx +58 -0
- package/src/markdown-preview/markdown-preview-iteration.stories.tsx +187 -0
- package/src/markdown-preview/markdown-preview-iteration.test.tsx +90 -0
- package/src/markdown-preview/markdown-preview-linking.test.tsx +61 -0
- package/src/markdown-preview/markdown-preview-transclusion.test.tsx +33 -0
- package/src/markdown-preview/markdown-preview.stories.tsx +108 -0
- package/src/markdown-preview/markdown-preview.test.tsx +411 -0
- package/src/markdown-preview/markdown-preview.tsx +1541 -0
- package/src/markdown-toolbar/index.ts +8 -0
- package/src/markdown-toolbar/markdown-commands.test.ts +84 -0
- package/src/markdown-toolbar/markdown-commands.ts +104 -0
- package/src/markdown-toolbar/markdown-toolbar.stories.tsx +56 -0
- package/src/markdown-toolbar/markdown-toolbar.tsx +263 -0
- package/src/markdown-workspace/focus-writing.test.ts +52 -0
- package/src/markdown-workspace/focus-writing.ts +38 -0
- package/src/markdown-workspace/index.ts +6 -0
- package/src/markdown-workspace/markdown-workspace.stories.tsx +688 -0
- package/src/markdown-workspace/markdown-workspace.test.tsx +326 -0
- package/src/markdown-workspace/markdown-workspace.tsx +787 -0
- package/src/mermaid-diagram/index.ts +1 -0
- package/src/mermaid-diagram/mermaid-diagram.stories.tsx +42 -0
- package/src/mermaid-diagram/mermaid-diagram.test.tsx +86 -0
- package/src/mermaid-diagram/mermaid-diagram.tsx +318 -0
- package/src/mermaid-diagram/mermaid-viewer.test.tsx +89 -0
- package/src/mermaid-diagram/mermaid-viewer.tsx +367 -0
- package/src/mermaid-diagram/real-parse.test.ts +42 -0
- package/src/mermaid-diagram/remediate.test.ts +60 -0
- package/src/mermaid-diagram/remediate.ts +124 -0
- package/src/mermaid-workspace/index.ts +1 -0
- package/src/mermaid-workspace/mermaid-workspace.stories.tsx +28 -0
- package/src/mermaid-workspace/mermaid-workspace.tsx +71 -0
- package/src/metric-block/index.ts +1 -0
- package/src/metric-block/metric-block.stories.tsx +40 -0
- package/src/metric-block/metric-block.tsx +14 -0
- package/src/monaco-workers.d.ts +10 -0
- package/src/prose/index.ts +14 -0
- package/src/prose/prose.stories.tsx +44 -0
- package/src/prose/prose.test.tsx +44 -0
- package/src/prose/prose.tsx +21 -0
- package/src/timeline/index.ts +18 -0
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared remark pipeline for the brand markdown dialect.
|
|
3
|
+
*
|
|
4
|
+
* `remark-directive` parses generic container/leaf/text directives (`:::name`,
|
|
5
|
+
* `::name`, `:name`). `remarkBrandDirectives` then rewrites the four brand blocks
|
|
6
|
+
* — :::card / :::callout / ::metric / :::timeline — into a single `<brand-directive>`
|
|
7
|
+
* element carrying a JSON `data-brand` payload, which the preview's components map
|
|
8
|
+
* turns into real @brand components. Unknown directive names are preserved + flagged
|
|
9
|
+
* so the preview can surface an "unknown block" error instead of silently dropping.
|
|
10
|
+
*
|
|
11
|
+
* One JSON attribute (not many) keeps it robust against Streamdown's HTML
|
|
12
|
+
* sanitization (we only need to allow a single attribute through). The SAME plugin
|
|
13
|
+
* array is fed to Streamdown (preview) and can be fed to Milkdown via `$remark`
|
|
14
|
+
* (editor), so both sides parse `:::card` identically.
|
|
15
|
+
*/
|
|
16
|
+
import type { ReactNode } from "react";
|
|
17
|
+
import remarkDirective from "remark-directive";
|
|
18
|
+
import type { PluggableList } from "unified";
|
|
19
|
+
import { visit } from "unist-util-visit";
|
|
20
|
+
|
|
21
|
+
export const BRAND_DIRECTIVES = ["card", "callout", "metric", "timeline"] as const;
|
|
22
|
+
export type BrandDirectiveName = (typeof BRAND_DIRECTIVES)[number];
|
|
23
|
+
|
|
24
|
+
/** Custom element BLOCK / LEAF directives are rewritten to. */
|
|
25
|
+
export const BRAND_DIRECTIVE_TAG = "brand-directive";
|
|
26
|
+
/**
|
|
27
|
+
* Custom element INLINE (text) directives are rewritten to. A SEPARATE tag (not
|
|
28
|
+
* {@link BRAND_DIRECTIVE_TAG}) so the preview can render inline directives without
|
|
29
|
+
* the block `<div>` wrappers the annotation/search layer adds to block tags —
|
|
30
|
+
* keeping `:entity[name]` inside the text flow.
|
|
31
|
+
*/
|
|
32
|
+
export const BRAND_DIRECTIVE_INLINE_TAG = "brand-directive-inline";
|
|
33
|
+
/** Rendered HTML attribute carrying the JSON payload (allow-list this in Streamdown). */
|
|
34
|
+
export const BRAND_DIRECTIVE_ATTR = "data-brand";
|
|
35
|
+
/**
|
|
36
|
+
* hast PROPERTY name (camelCase) for {@link BRAND_DIRECTIVE_ATTR}. This — not the
|
|
37
|
+
* rendered `data-brand` — is what Streamdown's `allowedTags` must list to let the
|
|
38
|
+
* payload survive sanitization.
|
|
39
|
+
*/
|
|
40
|
+
export const BRAND_DIRECTIVE_PROP = "dataBrand";
|
|
41
|
+
|
|
42
|
+
/** Which directive syntax produced a node: `:::block`, `::leaf`, or `:inline`. */
|
|
43
|
+
export type MarkdownDirectiveKind = "container" | "leaf" | "inline";
|
|
44
|
+
|
|
45
|
+
export interface BrandDirectivePayload {
|
|
46
|
+
name: string;
|
|
47
|
+
known: boolean;
|
|
48
|
+
kind: MarkdownDirectiveKind;
|
|
49
|
+
attributes: Record<string, string>;
|
|
50
|
+
items?: { title: string; status: string }[];
|
|
51
|
+
/**
|
|
52
|
+
* RAW label source for inline/leaf directives (`:calc[85 * 32]` → `85 * 32`).
|
|
53
|
+
* Captured from source positions so markdown-significant characters in the
|
|
54
|
+
* label (`*`, `_`, `[`) survive intact — the rendered children would mangle
|
|
55
|
+
* them. Renderers that need the verbatim expression (calc) read this.
|
|
56
|
+
*/
|
|
57
|
+
label?: string;
|
|
58
|
+
/**
|
|
59
|
+
* RAW container body source (the markdown between the `:::name` fences),
|
|
60
|
+
* captured only for the directive names passed in `rawBodyNames` — e.g. the
|
|
61
|
+
* per-cell TEMPLATE of an `:::iterate` block, which must be interpolated then
|
|
62
|
+
* rendered, NOT shown pre-rendered. Captured from source positions.
|
|
63
|
+
*/
|
|
64
|
+
body?: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/* ------------------------------------------------------------------ */
|
|
68
|
+
/* Extension registry — the consumer-supplied render seam (the library */
|
|
69
|
+
/* RENDERS; the consumer brings the renderer + any domain hook). One */
|
|
70
|
+
/* registry feeds BOTH the parse-side known-set and the render dispatch. */
|
|
71
|
+
/* ------------------------------------------------------------------ */
|
|
72
|
+
|
|
73
|
+
/** Context handed to a consumer directive renderer. */
|
|
74
|
+
export interface MarkdownDirectiveContext {
|
|
75
|
+
name: string;
|
|
76
|
+
/** Which syntax matched — a renderer can branch (e.g. inline chip vs block card). */
|
|
77
|
+
kind: MarkdownDirectiveKind;
|
|
78
|
+
/** Directive attributes (`{key=value}`), strings only. */
|
|
79
|
+
attributes: Record<string, string>;
|
|
80
|
+
/** Rendered body (container), label (inline), or empty (leaf). */
|
|
81
|
+
children: ReactNode;
|
|
82
|
+
/**
|
|
83
|
+
* RAW label text for inline/leaf directives (markdown-significant characters
|
|
84
|
+
* preserved). Use this — not `children` — when you need the verbatim source
|
|
85
|
+
* (e.g. a calc expression `85 * 32`). `undefined` for container directives.
|
|
86
|
+
*/
|
|
87
|
+
textValue?: string;
|
|
88
|
+
/**
|
|
89
|
+
* RAW container body markdown (the source between the `:::name` fences). Only
|
|
90
|
+
* populated for directives registered with `rawBodyNames` (e.g. `iterate` /
|
|
91
|
+
* `pivot`), where the body is a TEMPLATE to interpolate + render per cell, not
|
|
92
|
+
* to display pre-rendered. `undefined` otherwise.
|
|
93
|
+
*/
|
|
94
|
+
rawBody?: string;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Register a custom `:::name` / `::name` / `:name` directive renderer. */
|
|
98
|
+
export interface MarkdownDirectiveRenderer {
|
|
99
|
+
/** Directive name, e.g. `"decision"`, `"entity"`. */
|
|
100
|
+
name: string;
|
|
101
|
+
/** Accepted syntaxes. Default: all three. */
|
|
102
|
+
kinds?: readonly MarkdownDirectiveKind[];
|
|
103
|
+
render: (ctx: MarkdownDirectiveContext) => ReactNode;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Context handed to a consumer fence renderer. */
|
|
107
|
+
export interface MarkdownFenceContext {
|
|
108
|
+
/** The fence body (trailing newline stripped). */
|
|
109
|
+
source: string;
|
|
110
|
+
/** The info-string language, e.g. `"calc"`. */
|
|
111
|
+
lang: string;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Register a custom ```lang fenced-block renderer (the mermaid/calc seam). */
|
|
115
|
+
export interface MarkdownFenceRenderer {
|
|
116
|
+
/** Info-string this renderer claims, e.g. `"calc"`. */
|
|
117
|
+
lang: string;
|
|
118
|
+
render: (ctx: MarkdownFenceContext) => ReactNode;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Consumer extensions to the brand markdown dialect — passed to `MarkdownPreview`
|
|
123
|
+
* via the `extensions` prop. Registered directive NAMES are also fed to the parser
|
|
124
|
+
* (so `:entity[…]` is rewritten, while an unregistered `:foo` in prose stays
|
|
125
|
+
* literal text), and the renderers drive the preview's dispatch. The engine is
|
|
126
|
+
* never forked: new blocks register here.
|
|
127
|
+
*/
|
|
128
|
+
export interface MarkdownExtensions {
|
|
129
|
+
directives?: readonly MarkdownDirectiveRenderer[];
|
|
130
|
+
fences?: readonly MarkdownFenceRenderer[];
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
interface MdNode {
|
|
134
|
+
type: string;
|
|
135
|
+
name?: string;
|
|
136
|
+
value?: string;
|
|
137
|
+
attributes?: Record<string, string | null | undefined>;
|
|
138
|
+
children?: MdNode[];
|
|
139
|
+
position?: { start?: { offset?: number }; end?: { offset?: number } };
|
|
140
|
+
data?: { hName?: string; hProperties?: Record<string, unknown> };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const DIRECTIVE_TYPES = new Set(["containerDirective", "leafDirective", "textDirective"]);
|
|
144
|
+
|
|
145
|
+
function mdastText(node: MdNode): string {
|
|
146
|
+
if (typeof node.value === "string") return node.value;
|
|
147
|
+
if (node.children) return node.children.map(mdastText).join("");
|
|
148
|
+
return "";
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function extractTimelineItems(node: MdNode): { title: string; status: string }[] {
|
|
152
|
+
const list = node.children?.find((c) => c.type === "list");
|
|
153
|
+
if (!list?.children) return [];
|
|
154
|
+
// Status marker is a leading `(done)` / `(active)` / `(pending)` — parentheses
|
|
155
|
+
// avoid markdown's `[ref]` link-reference collision. Default: pending.
|
|
156
|
+
const MARKER = /^\((done|complete|completed|active|current|pending|todo)\)\s*/i;
|
|
157
|
+
return list.children
|
|
158
|
+
.filter((c) => c.type === "listItem")
|
|
159
|
+
.map((li) => {
|
|
160
|
+
let title = mdastText(li).trim();
|
|
161
|
+
let status = "pending";
|
|
162
|
+
const marker = title.match(MARKER);
|
|
163
|
+
if (marker) {
|
|
164
|
+
status = marker[1]!.toLowerCase();
|
|
165
|
+
title = title.slice(marker[0].length).trim();
|
|
166
|
+
}
|
|
167
|
+
return { title, status };
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function cleanAttributes(attrs: MdNode["attributes"]): Record<string, string> {
|
|
172
|
+
const out: Record<string, string> = {};
|
|
173
|
+
for (const [k, v] of Object.entries(attrs ?? {})) {
|
|
174
|
+
if (typeof v === "string") out[k] = v;
|
|
175
|
+
}
|
|
176
|
+
return out;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Reconstruct a directive's ORIGINAL source text. Prose colons routinely
|
|
181
|
+
* pattern-match remark-directive's text/leaf forms (`qwen3:0.6b` parses as a
|
|
182
|
+
* `:0` text directive and swallows the "0") — restoring the source slice is
|
|
183
|
+
* the only faithful undo.
|
|
184
|
+
*/
|
|
185
|
+
function originalText(node: MdNode, source: string | undefined): string {
|
|
186
|
+
const start = node.position?.start?.offset;
|
|
187
|
+
const end = node.position?.end?.offset;
|
|
188
|
+
if (source != null && typeof start === "number" && typeof end === "number") {
|
|
189
|
+
return source.slice(start, end);
|
|
190
|
+
}
|
|
191
|
+
// Position unavailable — best-effort reconstruction.
|
|
192
|
+
const colons = node.type === "leafDirective" ? "::" : ":";
|
|
193
|
+
const label = node.children?.length ? `[${mdastText(node)}]` : "";
|
|
194
|
+
return `${colons}${node.name ?? ""}${label}`;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** mdast node type → the directive kind it represents. */
|
|
198
|
+
function directiveKind(type: string): MarkdownDirectiveKind {
|
|
199
|
+
if (type === "containerDirective") return "container";
|
|
200
|
+
if (type === "leafDirective") return "leaf";
|
|
201
|
+
return "inline";
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* The RAW label source of a directive (`:name[label]`) — sliced from the original
|
|
206
|
+
* source via the label children's positions, so markdown-significant characters
|
|
207
|
+
* (`*`, `_`, `[`) survive instead of being parsed into emphasis/links.
|
|
208
|
+
*/
|
|
209
|
+
function rawLabel(node: MdNode, source: string | undefined): string | undefined {
|
|
210
|
+
const kids = node.children;
|
|
211
|
+
if (!source || !kids || kids.length === 0) return undefined;
|
|
212
|
+
const start = kids[0]?.position?.start?.offset;
|
|
213
|
+
const end = kids[kids.length - 1]?.position?.end?.offset;
|
|
214
|
+
if (typeof start === "number" && typeof end === "number") return source.slice(start, end);
|
|
215
|
+
return undefined;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* The RAW body source of a CONTAINER directive — the markdown between the
|
|
220
|
+
* `:::name{…}` opening and the closing `:::`. Skips a leading directive label
|
|
221
|
+
* (`:::name[label]`) child. Used for templated containers (`:::iterate`) whose
|
|
222
|
+
* body must be interpolated then rendered, not shown pre-rendered.
|
|
223
|
+
*/
|
|
224
|
+
function rawContainerBody(node: MdNode, source: string | undefined): string | undefined {
|
|
225
|
+
const kids = node.children;
|
|
226
|
+
if (!source || !kids || kids.length === 0) return undefined;
|
|
227
|
+
const body = kids.filter(
|
|
228
|
+
(c) => !(c.data as { directiveLabel?: boolean } | undefined)?.directiveLabel,
|
|
229
|
+
);
|
|
230
|
+
if (body.length === 0) return undefined;
|
|
231
|
+
const start = body[0]?.position?.start?.offset;
|
|
232
|
+
const end = body[body.length - 1]?.position?.end?.offset;
|
|
233
|
+
if (typeof start === "number" && typeof end === "number") return source.slice(start, end).trim();
|
|
234
|
+
return undefined;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* remark transform: brand directives → `<brand-directive data-brand="{json}">`
|
|
239
|
+
* (block/leaf) or `<brand-directive-inline …>` (inline).
|
|
240
|
+
*
|
|
241
|
+
* `knownNames` is the set of directive names to TREAT AS KNOWN — i.e. rewrite
|
|
242
|
+
* rather than restore. It defaults to the four built-ins; `MarkdownPreview`
|
|
243
|
+
* extends it with the names a consumer registered via `extensions`, so a
|
|
244
|
+
* registered `:entity[…]` is rewritten while an unregistered `:foo` (or a stray
|
|
245
|
+
* prose colon) is still restored to literal text.
|
|
246
|
+
*/
|
|
247
|
+
export function remarkBrandDirectives(
|
|
248
|
+
knownNames: readonly string[] = BRAND_DIRECTIVES,
|
|
249
|
+
rawBodyNames: readonly string[] = [],
|
|
250
|
+
) {
|
|
251
|
+
return (tree: unknown, file?: { value?: unknown }) => {
|
|
252
|
+
const source = typeof file?.value === "string" ? file.value : undefined;
|
|
253
|
+
visit(tree as never, (node: MdNode, index: number | undefined, parent: MdNode | undefined) => {
|
|
254
|
+
if (!DIRECTIVE_TYPES.has(node.type) || !node.name) return undefined;
|
|
255
|
+
|
|
256
|
+
const name = node.name;
|
|
257
|
+
const known = knownNames.includes(name);
|
|
258
|
+
const kind = directiveKind(node.type);
|
|
259
|
+
|
|
260
|
+
// Unknown text/leaf directives are almost always FALSE POSITIVES from
|
|
261
|
+
// ordinary colons in prose — restore them as literal text. Only an
|
|
262
|
+
// unknown CONTAINER (`:::name`, deliberately authored) keeps the
|
|
263
|
+
// explicit unknown-block error.
|
|
264
|
+
if (!known && node.type !== "containerDirective" && parent?.children && index != null) {
|
|
265
|
+
const literal: MdNode = { type: "text", value: originalText(node, source) };
|
|
266
|
+
parent.children.splice(
|
|
267
|
+
index,
|
|
268
|
+
1,
|
|
269
|
+
node.type === "leafDirective"
|
|
270
|
+
? ({ type: "paragraph", children: [literal] } as MdNode)
|
|
271
|
+
: literal,
|
|
272
|
+
);
|
|
273
|
+
return index + 1; // continue after the replacement
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const payload: BrandDirectivePayload = {
|
|
277
|
+
name,
|
|
278
|
+
known,
|
|
279
|
+
kind,
|
|
280
|
+
attributes: cleanAttributes(node.attributes),
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
// Inline/leaf directives carry a verbatim label (e.g. a calc expression).
|
|
284
|
+
if (kind !== "container") {
|
|
285
|
+
const label = rawLabel(node, source);
|
|
286
|
+
if (label != null) payload.label = label;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Templated containers (`:::iterate`/`:::pivot`) carry their RAW body so the
|
|
290
|
+
// renderer can interpolate + render it per cell. Clear the children so the
|
|
291
|
+
// template is NOT also rendered pre-interpolated (mirrors timeline).
|
|
292
|
+
if (kind === "container" && rawBodyNames.includes(name)) {
|
|
293
|
+
const body = rawContainerBody(node, source);
|
|
294
|
+
if (body != null) payload.body = body;
|
|
295
|
+
node.children = [];
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
if (name === "timeline") {
|
|
299
|
+
payload.items = extractTimelineItems(node);
|
|
300
|
+
node.children = []; // consumed into payload.items
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const data = node.data ?? (node.data = {});
|
|
304
|
+
// Inline directives keep their children (the label) and use a SEPARATE tag
|
|
305
|
+
// so the preview renders them inline (no block wrapper).
|
|
306
|
+
data.hName = kind === "inline" ? BRAND_DIRECTIVE_INLINE_TAG : BRAND_DIRECTIVE_TAG;
|
|
307
|
+
// camelCase hast property → renders as the `data-brand` attribute.
|
|
308
|
+
data.hProperties = { [BRAND_DIRECTIVE_PROP]: JSON.stringify(payload) };
|
|
309
|
+
});
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** Options for {@link buildMarkdownPlugins}. */
|
|
314
|
+
export interface BuildMarkdownPluginsOptions {
|
|
315
|
+
/**
|
|
316
|
+
* Extra directive names to treat as known (rewritten, not restored as literal
|
|
317
|
+
* text). The four built-ins are always known; pass the names a consumer
|
|
318
|
+
* registered via `extensions.directives`.
|
|
319
|
+
*/
|
|
320
|
+
directiveNames?: readonly string[];
|
|
321
|
+
/**
|
|
322
|
+
* Container directive names whose RAW body should be captured (as
|
|
323
|
+
* `payload.body` / `ctx.rawBody`) instead of pre-rendered — for templated
|
|
324
|
+
* blocks like `iterate` / `pivot` whose body is interpolated per cell.
|
|
325
|
+
*/
|
|
326
|
+
rawBodyNames?: readonly string[];
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* The shared remark plugin array (directive parsing + brand mapping). Pass
|
|
331
|
+
* `directiveNames` to recognize consumer-registered directives without forking
|
|
332
|
+
* the engine; with no options it parses exactly the four built-ins (backward
|
|
333
|
+
* compatible).
|
|
334
|
+
*/
|
|
335
|
+
export function buildMarkdownPlugins(options: BuildMarkdownPluginsOptions = {}): PluggableList {
|
|
336
|
+
const known =
|
|
337
|
+
options.directiveNames && options.directiveNames.length > 0
|
|
338
|
+
? [...BRAND_DIRECTIVES, ...options.directiveNames]
|
|
339
|
+
: BRAND_DIRECTIVES;
|
|
340
|
+
const rawBodyNames = options.rawBodyNames ?? [];
|
|
341
|
+
return [remarkDirective, [remarkBrandDirectives, known, rawBodyNames]];
|
|
342
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* YAML frontmatter parse/serialize. Splits the leading `---` block off a markdown
|
|
3
|
+
* document so the body can be rendered/edited and the metadata can drive a form.
|
|
4
|
+
*
|
|
5
|
+
* `parseFrontmatter` throws on malformed YAML — callers (the app) catch and map it
|
|
6
|
+
* to a typed error for the metadata panel.
|
|
7
|
+
*/
|
|
8
|
+
import { dump, load } from "js-yaml";
|
|
9
|
+
|
|
10
|
+
export interface ParsedDocument {
|
|
11
|
+
/** Parsed frontmatter object ({} when there is no frontmatter block). */
|
|
12
|
+
frontmatter: Record<string, unknown>;
|
|
13
|
+
/** Markdown body with the frontmatter block removed. */
|
|
14
|
+
content: string;
|
|
15
|
+
hasFrontmatter: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Optional leading BOM (\uFEFF), then a `---` … `---` block at the very top.
|
|
19
|
+
const FRONTMATTER_RE = /^\uFEFF?---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/;
|
|
20
|
+
|
|
21
|
+
export function parseFrontmatter(source: string): ParsedDocument {
|
|
22
|
+
const match = source.match(FRONTMATTER_RE);
|
|
23
|
+
if (!match) {
|
|
24
|
+
return { frontmatter: {}, content: source, hasFrontmatter: false };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const parsed = load(match[1] ?? "");
|
|
28
|
+
const frontmatter =
|
|
29
|
+
parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
|
30
|
+
? (parsed as Record<string, unknown>)
|
|
31
|
+
: {};
|
|
32
|
+
|
|
33
|
+
return {
|
|
34
|
+
frontmatter,
|
|
35
|
+
content: source.slice(match[0].length),
|
|
36
|
+
hasFrontmatter: true,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function serializeFrontmatter(
|
|
41
|
+
frontmatter: Record<string, unknown>,
|
|
42
|
+
content: string,
|
|
43
|
+
): string {
|
|
44
|
+
const body = content.replace(/^\s+/, "");
|
|
45
|
+
if (!frontmatter || Object.keys(frontmatter).length === 0) {
|
|
46
|
+
return body;
|
|
47
|
+
}
|
|
48
|
+
const yaml = dump(frontmatter, { lineWidth: -1, noRefs: true }).trimEnd();
|
|
49
|
+
return `---\n${yaml}\n---\n\n${body}`;
|
|
50
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
|
|
4
|
+
import { describe, expect, it } from "vitest";
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
MARKDOWN_HEADING_REM,
|
|
8
|
+
MARKDOWN_HEADING_TRACKING,
|
|
9
|
+
MARKDOWN_HEADING_WEIGHT,
|
|
10
|
+
MARKDOWN_MEASURE,
|
|
11
|
+
markdownScaleVars,
|
|
12
|
+
} from "./markdown-scale";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Drift contract for issue #18: the markdown editor (CSS) and the preview (prose
|
|
16
|
+
* React) must render ONE heading scale + measure. This test pins both sides to the
|
|
17
|
+
* shared `markdown-scale` module and fails the moment either diverges.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
// The Tailwind step the prose `Heading` uses per level (prose.tsx HEADING_SIZE),
|
|
21
|
+
// expressed in rem. This is the React side's contract: keep it equal to the scale.
|
|
22
|
+
const PROSE_HEADING_REM: Record<1 | 2 | 3 | 4 | 5 | 6, number> = {
|
|
23
|
+
1: 1.5, // text-2xl
|
|
24
|
+
2: 1.25, // text-xl
|
|
25
|
+
3: 1.125, // text-lg
|
|
26
|
+
4: 1, // text-base
|
|
27
|
+
5: 0.875, // text-sm
|
|
28
|
+
6: 0.875, // text-sm
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
describe("markdown-scale (shared editor↔preview source of truth)", () => {
|
|
32
|
+
it("the prose heading steps equal the canonical scale", () => {
|
|
33
|
+
expect(MARKDOWN_HEADING_REM).toEqual(PROSE_HEADING_REM);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("emits the scale as CSS vars for the editor host", () => {
|
|
37
|
+
const vars = markdownScaleVars();
|
|
38
|
+
expect(vars["--md-h1"]).toBe(`${MARKDOWN_HEADING_REM[1]}rem`);
|
|
39
|
+
expect(vars["--md-h6"]).toBe(`${MARKDOWN_HEADING_REM[6]}rem`);
|
|
40
|
+
expect(vars["--md-heading-weight"]).toBe(String(MARKDOWN_HEADING_WEIGHT));
|
|
41
|
+
expect(vars["--md-heading-tracking"]).toBe(MARKDOWN_HEADING_TRACKING);
|
|
42
|
+
expect(vars["--md-measure"]).toBe(MARKDOWN_MEASURE);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("the editor CSS drives its headings + measure from the scale vars (no hardcoded sizes)", () => {
|
|
46
|
+
// Resolve from the @elabs-ai/components-editor package root (vitest runs with cwd = package).
|
|
47
|
+
const cssPath = resolve(process.cwd(), "src/markdown-editor/markdown-editor.css");
|
|
48
|
+
const css = readFileSync(cssPath, "utf8");
|
|
49
|
+
|
|
50
|
+
// Each heading reads its --md-* var (so the editor can't carry its own numbers).
|
|
51
|
+
for (const level of [1, 2, 3, 4, 5, 6] as const) {
|
|
52
|
+
expect(css).toContain(`font-size: var(--md-h${level}`);
|
|
53
|
+
}
|
|
54
|
+
expect(css).toContain("font-weight: var(--md-heading-weight");
|
|
55
|
+
expect(css).toContain("letter-spacing: var(--md-heading-tracking");
|
|
56
|
+
// Measure: capped at the shared --md-measure (same as the preview's max-w-3xl).
|
|
57
|
+
expect(css).toContain("max-width: var(--md-measure");
|
|
58
|
+
|
|
59
|
+
// Guard against re-introducing the OLD standalone heading scale (the editor
|
|
60
|
+
// used to carry its own 1.6 / 1.35 / 1.15 / 0.9rem heading sizes — #18).
|
|
61
|
+
for (const old of ["1.6rem", "1.35rem", "1.15rem", "0.9rem"]) {
|
|
62
|
+
expect(css).not.toContain(`font-size: ${old}`);
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
});
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* markdown-scale — the markdown visual scale shared by BOTH renderers of the
|
|
3
|
+
* brand markdown dialect:
|
|
4
|
+
*
|
|
5
|
+
* • the Streamdown preview (React: `prose/prose.tsx` Heading + the `components` map)
|
|
6
|
+
* • the Milkdown WYSIWYG editor (CSS: `markdown-editor/markdown-editor.css`)
|
|
7
|
+
*
|
|
8
|
+
* The two are independent renderers (the editor is ProseMirror-native by design and
|
|
9
|
+
* cannot import the React components — see directive-nodes.ts), so without a shared,
|
|
10
|
+
* machine-consumable scale they drift: switching Source → Split → Preview-edit
|
|
11
|
+
* visibly re-skins headings / measure (issue #18).
|
|
12
|
+
*
|
|
13
|
+
* Since #188 the numbers are DERIVED, not re-hardcoded: @elabs-ai/components-ui owns the canonical
|
|
14
|
+
* reading scale (`PROSE_HEADING_REM` in `components/typography/prose.tsx`, itself
|
|
15
|
+
* pinned to the `--text-<role>` tokens where the rungs coincide). This module is the
|
|
16
|
+
* editor-side seam: it re-exports those numbers and emits them as CSS variables (set
|
|
17
|
+
* on `.milkdown-host` via `markdownScaleVars()`); `markdown-scale.test.ts` fails the
|
|
18
|
+
* moment either renderer diverges.
|
|
19
|
+
*/
|
|
20
|
+
import {
|
|
21
|
+
PROSE_HEADING_REM,
|
|
22
|
+
PROSE_HEADING_TRACKING,
|
|
23
|
+
PROSE_HEADING_WEIGHT,
|
|
24
|
+
} from "@elabs-ai/components-ui";
|
|
25
|
+
|
|
26
|
+
export type MarkdownHeadingLevel = 1 | 2 | 3 | 4 | 5 | 6;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Canonical heading font-size per level, in rem — derived from the @elabs-ai/components-ui
|
|
30
|
+
* prose reading scale (h2/h4/h5/h6 == the title/subtitle/body role rems;
|
|
31
|
+
* h1/h3 are intermediate reading rungs).
|
|
32
|
+
*/
|
|
33
|
+
export const MARKDOWN_HEADING_REM: Record<MarkdownHeadingLevel, number> = PROSE_HEADING_REM;
|
|
34
|
+
|
|
35
|
+
/** Canonical heading weight (Tailwind `font-semibold`) — derived from @elabs-ai/components-ui. */
|
|
36
|
+
export const MARKDOWN_HEADING_WEIGHT = PROSE_HEADING_WEIGHT;
|
|
37
|
+
|
|
38
|
+
/** Canonical heading letter-spacing (Tailwind `tracking-tight`) — derived from @elabs-ai/components-ui. */
|
|
39
|
+
export const MARKDOWN_HEADING_TRACKING = PROSE_HEADING_TRACKING;
|
|
40
|
+
|
|
41
|
+
/** Canonical reading measure (max content width). Mirrors `max-w-3xl`. */
|
|
42
|
+
export const MARKDOWN_MEASURE = "48rem";
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The scale as CSS custom properties, to set on the editor host (`.milkdown-host`)
|
|
46
|
+
* so `markdown-editor.css` reads the SAME numbers as the prose components instead of
|
|
47
|
+
* hardcoding its own. Spread onto a `style` prop:
|
|
48
|
+
* `<div className="milkdown-host" style={markdownScaleVars()} />`
|
|
49
|
+
*/
|
|
50
|
+
export function markdownScaleVars(): Record<string, string> {
|
|
51
|
+
return {
|
|
52
|
+
"--md-h1": `${MARKDOWN_HEADING_REM[1]}rem`,
|
|
53
|
+
"--md-h2": `${MARKDOWN_HEADING_REM[2]}rem`,
|
|
54
|
+
"--md-h3": `${MARKDOWN_HEADING_REM[3]}rem`,
|
|
55
|
+
"--md-h4": `${MARKDOWN_HEADING_REM[4]}rem`,
|
|
56
|
+
"--md-h5": `${MARKDOWN_HEADING_REM[5]}rem`,
|
|
57
|
+
"--md-h6": `${MARKDOWN_HEADING_REM[6]}rem`,
|
|
58
|
+
"--md-heading-weight": String(MARKDOWN_HEADING_WEIGHT),
|
|
59
|
+
"--md-heading-tracking": MARKDOWN_HEADING_TRACKING,
|
|
60
|
+
"--md-measure": MARKDOWN_MEASURE,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { mergeNormalizedEdit } from "./merge";
|
|
4
|
+
|
|
5
|
+
// Simulated serializer drift: the editor rewrites `*` bullets as `-` but
|
|
6
|
+
// keeps headings, blank lines, and prose byte-exact (markdown's blank lines
|
|
7
|
+
// are the anchors that make block-granularity merging work).
|
|
8
|
+
const O = "# Title\n\n* alpha\n* beta\n\npara one\n\nthe end";
|
|
9
|
+
const B = "# Title\n\n- alpha\n- beta\n\npara one\n\nthe end";
|
|
10
|
+
|
|
11
|
+
describe("mergeNormalizedEdit (WI-1 — lossless WYSIWYG edits)", () => {
|
|
12
|
+
it("returns the original byte-exact when the user changed nothing", () => {
|
|
13
|
+
expect(mergeNormalizedEdit(O, B, B)).toBe(O);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("returns the edit untouched when there is no normalization drift", () => {
|
|
17
|
+
const n = O.replace("para one", "para one EDITED");
|
|
18
|
+
expect(mergeNormalizedEdit(O, O, n)).toBe(n);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("a one-line edit changes exactly that line — normalized bullets stay original", () => {
|
|
22
|
+
const n = B.replace("para one", "para one EDITED");
|
|
23
|
+
expect(mergeNormalizedEdit(O, B, n)).toBe(O.replace("para one", "para one EDITED"));
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("an appended paragraph lands at the end, the rest stays original", () => {
|
|
27
|
+
const n = `${B}\n\nbrand new para`;
|
|
28
|
+
expect(mergeNormalizedEdit(O, B, n)).toBe(`${O}\n\nbrand new para`);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("deleting a line inside a normalized block keeps the rest of the doc original", () => {
|
|
32
|
+
const n = B.replace("- beta\n", "");
|
|
33
|
+
const merged = mergeNormalizedEdit(O, B, n);
|
|
34
|
+
// The edited block is locally normalized (documented trade-off)…
|
|
35
|
+
expect(merged).toContain("- alpha");
|
|
36
|
+
expect(merged).not.toContain("beta");
|
|
37
|
+
// …but everything outside it keeps original bytes.
|
|
38
|
+
expect(merged).toContain("# Title");
|
|
39
|
+
expect(merged).toContain("para one");
|
|
40
|
+
expect(merged).toContain("the end");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("an edit inside a normalized block normalizes only that block", () => {
|
|
44
|
+
const n = B.replace("- beta", "- beeee");
|
|
45
|
+
const merged = mergeNormalizedEdit(O, B, n);
|
|
46
|
+
expect(merged).toContain("- alpha");
|
|
47
|
+
expect(merged).toContain("- beeee");
|
|
48
|
+
expect(merged).toContain("para one\n\nthe end");
|
|
49
|
+
expect(merged.startsWith("# Title")).toBe(true);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("an insertion between untouched blocks preserves both neighbors byte-exact", () => {
|
|
53
|
+
const n = B.replace("\npara one", "\ninserted line\n\npara one");
|
|
54
|
+
const merged = mergeNormalizedEdit(O, B, n);
|
|
55
|
+
expect(merged).toContain("* alpha\n* beta");
|
|
56
|
+
expect(merged).toContain("inserted line");
|
|
57
|
+
expect(merged).toContain("para one\n\nthe end");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("falls back to the edited text for oversized inputs", () => {
|
|
61
|
+
const big = Array.from({ length: 6000 }, (_, i) => `line ${i}`).join("\n");
|
|
62
|
+
expect(mergeNormalizedEdit(big, "b", "n")).toBe("n");
|
|
63
|
+
});
|
|
64
|
+
});
|