@dskripchenko/markdown-editor 0.1.0 → 0.2.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/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 127c7b2: `image-base` and `link-base`: a document's relative pictures show from its folder in the visual editor and the preview and are saved back as written (`img/a.png`, `../a.png`); relative links of the preview resolve against `link-base`. `fromMarkdown`, `toMarkdown` and `normalize` take `{ imageBase }`; `relativeTo` is exported.
8
+
3
9
  ## 0.1.0
4
10
 
5
11
  ### Minor Changes
package/dist/index.cjs CHANGED
@@ -122,12 +122,35 @@ function splitBlocks(markdown) {
122
122
  function escapeHtml(text) {
123
123
  return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
124
124
  }
125
+ /**
126
+ * A URL back to a path relative to `base`, when both share the origin:
127
+ * `<base>img/a.png` → `img/a.png`, `<base's parent>up.png` → `../up.png`.
128
+ * Anything else stays as it is.
129
+ */
130
+ function relativeTo(url, base) {
131
+ let u;
132
+ let b;
133
+ try {
134
+ u = new URL(url);
135
+ b = new URL(base);
136
+ } catch {
137
+ return url;
138
+ }
139
+ if (u.protocol !== b.protocol || u.host !== b.host) return url;
140
+ const from = b.pathname.split("/").slice(0, -1);
141
+ const to = u.pathname.split("/");
142
+ let i = 0;
143
+ while (i < from.length && i < to.length - 1 && from[i] === to[i]) i++;
144
+ if (i === 0 && from.length > 1) return url;
145
+ const rel = [...Array(from.length - i).fill(".."), ...to.slice(i)].join("/");
146
+ return decodeURI(rel) + u.search + u.hash;
147
+ }
125
148
  /** Markdown → the HTML the editor edits; source blocks become code blocks of {@link SOURCE_LANG}. */
126
- function fromMarkdown(markdown) {
149
+ function fromMarkdown(markdown, options = {}) {
127
150
  const out = [];
128
151
  let pending = [];
129
152
  const flush = () => {
130
- if (pending.length) out.push((0, _dskripchenko_ui.renderMarkdown)(pending.join("\n\n")));
153
+ if (pending.length) out.push((0, _dskripchenko_ui.renderMarkdown)(pending.join("\n\n"), options.imageBase ? { imageBase: options.imageBase } : {}));
131
154
  pending = [];
132
155
  };
133
156
  for (const block of splitBlocks(markdown)) {
@@ -142,14 +165,18 @@ function fromMarkdown(markdown) {
142
165
  return out.join("\n");
143
166
  }
144
167
  var SOURCE_FENCE = new RegExp(`^(\`{3,})${SOURCE_LANG}\\n([\\s\\S]*?)\\n\\1$`, "gm");
145
- /** The editor's HTML → Markdown; source blocks come back as written. */
146
- function toMarkdown(html) {
147
- const md = (0, _dskripchenko_wysiwyg.htmlToMarkdown)(html).replace(SOURCE_FENCE, (_m, _fence, text) => text);
168
+ /** The editor's HTML → Markdown; source blocks come back as written, pictures by their relative paths. */
169
+ function toMarkdown(html, options = {}) {
170
+ const base = options.imageBase;
171
+ const relative = base ? html.replace(/(<img\b[^>]*?\ssrc=")([^"]*)(")/gi, (_m, head, src, tail) => {
172
+ return head + relativeTo(src.replace(/&amp;/g, "&"), base).replace(/&/g, "&amp;") + tail;
173
+ }) : html;
174
+ const md = (0, _dskripchenko_wysiwyg.htmlToMarkdown)(relative).replace(SOURCE_FENCE, (_m, _fence, text) => text);
148
175
  return md === "" ? "" : `${md}\n`;
149
176
  }
150
177
  /** One round: what saving a document unchanged in the editor gives. */
151
- function normalize(markdown) {
152
- return toMarkdown(fromMarkdown(markdown));
178
+ function normalize(markdown, options = {}) {
179
+ return toMarkdown(fromMarkdown(markdown, options), options);
153
180
  }
154
181
  // src/locale.ts
155
182
  var en = {
@@ -211,7 +238,9 @@ var MarkdownEditor_default = /* @__PURE__ */ (0, vue.defineComponent)({
211
238
  toolbarItems: { default: void 0 },
212
239
  slashCommands: { default: void 0 },
213
240
  locale: { default: void 0 },
214
- labels: { default: void 0 }
241
+ labels: { default: void 0 },
242
+ imageBase: { default: void 0 },
243
+ linkBase: { default: void 0 }
215
244
  }, {
216
245
  "mode": { default: "visual" },
217
246
  "modeModifiers": {}
@@ -226,14 +255,18 @@ var MarkdownEditor_default = /* @__PURE__ */ (0, vue.defineComponent)({
226
255
  ...labelsFor(props.locale ?? kitLocale.value.code),
227
256
  ...props.labels
228
257
  }));
258
+ const options = (0, vue.computed)(() => ({ imageBase: props.imageBase ?? null }));
229
259
  const markdown = (0, vue.ref)(props.modelValue);
230
- const html = (0, vue.ref)(fromMarkdown(props.modelValue));
260
+ const html = (0, vue.ref)(fromMarkdown(props.modelValue, options.value));
231
261
  const wysiwyg = (0, vue.ref)(null);
232
262
  const area = (0, vue.ref)(null);
233
263
  (0, vue.watch)(() => props.modelValue, (next) => {
234
264
  if (next === markdown.value) return;
235
265
  markdown.value = next;
236
- html.value = fromMarkdown(next);
266
+ html.value = fromMarkdown(next, options.value);
267
+ });
268
+ (0, vue.watch)(() => props.imageBase, () => {
269
+ html.value = fromMarkdown(markdown.value, options.value);
237
270
  });
238
271
  function commit(next) {
239
272
  if (next === markdown.value) return;
@@ -243,14 +276,14 @@ var MarkdownEditor_default = /* @__PURE__ */ (0, vue.defineComponent)({
243
276
  }
244
277
  function onHtml(next) {
245
278
  html.value = next;
246
- commit(toMarkdown(next));
279
+ commit(toMarkdown(next, options.value));
247
280
  }
248
281
  function onSource(event) {
249
282
  commit(event.target.value);
250
283
  }
251
284
  function setMode(next) {
252
285
  if (next === mode.value) return;
253
- if (next === "visual") html.value = fromMarkdown(markdown.value);
286
+ if (next === "visual") html.value = fromMarkdown(markdown.value, options.value);
254
287
  mode.value = next;
255
288
  }
256
289
  /** Text that reads as Markdown: pasted into the visual editor, it arrives formatted. */
@@ -263,7 +296,7 @@ var MarkdownEditor_default = /* @__PURE__ */ (0, vue.defineComponent)({
263
296
  if (!text || !MARKDOWN_HINT.test(text)) return;
264
297
  event.preventDefault();
265
298
  event.stopPropagation();
266
- document.execCommand("insertHTML", false, fromMarkdown(text));
299
+ document.execCommand("insertHTML", false, fromMarkdown(text, options.value));
267
300
  }
268
301
  function onTab(event) {
269
302
  if (event.key !== "Tab" || event.shiftKey || event.ctrlKey || event.metaKey || event.altKey) return;
@@ -281,7 +314,7 @@ var MarkdownEditor_default = /* @__PURE__ */ (0, vue.defineComponent)({
281
314
  function insert(text) {
282
315
  if (mode.value === "visual") {
283
316
  focus();
284
- document.execCommand("insertHTML", false, fromMarkdown(text));
317
+ document.execCommand("insertHTML", false, fromMarkdown(text, options.value));
285
318
  return;
286
319
  }
287
320
  const el = area.value;
@@ -362,8 +395,15 @@ var MarkdownEditor_default = /* @__PURE__ */ (0, vue.defineComponent)({
362
395
  "aria-label": t.value.preview
363
396
  }, [(0, vue.createVNode)((0, vue.unref)(_dskripchenko_ui.UidMarkdown), {
364
397
  source: markdown.value,
365
- "block-renderers": __props.blockRenderers
366
- }, null, 8, ["source", "block-renderers"])], 8, _hoisted_6)) : (0, vue.createCommentVNode)("", true)]))], 6);
398
+ "block-renderers": __props.blockRenderers,
399
+ "image-base": __props.imageBase,
400
+ "link-base": __props.linkBase
401
+ }, null, 8, [
402
+ "source",
403
+ "block-renderers",
404
+ "image-base",
405
+ "link-base"
406
+ ])], 8, _hoisted_6)) : (0, vue.createCommentVNode)("", true)]))], 6);
367
407
  };
