@dskripchenko/markdown-editor 0.1.0 → 0.2.1

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,17 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.1
4
+
5
+ ### Patch Changes
6
+
7
+ - dc2c5a0: `image-base` without a scheme (`/media/`): pictures shown from it are saved back relative as well.
8
+
9
+ ## 0.2.0
10
+
11
+ ### Minor Changes
12
+
13
+ - 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.
14
+
3
15
  ## 0.1.0
4
16
 
5
17
  ### Minor Changes
package/dist/index.cjs CHANGED
@@ -122,12 +122,36 @@ 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
+ const PLACEHOLDER = "http://base.invalid/";
132
+ let u;
133
+ let b;
134
+ try {
135
+ b = new URL(base, PLACEHOLDER);
136
+ u = new URL(url, b);
137
+ } catch {
138
+ return url;
139
+ }
140
+ if (u.protocol !== b.protocol || u.host !== b.host) return url;
141
+ const from = b.pathname.split("/").slice(0, -1);
142
+ const to = u.pathname.split("/");
143
+ let i = 0;
144
+ while (i < from.length && i < to.length - 1 && from[i] === to[i]) i++;
145
+ if (i === 0 && from.length > 1) return url;
146
+ const rel = [...Array(from.length - i).fill(".."), ...to.slice(i)].join("/");
147
+ return decodeURI(rel) + u.search + u.hash;
148
+ }
125
149
  /** Markdown → the HTML the editor edits; source blocks become code blocks of {@link SOURCE_LANG}. */
126
- function fromMarkdown(markdown) {
150
+ function fromMarkdown(markdown, options = {}) {
127
151
  const out = [];
128
152
  let pending = [];
129
153
  const flush = () => {
130
- if (pending.length) out.push((0, _dskripchenko_ui.renderMarkdown)(pending.join("\n\n")));
154
+ if (pending.length) out.push((0, _dskripchenko_ui.renderMarkdown)(pending.join("\n\n"), options.imageBase ? { imageBase: options.imageBase } : {}));
131
155
  pending = [];
132
156
  };
133
157
  for (const block of splitBlocks(markdown)) {
@@ -142,14 +166,18 @@ function fromMarkdown(markdown) {
142
166
  return out.join("\n");
143
167
  }
144
168
  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);
169
+ /** The editor's HTML → Markdown; source blocks come back as written, pictures by their relative paths. */
170
+ function toMarkdown(html, options = {}) {
171
+ const base = options.imageBase;
172
+ const relative = base ? html.replace(/(<img\b[^>]*?\ssrc=")([^"]*)(")/gi, (_m, head, src, tail) => {
173
+ return head + relativeTo(src.replace(/&amp;/g, "&"), base).replace(/&/g, "&amp;") + tail;
174
+ }) : html;
175
+ const md = (0, _dskripchenko_wysiwyg.htmlToMarkdown)(relative).replace(SOURCE_FENCE, (_m, _fence, text) => text);
148
176
  return md === "" ? "" : `${md}\n`;
149
177
  }
150
178
  /** One round: what saving a document unchanged in the editor gives. */
151
- function normalize(markdown) {
152
- return toMarkdown(fromMarkdown(markdown));
179
+ function normalize(markdown, options = {}) {
180
+ return toMarkdown(fromMarkdown(markdown, options), options);
153
181
  }
154
182
  // src/locale.ts
155
183
  var en = {
@@ -211,7 +239,9 @@ var MarkdownEditor_default = /* @__PURE__ */ (0, vue.defineComponent)({
211
239
  toolbarItems: { default: void 0 },
212
240
  slashCommands: { default: void 0 },
213
241
  locale: { default: void 0 },
214
- labels: { default: void 0 }
242
+ labels: { default: void 0 },
243
+ imageBase: { default: void 0 },
244
+ linkBase: { default: void 0 }
215
245
  }, {
216
246
  "mode": { default: "visual" },
217
247
  "modeModifiers": {}
@@ -226,14 +256,18 @@ var MarkdownEditor_default = /* @__PURE__ */ (0, vue.defineComponent)({
226
256
  ...labelsFor(props.locale ?? kitLocale.value.code),
227
257
  ...props.labels
228
258
  }));
259
+ const options = (0, vue.computed)(() => ({ imageBase: props.imageBase ?? null }));
229
260
  const markdown = (0, vue.ref)(props.modelValue);
230
- const html = (0, vue.ref)(fromMarkdown(props.modelValue));
261
+ const html = (0, vue.ref)(fromMarkdown(props.modelValue, options.value));
231
262
  const wysiwyg = (0, vue.ref)(null);
232
263
  const area = (0, vue.ref)(null);
233
264
  (0, vue.watch)(() => props.modelValue, (next) => {
234
265
  if (next === markdown.value) return;
235
266
  markdown.value = next;
236
- html.value = fromMarkdown(next);
267
+ html.value = fromMarkdown(next, options.value);
268
+ });
269
+ (0, vue.watch)(() => props.imageBase, () => {
270
+ html.value = fromMarkdown(markdown.value, options.value);
237
271
  });
238
272
  function commit(next) {
239
273
  if (next === markdown.value) return;
@@ -243,14 +277,14 @@ var MarkdownEditor_default = /* @__PURE__ */ (0, vue.defineComponent)({
243
277
  }
244
278
  function onHtml(next) {
245
279
  html.value = next;
246
- commit(toMarkdown(next));
280
+ commit(toMarkdown(next, options.value));
247
281
  }
248
282
  function onSource(event) {
249
283
  commit(event.target.value);
250
284
  }
251
285
  function setMode(next) {
252
286
  if (next === mode.value) return;
253
- if (next === "visual") html.value = fromMarkdown(markdown.value);
287
+ if (next === "visual") html.value = fromMarkdown(markdown.value, options.value);
254
288
  mode.value = next;
255
289
  }
256
290
  /** Text that reads as Markdown: pasted into the visual editor, it arrives formatted. */
@@ -263,7 +297,7 @@ var MarkdownEditor_default = /* @__PURE__ */ (0, vue.defineComponent)({
263
297
  if (!text || !MARKDOWN_HINT.test(text)) return;
264
298
  event.preventDefault();
265
299
  event.stopPropagation();
266
- document.execCommand("insertHTML", false, fromMarkdown(text));
300
+ document.execCommand("insertHTML", false, fromMarkdown(text, options.value));
267
301
  }
268
302
  function onTab(event) {
269
303
  if (event.key !== "Tab" || event.shiftKey || event.ctrlKey || event.metaKey || event.altKey) return;
@@ -281,7 +315,7 @@ var MarkdownEditor_default = /* @__PURE__ */ (0, vue.defineComponent)({
281
315
  function insert(text) {
282
316
  if (mode.value === "visual") {
283
317
  focus();
284
- document.execCommand("insertHTML", false, fromMarkdown(text));
318
+ document.execCommand("insertHTML", false, fromMarkdown(text, options.value));
285
319
  return;
286
320
  }
287
321
  const el = area.value;
@@ -362,8 +396,15 @@ var MarkdownEditor_default = /* @__PURE__ */ (0, vue.defineComponent)({
362
396
  "aria-label": t.value.preview
363
397
  }, [(0, vue.createVNode)((0, vue.unref)(_dskripchenko_ui.UidMarkdown), {
364
398
  source: markdown.value,
365
- "block-renderers": __props.blockRenderers
366
- }, null, 8, ["source", "block-renderers"])], 8, _hoisted_6)) : (0, vue.createCommentVNode)("", true)]))], 6);
399
+ "block-renderers": __props.blockRenderers,
400
+ "image-base": __props.imageBase,
401
+ "link-base": __props.linkBase
402
+ }, null, 8, [
403
+ "source",
404
+ "block-renderers",
405
+ "image-base",
406
+ "link-base"
407
+ ])], 8, _hoisted_6)) : (0, vue.createCommentVNode)("", true)]))], 6);
367
408
  };
