@remigius42/morg 0.9.1 → 0.10.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/README.md CHANGED
@@ -6,7 +6,7 @@ Copyright 2026 [Andreas Remigius Schmidt](https://github.com/remigius42)
6
6
  [![Changelog](https://img.shields.io/github/v/tag/remigius42/morg?label=changelog)](https://github.com/remigius42/morg/blob/main/CHANGELOG.md)
7
7
  [![License](https://img.shields.io/badge/license-GPL--3.0--or--later-blue.svg)](LICENSE)
8
8
  [![CI](https://github.com/remigius42/morg/actions/workflows/ci.yml/badge.svg)](https://github.com/remigius42/morg/actions/workflows/ci.yml)
9
- ![Node](https://img.shields.io/badge/node-%3E%3D20-lightgrey.svg)
9
+ ![Node](https://img.shields.io/badge/node-%3E%3D24-lightgrey.svg)
10
10
  [![Codacy grade](https://app.codacy.com/project/badge/Grade/da438d1b90e74d40b03f9fa5b3eca221)](https://app.codacy.com/gh/remigius42/morg/dashboard?utm_source=gh&utm_medium=referral&utm_content=&utm_campaign=Badge_grade)
11
11
  [![Codacy coverage](https://app.codacy.com/project/badge/Coverage/da438d1b90e74d40b03f9fa5b3eca221)](https://app.codacy.com/gh/remigius42/morg/dashboard?utm_source=gh&utm_medium=referral&utm_content=&utm_campaign=Badge_coverage)
12
12
 
@@ -18,7 +18,9 @@ Bidirectional **Markdown ↔ Org-mode** converter, built on the
18
18
  morg treats Org as a canonical plain-text format and Markdown (Obsidian,
19
19
  generic) as the interop surface. Dialect conventions, such as
20
20
  [Logseq](https://docs.logseq.com/)'s outline of blocks and page
21
- properties, are supported via presets.
21
+ properties, are supported via presets. Within one format, morg
22
+ translates between two dialects (Logseq Markdown ↔ Markdown, Obsidian
23
+ ↔ Logseq), changing only what they write differently.
22
24
 
23
25
  ## Round-trip convergence
24
26
 
@@ -63,6 +65,7 @@ happens in your browser, nothing is uploaded (see [ADR
63
65
  0003](docs/adr/0003-client-side-web-ui-on-github-pages.md)). Each side
64
66
  names its format and dialect (Input: Org (Logseq), Output: Markdown);
65
67
  the direction follows from the two, the same on both sides normalizes,
68
+ two dialects of one format translate (Markdown (Logseq) → Markdown),
66
69
  and ⇄ swaps them. The
67
70
  chrome-less embed page (`/embed.html`, optionally with
68
71
  `?theme=dark|light`) can be iframed into other sites. It posts its
@@ -90,7 +93,8 @@ both at once and each goes where it belongs; an overlay names what is
90
93
  accepted while a drag is in flight, and anything that turns out not to
91
94
  be text is named in the warning list rather than loaded. The result can
92
95
  be copied or saved with the Copy and Download buttons; a normalized file
93
- is saved as `notes.normalized.org`, and switching to a direction that no
96
+ is saved as `notes.normalized.org`, a translated one under its dialect
97
+ (`page.vanilla.md`), and switching to a direction that no
94
98
  longer reads the opened file falls back to a generic name, so neither
95
99
  lands on top of its own source. Files are read and written by the
96
100
  browser itself; this is not an upload.
@@ -137,10 +141,14 @@ morg --input notes.md --output notes.org --silent
137
141
  # canonicalizing it; markers used inconsistently warn and are skipped
138
142
  morg --input notes.md --output notes.org --record-style
139
143
 
140
- # Normalize to canonical form (same format in and out); this
144
+ # Translate between two dialects of one format, changing only what
145
+ # they write differently (a block's content stays as written)
146
+ morg --input-preset logseq --input page.md --output page.vanilla.md
147
+
148
+ # Normalize to canonical form (same format and preset in and out); this
141
149
  # canonicalizes (the one-time reformat a first conversion would apply
142
150
  # anyway, ADR 0001); it is not a style formatter like prettier
143
- morg normalize --input notes.org --output notes.org
151
+ morg --input notes.org --output notes.org
144
152
  ```
145
153
 
146
154
  ### Configuration file
@@ -223,6 +231,9 @@ preset })`: `preserveOrgisms` default `true`; `useHtml` (default
223
231
  `[[((uuid))][label]]`, and `^^highlight^^` markup survives verbatim
224
232
  (it would otherwise re-parse as superscripts).
225
233
  - `obsidian()`: wikilinks `[[Page]]` / `[[Page|alias]]` ↔ org fuzzy links
234
+ (`[[Page\|alias]]` in a table cell). Translated to Vanilla or Logseq
235
+ Markdown, a `%%comment%%` becomes an HTML comment and an inline
236
+ footnote `^[note]` a footnote.
226
237
  - `inputPreset` / `outputPreset` (on both conversions): the dialect
227
238
  the input is read in and the one the output is written in (ADR
228
239
  0006); leaving one out is Vanilla. `preset` sets both, but leaves a
@@ -231,15 +242,27 @@ preset })`: `preserveOrgisms` default `true`; `useHtml` (default
231
242
  `preset` naming another preset than a side preset.
232
243
 
233
244
  - `normalizeMarkdown(md, { preset })` / `normalizeOrg(org, { preset })`
234
- (CLI: `morg normalize`): one full round trip to morg's canonical
245
+ (CLI: one format and preset on both sides): one full round trip to morg's canonical
235
246
  form, a fixed point, within one dialect: different presets per side
236
247
  throw. Canonicalization, not styling: org-isms and
237
248
  md-isms are rewritten exactly as a conversion would rewrite them.
238
249
  Normalize with the same preset/config you will convert with, since
239
250
  convergence is per-config (ADR 0002).
240
251
 
252
+ - `translateOrg(org, { inputPreset, outputPreset })`: translates
253
+ between two org dialects (Logseq org ↔ Vanilla org), changing only
254
+ what they write differently: a block's content stays as written.
255
+ The same preset on both sides throws; that is `normalizeOrg`.
256
+ `translateMarkdown(md, { inputPreset, outputPreset })` does the same
257
+ within Markdown (Logseq md, Obsidian md and Vanilla md, any two);
258
+ `orgismKeys` names the
259
+ `key::` lines Vanilla md writes planning under, as in a conversion,
260
+ and `markdownStyle` rewrites the markers it names, leaving the rest
261
+ as written.
262
+
241
263
  - `markdownStyle: { bullet, emphasis, strong, fence, rule, ruleRepetition }`
242
- (on `convertOrgToMarkdown` and `normalizeMarkdown`; CLI `--bullet`,
264
+ (on `convertOrgToMarkdown`, `normalizeMarkdown` and
265
+ `translateMarkdown`; CLI `--bullet`,
243
266
  `--emphasis`, `--strong`, `--fence`, `--rule`, `--rule-repetition`)
244
267
  are Markdown output style knobs. Defaults match prettier except
245
268
  emphasis (`*italic*`); `--emphasis _` aligns fully with prettier.
@@ -248,7 +271,7 @@ preset })`: `preserveOrgisms` default `true`; `useHtml` (default
248
271
  CommonMark/GFM prescribe no style; these defaults are morg's
249
272
  canonical choices, not a standard.
250
273
 
251
- Both convert functions also accept `onWarning: message => …`, called for
274
+ The convert and translate functions also accept `onWarning: message => …`, called for
252
275
  each construct dropped without an equivalent (e.g. image titles, LaTeX
253
276
  fragments). The CLI wires this to stderr unless `-s` / `--silent` is
254
277
  given; the library is silent unless a callback is passed.
@@ -275,9 +298,10 @@ Project vocabulary lives in [CONTEXT.md](CONTEXT.md); design decisions in
275
298
  The core conversion surface is feature-complete and validated against
276
299
  real-world Logseq org vaults (edge cases found there live on as
277
300
  anonymized fixtures, e.g. `tests/fixtures/logseq-vault.org`), and
278
- the conversions between a Logseq dialect and Vanilla Org or Markdown
279
- are checked by round trips of that vault and of public Markdown and
280
- Org documentation from either side; the
301
+ the conversions between a Logseq dialect and Vanilla Org or Markdown,
302
+ and the translations between two dialects of one format, are checked
303
+ by round trips of that vault and of public Markdown and Org
304
+ documentation from either side; the
281
305
  client-side [Web UI](https://morg.binarypoetry.ch) is deployed from
282
306
  `main`. The npm package is `@remigius42/morg`, since the bare `morg`
283
307
  name is taken, and pushing a `v*` tag publishes it. Most of the code is
@@ -1,5 +1,4 @@
1
1
  export interface CliArgs {
2
- normalize: boolean;
3
2
  help: boolean;
4
3
  version: boolean;
5
4
  fromFormat: string | undefined;
package/dist/cli/args.js CHANGED
@@ -1,14 +1,11 @@
1
1
  import { CliError } from "./error.js";
2
2
  import { FLAGS_BY_NAME } from "./flags.js";
3
3
  export function parseArgs(args) {
4
- // `morg normalize` canonicalizes in place of converting: same format
5
- // in and out, one full round trip (see ADR 0001)
6
- const normalize = args[0] === "normalize";
7
- if (normalize) {
8
- args.shift();
4
+ // gone in 0.10.0: one format on both sides normalizes (ADR 0006)
5
+ if (args[0] === "normalize") {
6
+ throw new CliError("'morg normalize' is gone: the same format and preset on both sides normalizes (--from org --to org)");
9
7
  }
10
8
  const parsed = {
11
- normalize,
12
9
  help: false,
13
10
  version: false,
14
11
  fromFormat: undefined,
@@ -1,6 +1,6 @@
1
1
  import type { MorgConfig } from "../config.js";
2
2
  import type { MarkdownStyleOptions } from "../options.js";
3
- import type { PresetOptions } from "../presets/sides.js";
3
+ import type { Format, PresetOptions } from "../presets/sides.js";
4
4
  import type { CliArgs } from "./args.js";
5
5
  export declare function buildConversionOptions(cli: CliArgs, config: MorgConfig, presets: PresetOptions): {
6
6
  mdToOrgOptions: import("../options.js").MarkdownToOrgOptions;
@@ -8,4 +8,4 @@ export declare function buildConversionOptions(cli: CliArgs, config: MorgConfig,
8
8
  markdownStyle: MarkdownStyleOptions;
9
9
  };
10
10
  };
11
- export declare function convert(inputContent: string, fromFormat: string, normalize: boolean, cli: CliArgs, config: MorgConfig, presets: PresetOptions): string;
11
+ export declare function convert(inputContent: string, fromFormat: Format, toFormat: Format, cli: CliArgs, config: MorgConfig, presets: PresetOptions): string;
@@ -1,6 +1,6 @@
1
1
  import { convertMarkdownToOrg } from "../markdownToOrg.js";
2
2
  import { convertOrgToMarkdown } from "../orgToMarkdown.js";
3
- import { normalizeMarkdown, normalizeOrg } from "../normalize.js";
3
+ import { convertWithinFormat } from "../withinFormat.js";
4
4
  import { buildConversionOptions as layerOptions } from "../conversionOptions.js";
5
5
  import { CliError } from "./error.js";
6
6
  export function buildConversionOptions(cli, config, presets) {
@@ -28,16 +28,14 @@ export function buildConversionOptions(cli, config, presets) {
28
28
  ...(config.orgismKeys && { orgismKeys: config.orgismKeys })
29
29
  });
30
30
  }
31
- export function convert(inputContent, fromFormat, normalize, cli, config, presets) {
31
+ export function convert(inputContent, fromFormat, toFormat, cli, config, presets) {
32
32
  try {
33
33
  const { mdToOrgOptions, orgToMdOptions } = buildConversionOptions(cli, config, presets);
34
- if (normalize) {
35
- return fromFormat === "markdown"
36
- ? normalizeMarkdown(inputContent, {
37
- ...mdToOrgOptions,
38
- ...orgToMdOptions
39
- })
40
- : normalizeOrg(inputContent, { ...mdToOrgOptions, ...orgToMdOptions });
34
+ if (fromFormat === toFormat) {
35
+ return convertWithinFormat(inputContent, fromFormat, {
36
+ ...mdToOrgOptions,
37
+ ...orgToMdOptions
38
+ });
41
39
  }
42
40
  if (fromFormat === "markdown") {
43
41
  return convertMarkdownToOrg(inputContent, mdToOrgOptions);
@@ -1,4 +1,4 @@
1
1
  import type { CliArgs } from "./args.js";
2
2
  import type { Format } from "../presets/sides.js";
3
3
  export declare function inferFormats(cli: CliArgs): [fromFormat: string | undefined, toFormat: string | undefined];
4
- export declare function validateFormats(fromFormat: string | undefined, toFormat: string | undefined, normalize: boolean): [fromFormat: Format, toFormat: Format];
4
+ export declare function validateFormats(fromFormat: string | undefined, toFormat: string | undefined): [fromFormat: Format, toFormat: Format];
@@ -9,17 +9,11 @@ export function inferFormats(cli) {
9
9
  if (cli.outputFile && !toFormat) {
10
10
  toFormat = formatFromFileName(cli.outputFile);
11
11
  }
12
- return inferMissingFormat(cli.normalize, fromFormat, toFormat);
12
+ return inferMissingFormat(fromFormat, toFormat);
13
13
  }
14
14
  // Infer missing format based on the other
15
- function inferMissingFormat(normalize, fromFormat, toFormat) {
16
- if (normalize) {
17
- // mirror whichever side is known; a conflict between the two is left
18
- // intact for validateFormats to reject rather than silently overwritten
19
- fromFormat = fromFormat ?? toFormat;
20
- toFormat = toFormat ?? fromFormat;
21
- }
22
- else if (fromFormat && !toFormat) {
15
+ function inferMissingFormat(fromFormat, toFormat) {
16
+ if (fromFormat && !toFormat) {
23
17
  toFormat = fromFormat === "markdown" ? "org" : "markdown";
24
18
  }
25
19
  else if (toFormat && !fromFormat) {
@@ -36,7 +30,7 @@ function fileNameHint(flag, value) {
36
30
  const fileFlag = flag === "--from" ? "--input" : "--output";
37
31
  return `\n${flag} takes a format name; for a file use ${fileFlag} ${value}.`;
38
32
  }
39
- export function validateFormats(fromFormat, toFormat, normalize) {
33
+ export function validateFormats(fromFormat, toFormat) {
40
34
  if (!fromFormat || !toFormat) {
41
35
  throw new CliError("Error: Could not determine conversion formats.\n" +
42
36
  "Please specify --from and --to, or provide input/output files with .md or .org extensions.");
@@ -50,12 +44,5 @@ export function validateFormats(fromFormat, toFormat, normalize) {
50
44
  `'markdown' and 'org'.${fileNameHint(flag, value)}`);
51
45
  }
52
46
  }
53
- if (!normalize && fromFormat === toFormat) {
54
- throw new CliError("Error: Source and target formats cannot be the same.");
55
- }
56
- if (normalize && fromFormat !== toFormat) {
57
- throw new CliError("Error: normalize reads and writes the same format; " +
58
- `got '${fromFormat}' and '${toFormat}'.`);
59
- }
60
47
  return [fromFormat, toFormat];
61
48
  }
package/dist/cli/help.js CHANGED
@@ -5,15 +5,13 @@ const DESCRIPTION_COLUMN = Math.max(...FLAGS.map(spec => signature(spec).length)
5
5
  const section = (specs) => specs
6
6
  .map(spec => ` ${signature(spec).padEnd(DESCRIPTION_COLUMN)}${spec.description}`)
7
7
  .join("\n");
8
- export const HELP_TEXT = `Usage: morg [normalize] [options]
8
+ export const HELP_TEXT = `Usage: morg [options]
9
9
 
10
10
  Convert between Markdown and Org-mode. Reads stdin and writes stdout
11
11
  unless --input/--output are given; formats are inferred from the .md and
12
12
  .org file extensions, so --from/--to are only needed for stdin or stdout.
13
-
14
- Commands:
15
- ${"normalize".padEnd(DESCRIPTION_COLUMN)}Round-trip a document through the other format
16
- ${"".padEnd(DESCRIPTION_COLUMN)}and back, canonicalizing it in its own format
13
+ One format on both sides translates between two presets' dialects, and
14
+ with one preset normalizes: a round trip through the other format.
17
15
 
18
16
  Options:
19
17
  ${section(FLAGS.filter(spec => spec.kind !== "style"))}
@@ -24,5 +22,6 @@ ${section(FLAGS.filter(spec => spec.kind === "style"))}
24
22
  Examples:
25
23
  morg --input notes.md --output notes.org
26
24
  morg --from markdown < notes.md > notes.org
27
- morg normalize --input notes.org --output notes.org
25
+ morg --input-preset logseq --input page.md --output page.vanilla.md
26
+ morg --input notes.org --output notes.org
28
27
  `;
package/dist/cli.js CHANGED
@@ -46,10 +46,10 @@ async function main() {
46
46
  return;
47
47
  }
48
48
  const config = loadConfig(cli.configPath);
49
- const [fromFormat, toFormat] = validateFormats(...inferFormats(cli), cli.normalize);
49
+ const [fromFormat, toFormat] = validateFormats(...inferFormats(cli));
50
50
  const presets = resolvePresets(cli, config, fromFormat, toFormat);
51
51
  const inputContent = await readInput(cli.inputFile);
52
- const outputContent = convert(inputContent, fromFormat, cli.normalize, cli, config, presets);
52
+ const outputContent = convert(inputContent, fromFormat, toFormat, cli, config, presets);
53
53
  if (cli.outputFile) {
54
54
  fs.writeFileSync(cli.outputFile, outputContent, "utf8");
55
55
  }
@@ -40,9 +40,9 @@ export interface PresetNames {
40
40
  * per side, the first layer that sets the side or `preset` wins.
41
41
  * @param layers Preset names by layer, highest first.
42
42
  * @param from The input's format.
43
- * @param to The output's format; the input's for normalizing.
43
+ * @param to The output's format.
44
44
  * @returns The conversion's preset options.
45
45
  * @throws If a layer sets `preset` and another side preset, or a side
46
- * preset has no dialect for its side's format, or normalizing names two.
46
+ * preset has no dialect for its side's format.
47
47
  */
48
48
  export declare function resolvePresetOptions(layers: PresetNames[], from: Format, to: Format): PresetOptions;
@@ -60,13 +60,14 @@ function sidePreset(name, format, side) {
60
60
  ? resolveSide(undefined, preset, format, side)
61
61
  : resolveSide(preset, undefined, format, side);
62
62
  }
63
- // normalizing goes there and back within one dialect (ADR 0006), so
64
- // both sides must name the same preset, which then sets both ways
63
+ // one preset on both sides of one format normalizes, going there and
64
+ // back within its dialect, so it sets both ways of the trip; two
65
+ // translate (ADR 0006)
65
66
  function normalizePresetOptions(layers) {
66
67
  const input = sideName(layers, "inputPreset")?.name ?? "vanilla";
67
68
  const output = sideName(layers, "outputPreset")?.name ?? "vanilla";
68
69
  if (input !== output) {
69
- throw new Error(`normalize takes one preset; got inputPreset '${input}' and outputPreset '${output}'`);
70
+ return undefined;
70
71
  }
71
72
  const preset = createPreset(input);
72
73
  return preset ? { preset } : {};
@@ -76,15 +77,16 @@ function normalizePresetOptions(layers) {
76
77
  * per side, the first layer that sets the side or `preset` wins.
77
78
  * @param layers Preset names by layer, highest first.
78
79
  * @param from The input's format.
79
- * @param to The output's format; the input's for normalizing.
80
+ * @param to The output's format.
80
81
  * @returns The conversion's preset options.
81
82
  * @throws If a layer sets `preset` and another side preset, or a side
82
- * preset has no dialect for its side's format, or normalizing names two.
83
+ * preset has no dialect for its side's format.
83
84
  */
84
85
  export function resolvePresetOptions(layers, from, to) {
85
86
  layers.forEach(rejectConflict);
86
- if (from === to) {
87
- return normalizePresetOptions(layers);
87
+ const normalize = from === to ? normalizePresetOptions(layers) : undefined;
88
+ if (normalize) {
89
+ return normalize;
88
90
  }
89
91
  const input = sidePreset(sideName(layers, "inputPreset"), from, "input");
90
92
  const output = sidePreset(sideName(layers, "outputPreset"), to, "output");
@@ -0,0 +1,17 @@
1
+ /**
2
+ * A Markdown string with its code, math, frontmatter and raw HTML
3
+ * blanked out (NUL characters, one per character), so a scan for syntax
4
+ * finds it outside them only, at the offsets it has in the string.
5
+ * @param markdown The Markdown string.
6
+ * @returns The masked string, as long as the input.
7
+ */
8
+ export declare function maskCode(markdown: string): string;
9
+ /**
10
+ * Maps a Markdown string's text, each stretch between code, math,
11
+ * frontmatter and raw HTML on its own, leaving those as written; a
12
+ * stretch ends where a table starts or ends too.
13
+ * @param markdown The Markdown string.
14
+ * @param map What a stretch of text becomes, told if it is in a table.
15
+ * @returns The mapped Markdown string.
16
+ */
17
+ export declare function mapOutsideCode(markdown: string, map: (text: string, inTable: boolean) => string): string;
@@ -0,0 +1,98 @@
1
+ import { unified } from "unified";
2
+ import remarkParse from "remark-parse";
3
+ import remarkGfm from "remark-gfm";
4
+ import remarkFrontmatter from "remark-frontmatter";
5
+ import remarkMath from "remark-math";
6
+ // what holds no Markdown text: code, math, frontmatter and raw HTML
7
+ const VERBATIM = new Set([
8
+ "code",
9
+ "inlineCode",
10
+ "math",
11
+ "inlineMath",
12
+ "yaml",
13
+ "html"
14
+ ]);
15
+ function verbatimRanges(node, ranges) {
16
+ if (VERBATIM.has(node.type)) {
17
+ ranges.push([
18
+ node.position?.start.offset ?? 0,
19
+ node.position?.end.offset ?? 0
20
+ ]);
21
+ return;
22
+ }
23
+ for (const child of node.children ?? []) {
24
+ verbatimRanges(child, ranges);
25
+ }
26
+ }
27
+ function parse(markdown) {
28
+ return unified()
29
+ .use(remarkParse)
30
+ .use(remarkGfm)
31
+ .use(remarkFrontmatter)
32
+ .use(remarkMath)
33
+ .parse(markdown);
34
+ }
35
+ function mask(markdown, tree) {
36
+ const ranges = [];
37
+ verbatimRanges(tree, ranges);
38
+ let masked = "";
39
+ let from = 0;
40
+ for (const [start, end] of ranges) {
41
+ masked += markdown.slice(from, start) + "\0".repeat(end - start);
42
+ from = end;
43
+ }
44
+ return masked + markdown.slice(from);
45
+ }
46
+ /**
47
+ * A Markdown string with its code, math, frontmatter and raw HTML
48
+ * blanked out (NUL characters, one per character), so a scan for syntax
49
+ * finds it outside them only, at the offsets it has in the string.
50
+ * @param markdown The Markdown string.
51
+ * @returns The masked string, as long as the input.
52
+ */
53
+ export function maskCode(markdown) {
54
+ return mask(markdown, parse(markdown));
55
+ }
56
+ function tableRanges(node, ranges) {
57
+ if (node.type === "table") {
58
+ ranges.push([
59
+ node.position?.start.offset ?? 0,
60
+ node.position?.end.offset ?? 0
61
+ ]);
62
+ return;
63
+ }
64
+ for (const child of node.children ?? []) {
65
+ tableRanges(child, ranges);
66
+ }
67
+ }
68
+ /**
69
+ * Maps a Markdown string's text, each stretch between code, math,
70
+ * frontmatter and raw HTML on its own, leaving those as written; a
71
+ * stretch ends where a table starts or ends too.
72
+ * @param markdown The Markdown string.
73
+ * @param map What a stretch of text becomes, told if it is in a table.
74
+ * @returns The mapped Markdown string.
75
+ */
76
+ export function mapOutsideCode(markdown, map) {
77
+ const tree = parse(markdown);
78
+ const tables = [];
79
+ tableRanges(tree, tables);
80
+ const bounds = tables.flat();
81
+ return [...mask(markdown, tree).matchAll(/\0+|[^\0]+/g)]
82
+ .map(({ 0: run, index }) => {
83
+ const end = index + run.length;
84
+ if (run.startsWith("\0")) {
85
+ return markdown.slice(index, end);
86
+ }
87
+ const cuts = [index, ...bounds.filter(b => b > index && b < end), end];
88
+ return cuts
89
+ .slice(1)
90
+ .map((to, i) => {
91
+ const from = cuts[i] ?? index;
92
+ const inTable = tables.some(([s, e]) => from >= s && from < e);
93
+ return map(markdown.slice(from, to), inTable);
94
+ })
95
+ .join("");
96
+ })
97
+ .join("");
98
+ }
@@ -0,0 +1,9 @@
1
+ import type { MarkdownStyleOptions } from "../options.js";
2
+ /**
3
+ * Rewrites the markers a style names, and nothing else: the rest of the
4
+ * Markdown stays as written, where a stringifier would canonicalize it.
5
+ * @param markdown The Markdown string.
6
+ * @param style The markers to write.
7
+ * @returns The Markdown string with those markers.
8
+ */
9
+ export declare function restyleMarkdown(markdown: string, style: MarkdownStyleOptions): string;
@@ -0,0 +1,110 @@
1
+ import { unified } from "unified";
2
+ import remarkParse from "remark-parse";
3
+ import remarkGfm from "remark-gfm";
4
+ import remarkFrontmatter from "remark-frontmatter";
5
+ import remarkMath from "remark-math";
6
+ import { visit } from "unist-util-visit";
7
+ function offsets(node) {
8
+ return [node.position?.start.offset ?? 0, node.position?.end.offset ?? 0];
9
+ }
10
+ function bulletEdits(tree, bullet) {
11
+ const edits = [];
12
+ visit(tree, "list", (list) => {
13
+ if (list.ordered) {
14
+ return;
15
+ }
16
+ for (const item of list.children) {
17
+ const [start] = offsets(item);
18
+ edits.push([start, start + 1, bullet]);
19
+ }
20
+ });
21
+ return edits;
22
+ }
23
+ // `_` closes no emphasis within a word
24
+ function inWord(markdown, start, end) {
25
+ return /\w/.test(markdown[start - 1] ?? "") || /\w/.test(markdown[end] ?? "");
26
+ }
27
+ function delimiterEdits(tree, markdown, type, marker) {
28
+ const width = type === "strong" ? 2 : 1;
29
+ const edits = [];
30
+ visit(tree, type, (node) => {
31
+ const [start, end] = offsets(node);
32
+ if (markdown[start] === marker ||
33
+ (marker === "_" && inWord(markdown, start, end))) {
34
+ return;
35
+ }
36
+ const markers = marker.repeat(width);
37
+ edits.push([start, start + width, markers], [end - width, end, markers]);
38
+ });
39
+ return edits;
40
+ }
41
+ const FENCE_RE = /^ {0,3}(`{3,}|~{3,})/;
42
+ // a fenced block's opening and closing fence, unless its code holds a
43
+ // run of the new marker, which could close it
44
+ function fenceEdits(tree, markdown, fence) {
45
+ const edits = [];
46
+ visit(tree, "code", (node) => {
47
+ const [start, end] = offsets(node);
48
+ const source = markdown.slice(start, end);
49
+ const run = FENCE_RE.exec(source)?.[1];
50
+ if (!run || run[0] === fence || node.value.includes(fence.repeat(3))) {
51
+ return;
52
+ }
53
+ const markers = fence.repeat(run.length);
54
+ const open = start + source.indexOf(run);
55
+ edits.push([open, open + run.length, markers]);
56
+ // the closing fence may be longer, and missing at the document's end
57
+ const last = source.lastIndexOf("\n") + 1;
58
+ const closing = /^ *([`~]+) *$/.exec(source.slice(last));
59
+ if (last && closing?.[1]) {
60
+ const at = start + last + source.slice(last).indexOf(closing[1]);
61
+ edits.push([at, at + closing[1].length, fence.repeat(closing[1].length)]);
62
+ }
63
+ });
64
+ return edits;
65
+ }
66
+ // a thematic break, as long as it was unless the style says how long
67
+ function ruleEdits(tree, markdown, { rule, ruleRepetition }) {
68
+ const edits = [];
69
+ visit(tree, "thematicBreak", (node) => {
70
+ const [start, end] = offsets(node);
71
+ const source = markdown.slice(start, end).trim();
72
+ const marker = rule ?? source[0] ?? "-";
73
+ const count = ruleRepetition ?? source.replace(/\s/g, "").length;
74
+ edits.push([start, end, marker.repeat(Math.max(3, count))]);
75
+ });
76
+ return edits;
77
+ }
78
+ /**
79
+ * Rewrites the markers a style names, and nothing else: the rest of the
80
+ * Markdown stays as written, where a stringifier would canonicalize it.
81
+ * @param markdown The Markdown string.
82
+ * @param style The markers to write.
83
+ * @returns The Markdown string with those markers.
84
+ */
85
+ export function restyleMarkdown(markdown, style) {
86
+ const tree = unified()
87
+ .use(remarkParse)
88
+ .use(remarkGfm)
89
+ .use(remarkFrontmatter)
90
+ .use(remarkMath)
91
+ .parse(markdown);
92
+ const edits = [
93
+ ...(style.bullet ? bulletEdits(tree, style.bullet) : []),
94
+ ...(style.emphasis
95
+ ? delimiterEdits(tree, markdown, "emphasis", style.emphasis)
96
+ : []),
97
+ ...(style.strong
98
+ ? delimiterEdits(tree, markdown, "strong", style.strong)
99
+ : []),
100
+ ...(style.fence ? fenceEdits(tree, markdown, style.fence) : []),
101
+ ...(style.rule || style.ruleRepetition
102
+ ? ruleEdits(tree, markdown, style)
103
+ : [])
104
+ ];
105
+ let result = markdown;
106
+ for (const [start, end, text] of edits.sort((a, b) => b[0] - a[0])) {
107
+ result = result.slice(0, start) + text + result.slice(end);
108
+ }
109
+ return result;
110
+ }
package/dist/index.d.ts CHANGED
@@ -2,6 +2,8 @@ export { convertMarkdownToOrg } from "./markdownToOrg.js";
2
2
  export { convertOrgToMarkdown } from "./orgToMarkdown.js";
3
3
  export { normalizeMarkdown, normalizeOrg } from "./normalize.js";
4
4
  export type { NormalizeOptions } from "./normalize.js";
5
+ export { translateMarkdown, translateOrg } from "./translate.js";
6
+ export type { TranslateOptions } from "./translate.js";
5
7
  export { parseConfig } from "./config.js";
6
8
  export type { MorgConfig } from "./config.js";
7
9
  export { transformMdastToUniorgAst } from "./core/mdastToUniorg/index.js";
package/dist/index.js CHANGED
@@ -1,6 +1,7 @@
1
1
  export { convertMarkdownToOrg } from "./markdownToOrg.js";
2
2
  export { convertOrgToMarkdown } from "./orgToMarkdown.js";
3
3
  export { normalizeMarkdown, normalizeOrg } from "./normalize.js";
4
+ export { translateMarkdown, translateOrg } from "./translate.js";
4
5
  export { parseConfig } from "./config.js";
5
6
  export { transformMdastToUniorgAst } from "./core/mdastToUniorg/index.js";
6
7
  export { transformUniorgAstToMdast } from "./core/uniorgToMdast/index.js";
@@ -0,0 +1,6 @@
1
+ /**
2
+ * A labeled org fuzzy link in text (`[[Page][label]]`): the form a
3
+ * translation's page links pass through between two Markdown dialects
4
+ * (`MarkdownDialect.links`), as a conversion's pass through org.
5
+ */
6
+ export declare const FUZZY_LINK_RE: RegExp;
@@ -0,0 +1,6 @@
1
+ /**
2
+ * A labeled org fuzzy link in text (`[[Page][label]]`): the form a
3
+ * translation's page links pass through between two Markdown dialects
4
+ * (`MarkdownDialect.links`), as a conversion's pass through org.
5
+ */
6
+ export const FUZZY_LINK_RE = /\[\[([^\][]+)\]\[([^\][]+)\]\]/g;