368
408
  }
369
409
  });
@@ -373,6 +413,7 @@ exports.fromMarkdown = fromMarkdown;
373
413
  exports.labelsFor = labelsFor;
374
414
  exports.markdownEditorLocales = markdownEditorLocales;
375
415
  exports.normalize = normalize;
416
+ exports.relativeTo = relativeTo;
376
417
  exports.splitBlocks = splitBlocks;
377
418
  exports.toMarkdown = toMarkdown;
378
419
 
package/dist/index.d.ts CHANGED
@@ -20,6 +20,7 @@ declare const __VLS_export: DefineComponent<__VLS_PublicProps, {
20
20
  onChange?: ((markdown: string) => any) | undefined;
21
21
  "onUpdate:mode"?: ((value: MarkdownEditorMode) => any) | undefined;
22
22
  }>, {
23
+ imageBase: string;
23
24
  modelValue: string;
24
25
  placeholder: string;
25
26
  toolbarItems: ToolbarItem[];
@@ -31,6 +32,7 @@ declare const __VLS_export: DefineComponent<__VLS_PublicProps, {
31
32
  locale: MarkdownEditorLocale;
32
33
  labels: Partial<MarkdownEditorLabels>;
33
34
  modes: MarkdownEditorMode[];
35
+ linkBase: string;
34
36
  }, {}, {}, {}, string, ComponentProvideOptions, false, {}, any>;
35
37
 
36
38
  declare type __VLS_ModelProps = {
@@ -49,17 +51,27 @@ declare type __VLS_Props = {
49
51
  slashCommands?: SlashCommands;
50
52
  locale?: MarkdownEditorLocale;
51
53
  labels?: Partial<MarkdownEditorLabels>;
54
+ /** The folder relative pictures resolve against (a URL ending in `/`); saved back relative. */
55
+ imageBase?: string;
56
+ /** The base of relative links in the preview. */
57
+ linkBase?: string;
52
58
  };
53
59
 
54
60
  declare type __VLS_PublicProps = __VLS_Props & __VLS_ModelProps;
55
61
 
56
62
  declare type BlockRenderers = InstanceType<typeof DskWysiwyg>['$props']['blockRenderers'];
57
63
 
64
+ /** Where the document's relative pictures resolve while it is edited. */
65
+ export declare interface ConvertOptions {
66
+ /** A folder URL (ending in `/`): `img/a.png` shows as `<imageBase>img/a.png` and is saved back as `img/a.png`. */
67
+ imageBase?: string | null;
68
+ }
69
+
58
70
  /** Focus the editor of the current mode. */
59
71
  declare function focus_2(): void;
60
72
 
61
73
  /** Markdown → the HTML the editor edits; source blocks become code blocks of {@link SOURCE_LANG}. */
62
- export declare function fromMarkdown(markdown: string): string;
74
+ export declare function fromMarkdown(markdown: string, options?: ConvertOptions): string;
63
75
 
64
76
  /** Insert Markdown at the caret (the source's caret in the Markdown modes). */
65
77
  declare function insert(text: string): void;
@@ -90,7 +102,14 @@ export declare const markdownEditorLocales: Record<MarkdownEditorLocale, Markdow
90
102
  export declare type MarkdownEditorMode = 'visual' | 'source' | 'split';
91
103
 
92
104
  /** One round: what saving a document unchanged in the editor gives. */
93
- export declare function normalize(markdown: string): string;
105
+ export declare function normalize(markdown: string, options?: ConvertOptions): string;
106
+
107
+ /**
108
+ * A URL back to a path relative to `base`, when both share the origin:
109
+ * `<base>img/a.png` → `img/a.png`, `<base's parent>up.png` → `../up.png`.
110
+ * Anything else stays as it is.
111
+ */
112
+ export declare function relativeTo(url: string, base: string): string;
94
113
 
95
114
  declare type SlashCommands = InstanceType<typeof DskWysiwyg>['$props']['slashCommands'];
96
115
 
@@ -103,8 +122,8 @@ export declare const SOURCE_LANG = "x-markdown";
103
122
  */
104
123
  export declare function splitBlocks(markdown: string): MarkdownBlock[];
105
124
 
106
- /** The editor's HTML → Markdown; source blocks come back as written. */
107
- export declare function toMarkdown(html: string): string;
125
+ /** The editor's HTML → Markdown; source blocks come back as written, pictures by their relative paths. */
126
+ export declare function toMarkdown(html: string, options?: ConvertOptions): string;
108
127
 
109
128
  declare type ToolbarItems = InstanceType<typeof DskWysiwyg>['$props']['toolbarItems'];
110
129
 
package/dist/index.mjs CHANGED
@@ -121,12 +121,35 @@ function splitBlocks(markdown) {
121
121
  function escapeHtml(text) {
122
122
  return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
123
123
  }
124
+ /**
125
+ * A URL back to a path relative to `base`, when both share the origin:
126
+ * `<base>img/a.png` → `img/a.png`, `<base's parent>up.png` → `../up.png`.
127
+ * Anything else stays as it is.
128
+ */
129
+ function relativeTo(url, base) {
130
+ let u;
131
+ let b;
132
+ try {
133
+ u = new URL(url);
134
+ b = new URL(base);
135
+ } catch {
136
+ return url;
137
+ }
138
+ if (u.protocol !== b.protocol || u.host !== b.host) return url;
139
+ const from = b.pathname.split("/").slice(0, -1);
140
+ const to = u.pathname.split("/");
141
+ let i = 0;
142
+ while (i < from.length && i < to.length - 1 && from[i] === to[i]) i++;
143
+ if (i === 0 && from.length > 1) return url;
144
+ const rel = [...Array(from.length - i).fill(".."), ...to.slice(i)].join("/");
145
+ return decodeURI(rel) + u.search + u.hash;
146
+ }
124
147
  /** Markdown → the HTML the editor edits; source blocks become code blocks of {@link SOURCE_LANG}. */
125
- function fromMarkdown(markdown) {
148
+ function fromMarkdown(markdown, options = {}) {
126
149
  const out = [];
127
150
  let pending = [];
128
151
  const flush = () => {
129
- if (pending.length) out.push(renderMarkdown(pending.join("\n\n")));
152
+ if (pending.length) out.push(renderMarkdown(pending.join("\n\n"), options.imageBase ? { imageBase: options.imageBase } : {}));
130
153
  pending = [];
131
154
  };
132
155
  for (const block of splitBlocks(markdown)) {
@@ -141,14 +164,18 @@ function fromMarkdown(markdown) {
141
164
  return out.join("\n");
142
165
  }
143
166
  var SOURCE_FENCE = new RegExp(`^(\`{3,})${SOURCE_LANG}\\n([\\s\\S]*?)\\n\\1$`, "gm");
144
- /** The editor's HTML → Markdown; source blocks come back as written. */
145
- function toMarkdown(html) {
146
- const md = htmlToMarkdown(html).replace(SOURCE_FENCE, (_m, _fence, text) => text);
167
+ /** The editor's HTML → Markdown; source blocks come back as written, pictures by their relative paths. */
168
+ function toMarkdown(html, options = {}) {
169
+ const base = options.imageBase;
170
+ const relative = base ? html.replace(/(<img\b[^>]*?\ssrc=")([^"]*)(")/gi, (_m, head, src, tail) => {
171
+ return head + relativeTo(src.replace(/&amp;/g, "&"), base).replace(/&/g, "&amp;") + tail;
172
+ }) : html;
173
+ const md = htmlToMarkdown(relative).replace(SOURCE_FENCE, (_m, _fence, text) => text);
147
174
  return md === "" ? "" : `${md}\n`;
148
175
  }
149
176
  /** One round: what saving a document unchanged in the editor gives. */
150
- function normalize(markdown) {
151
- return toMarkdown(fromMarkdown(markdown));
177
+ function normalize(markdown, options = {}) {
178
+ return toMarkdown(fromMarkdown(markdown, options), options);
152
179
  }
153
180
  // src/locale.ts
154
181
  var en = {
@@ -210,7 +237,9 @@ var MarkdownEditor_default = /* @__PURE__ */ defineComponent({
210
237
  toolbarItems: { default: void 0 },
211
238
  slashCommands: { default: void 0 },
212
239
  locale: { default: void 0 },
213
- labels: { default: void 0 }
240
+ labels: { default: void 0 },
241
+ imageBase: { default: void 0 },
242
+ linkBase: { default: void 0 }
214
243
  }, {
215
244
  "mode": { default: "visual" },
216
245
  "modeModifiers": {}
@@ -225,14 +254,18 @@ var MarkdownEditor_default = /* @__PURE__ */ defineComponent({
225
254
  ...labelsFor(props.locale ?? kitLocale.value.code),
226
255
  ...props.labels
227
256
  }));
257
+ const options = computed(() => ({ imageBase: props.imageBase ?? null }));
228
258
  const markdown = ref(props.modelValue);
229
- const html = ref(fromMarkdown(props.modelValue));
259
+ const html = ref(fromMarkdown(props.modelValue, options.value));
230
260
  const wysiwyg = ref(null);
231
261
  const area = ref(null);
232
262
  watch(() => props.modelValue, (next) => {
233
263
  if (next === markdown.value) return;
234
264
  markdown.value = next;
235
- html.value = fromMarkdown(next);
265
+ html.value = fromMarkdown(next, options.value);
266
+ });
267
+ watch(() => props.imageBase, () => {
268
+ html.value = fromMarkdown(markdown.value, options.value);
236
269
  });
237
270
  function commit(next) {
238
271
  if (next === markdown.value) return;
@@ -242,14 +275,14 @@ var MarkdownEditor_default = /* @__PURE__ */ defineComponent({
242
275
  }
243
276
  function onHtml(next) {
244
277
  html.value = next;
245
- commit(toMarkdown(next));
278
+ commit(toMarkdown(next, options.value));
246
279
  }
247
280
  function onSource(event) {
248
281
  commit(event.target.value);
249
282
  }
250
283
  function setMode(next) {
251
284
  if (next === mode.value) return;
252
- if (next === "visual") html.value = fromMarkdown(markdown.value);
285
+ if (next === "visual") html.value = fromMarkdown(markdown.value, options.value);
253
286
  mode.value = next;
254
287
  }
255
288
  /** Text that reads as Markdown: pasted into the visual editor, it arrives formatted. */
@@ -262,7 +295,7 @@ var MarkdownEditor_default = /* @__PURE__ */ defineComponent({
262
295
  if (!text || !MARKDOWN_HINT.test(text)) return;
263
296
  event.preventDefault();
264
297
  event.stopPropagation();
265
- document.execCommand("insertHTML", false, fromMarkdown(text));
298
+ document.execCommand("insertHTML", false, fromMarkdown(text, options.value));
266
299
  }
267
300
  function onTab(event) {
268
301
  if (event.key !== "Tab" || event.shiftKey || event.ctrlKey || event.metaKey || event.altKey) return;
@@ -280,7 +313,7 @@ var MarkdownEditor_default = /* @__PURE__ */ defineComponent({
280
313
  function insert(text) {
281
314
  if (mode.value === "visual") {
282
315
  focus();
283
- document.execCommand("insertHTML", false, fromMarkdown(text));
316
+ document.execCommand("insertHTML", false, fromMarkdown(text, options.value));
284
317
  return;
285
318
  }
286
319
  const el = area.value;
@@ -361,11 +394,18 @@ var MarkdownEditor_default = /* @__PURE__ */ defineComponent({
361
394
  "aria-label": t.value.preview
362
395
  }, [createVNode(unref(UidMarkdown), {
363
396
  source: markdown.value,
364
- "block-renderers": __props.blockRenderers
365
- }, null, 8, ["source", "block-renderers"])], 8, _hoisted_6)) : createCommentVNode("", true)]))], 6);
397
+ "block-renderers": __props.blockRenderers,
398
+ "image-base": __props.imageBase,
399
+ "link-base": __props.linkBase
400
+ }, null, 8, [
401
+ "source",
402
+ "block-renderers",
403
+ "image-base",
404
+ "link-base"
405
+ ])], 8, _hoisted_6)) : createCommentVNode("", true)]))], 6);
366
406
  };
367
407
  }
368
408
  });
369
- export { MarkdownEditor_default as MarkdownEditor, SOURCE_LANG, fromMarkdown, labelsFor, markdownEditorLocales, normalize, splitBlocks, toMarkdown };
409
+ export { MarkdownEditor_default as MarkdownEditor, SOURCE_LANG, fromMarkdown, labelsFor, markdownEditorLocales, normalize, relativeTo, splitBlocks, toMarkdown };
370
410
 
371
411
  //# sourceMappingURL=index.mjs.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dskripchenko/markdown-editor",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "A Markdown editor for Vue 3 that edits like a word processor: the value is Markdown, the editing is the @dskripchenko/wysiwyg engine — its toolbar, slash menu, shortcuts and live blocks — with source and side-by-side modes, and a lossless round trip",
5
5
  "keywords": [
6
6
  "markdown",