368
409
  }
369
410
  });
@@ -373,6 +414,7 @@ exports.fromMarkdown = fromMarkdown;
373
414
  exports.labelsFor = labelsFor;
374
415
  exports.markdownEditorLocales = markdownEditorLocales;
375
416
  exports.normalize = normalize;
417
+ exports.relativeTo = relativeTo;
376
418
  exports.splitBlocks = splitBlocks;
377
419
  exports.toMarkdown = toMarkdown;
378
420
 
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,36 @@ 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
+ const PLACEHOLDER = "http://base.invalid/";
131
+ let u;
132
+ let b;
133
+ try {
134
+ b = new URL(base, PLACEHOLDER);
135
+ u = new URL(url, b);
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
+ }
124
148
  /** Markdown → the HTML the editor edits; source blocks become code blocks of {@link SOURCE_LANG}. */
125
- function fromMarkdown(markdown) {
149
+ function fromMarkdown(markdown, options = {}) {
126
150
  const out = [];
127
151
  let pending = [];
128
152
  const flush = () => {
129
- if (pending.length) out.push(renderMarkdown(pending.join("\n\n")));
153
+ if (pending.length) out.push(renderMarkdown(pending.join("\n\n"), options.imageBase ? { imageBase: options.imageBase } : {}));
130
154
  pending = [];
131
155
  };
132
156
  for (const block of splitBlocks(markdown)) {
@@ -141,14 +165,18 @@ function fromMarkdown(markdown) {
141
165
  return out.join("\n");
142
166
  }
143
167
  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);
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 = htmlToMarkdown(relative).replace(SOURCE_FENCE, (_m, _fence, text) => text);
147
175
  return md === "" ? "" : `${md}\n`;
148
176
  }
149
177
  /** One round: what saving a document unchanged in the editor gives. */
150
- function normalize(markdown) {
151
- return toMarkdown(fromMarkdown(markdown));
178
+ function normalize(markdown, options = {}) {
179
+ return toMarkdown(fromMarkdown(markdown, options), options);
152
180
  }
153
181
  // src/locale.ts
154
182
  var en = {
@@ -210,7 +238,9 @@ var MarkdownEditor_default = /* @__PURE__ */ defineComponent({
210
238
  toolbarItems: { default: void 0 },
211
239
  slashCommands: { default: void 0 },
212
240
  locale: { default: void 0 },
213
- labels: { default: void 0 }
241
+ labels: { default: void 0 },
242
+ imageBase: { default: void 0 },
243
+ linkBase: { default: void 0 }
214
244
  }, {
215
245
  "mode": { default: "visual" },
216
246
  "modeModifiers": {}
@@ -225,14 +255,18 @@ var MarkdownEditor_default = /* @__PURE__ */ defineComponent({
225
255
  ...labelsFor(props.locale ?? kitLocale.value.code),
226
256
  ...props.labels
227
257
  }));
258
+ const options = computed(() => ({ imageBase: props.imageBase ?? null }));
228
259
  const markdown = ref(props.modelValue);
229
- const html = ref(fromMarkdown(props.modelValue));
260
+ const html = ref(fromMarkdown(props.modelValue, options.value));
230
261
  const wysiwyg = ref(null);
231
262
  const area = ref(null);
232
263
  watch(() => props.modelValue, (next) => {
233
264
  if (next === markdown.value) return;
234
265
  markdown.value = next;
235
- html.value = fromMarkdown(next);
266
+ html.value = fromMarkdown(next, options.value);
267
+ });
268
+ watch(() => props.imageBase, () => {
269
+ html.value = fromMarkdown(markdown.value, options.value);
236
270
  });
237
271
  function commit(next) {
238
272
  if (next === markdown.value) return;
@@ -242,14 +276,14 @@ var MarkdownEditor_default = /* @__PURE__ */ defineComponent({
242
276
  }
243
277
  function onHtml(next) {
244
278
  html.value = next;
245
- commit(toMarkdown(next));
279
+ commit(toMarkdown(next, options.value));
246
280
  }
247
281
  function onSource(event) {
248
282
  commit(event.target.value);
249
283
  }
250
284
  function setMode(next) {
251
285
  if (next === mode.value) return;
252
- if (next === "visual") html.value = fromMarkdown(markdown.value);
286
+ if (next === "visual") html.value = fromMarkdown(markdown.value, options.value);
253
287
  mode.value = next;
254
288
  }
255
289
  /** Text that reads as Markdown: pasted into the visual editor, it arrives formatted. */
@@ -262,7 +296,7 @@ var MarkdownEditor_default = /* @__PURE__ */ defineComponent({
262
296
  if (!text || !MARKDOWN_HINT.test(text)) return;
263
297
  event.preventDefault();
264
298
  event.stopPropagation();
265
- document.execCommand("insertHTML", false, fromMarkdown(text));
299
+ document.execCommand("insertHTML", false, fromMarkdown(text, options.value));
266
300
  }
267
301
  function onTab(event) {
268
302
  if (event.key !== "Tab" || event.shiftKey || event.ctrlKey || event.metaKey || event.altKey) return;
@@ -280,7 +314,7 @@ var MarkdownEditor_default = /* @__PURE__ */ defineComponent({
280
314
  function insert(text) {
281
315
  if (mode.value === "visual") {
282
316
  focus();
283
- document.execCommand("insertHTML", false, fromMarkdown(text));
317
+ document.execCommand("insertHTML", false, fromMarkdown(text, options.value));
284
318
  return;
285
319
  }
286
320
  const el = area.value;
@@ -361,11 +395,18 @@ var MarkdownEditor_default = /* @__PURE__ */ defineComponent({
361
395
  "aria-label": t.value.preview
362
396
  }, [createVNode(unref(UidMarkdown), {
363
397
  source: markdown.value,
364
- "block-renderers": __props.blockRenderers
365
- }, null, 8, ["source", "block-renderers"])], 8, _hoisted_6)) : 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)) : createCommentVNode("", true)]))], 6);
366
407
  };
367
408
  }
368
409
  });
369
- export { MarkdownEditor_default as MarkdownEditor, SOURCE_LANG, fromMarkdown, labelsFor, markdownEditorLocales, normalize, splitBlocks, toMarkdown };
410
+ export { MarkdownEditor_default as MarkdownEditor, SOURCE_LANG, fromMarkdown, labelsFor, markdownEditorLocales, normalize, relativeTo, splitBlocks, toMarkdown };
370
411
 
371
412
  //# 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.1",
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",