punctilio 2.0.0 → 2.1.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/README.md CHANGED
@@ -116,6 +116,8 @@ unified()
116
116
  // are both resolved correctly across the element boundary.
117
117
  ```
118
118
 
119
+ For Markdown ASTs via `remark`, use `remarkPunctilio` which applies the same separator technique to preserve inline element boundaries, or use `transformMarkdown` for a simpler Markdown-to-Markdown pipeline.
120
+
119
121
  For manual DOM walking or custom transforms, use `transformElement` from `punctilio/rehype`.
120
122
 
121
123
  ## Options
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Convenience module for Markdown-to-Markdown typography transformations.
3
+ *
4
+ * Provides a single function that takes Markdown input and returns
5
+ * typographically improved Markdown output.
6
+ *
7
+ * Requires `unified`, `remark-parse`, and `remark-stringify` to be installed.
8
+ *
9
+ * @packageDocumentation
10
+ */
11
+ import { type RemarkPunctilioOptions } from "./remark.js";
12
+ /**
13
+ * Options for the Markdown transform pipeline.
14
+ */
15
+ export interface MarkdownOptions extends RemarkPunctilioOptions {
16
+ /**
17
+ * Character for emphasis (`*text*` vs `_text_`) and strong (`**text**` vs `__text__`).
18
+ * Default: "*"
19
+ */
20
+ emphasisMarker?: "*" | "_";
21
+ /**
22
+ * Character for strong emphasis (`**text**` vs `__text__`). Must match emphasisMarker.
23
+ * Default: "*"
24
+ */
25
+ strongMarker?: "*" | "_";
26
+ /** Bullet marker for output consistency. Default: "-" */
27
+ bulletMarker?: "-" | "*" | "+";
28
+ /** Thematic break marker. Default: "-" */
29
+ ruleMarker?: "-" | "*" | "_";
30
+ }
31
+ /**
32
+ * Transforms Markdown text with typographic improvements.
33
+ *
34
+ * Parses the input as Markdown, applies punctilio typography transformations
35
+ * (smart quotes, em-dashes, ellipses, etc.), and serializes back to Markdown.
36
+ *
37
+ * @param input - The Markdown text to transform
38
+ * @param options - Configuration options for both the transform and serializer
39
+ * @returns The typographically improved Markdown text
40
+ *
41
+ * @example
42
+ * ```ts
43
+ * import { transformMarkdown } from 'punctilio/markdown'
44
+ *
45
+ * await transformMarkdown('"Hello" -- world')
46
+ * // → '\u201CHello\u201D\u2014world'
47
+ * ```
48
+ */
49
+ export declare function transformMarkdown(input: string, options?: MarkdownOptions): Promise<string>;
50
+ //# sourceMappingURL=markdown.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"markdown.d.ts","sourceRoot":"","sources":["../src/markdown.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAMH,OAAO,EAAmB,KAAK,sBAAsB,EAAE,MAAM,aAAa,CAAA;AAE1E;;GAEG;AACH,MAAM,WAAW,eAAgB,SAAQ,sBAAsB;IAC7D;;;OAGG;IACH,cAAc,CAAC,EAAE,GAAG,GAAG,GAAG,CAAA;IAE1B;;;OAGG;IACH,YAAY,CAAC,EAAE,GAAG,GAAG,GAAG,CAAA;IAExB,yDAAyD;IACzD,YAAY,CAAC,EAAE,GAAG,GAAG,GAAG,GAAG,GAAG,CAAA;IAE9B,0CAA0C;IAC1C,UAAU,CAAC,EAAE,GAAG,GAAG,GAAG,GAAG,GAAG,CAAA;CAC7B;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAsB,iBAAiB,CACrC,KAAK,EAAE,MAAM,EACb,OAAO,GAAE,eAAoB,GAC5B,OAAO,CAAC,MAAM,CAAC,CAsBjB"}
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Convenience module for Markdown-to-Markdown typography transformations.
3
+ *
4
+ * Provides a single function that takes Markdown input and returns
5
+ * typographically improved Markdown output.
6
+ *
7
+ * Requires `unified`, `remark-parse`, and `remark-stringify` to be installed.
8
+ *
9
+ * @packageDocumentation
10
+ */
11
+ import { unified } from "unified";
12
+ import remarkParse from "remark-parse";
13
+ import remarkStringify from "remark-stringify";
14
+ import { remarkPunctilio } from "./remark.js";
15
+ /**
16
+ * Transforms Markdown text with typographic improvements.
17
+ *
18
+ * Parses the input as Markdown, applies punctilio typography transformations
19
+ * (smart quotes, em-dashes, ellipses, etc.), and serializes back to Markdown.
20
+ *
21
+ * @param input - The Markdown text to transform
22
+ * @param options - Configuration options for both the transform and serializer
23
+ * @returns The typographically improved Markdown text
24
+ *
25
+ * @example
26
+ * ```ts
27
+ * import { transformMarkdown } from 'punctilio/markdown'
28
+ *
29
+ * await transformMarkdown('"Hello" -- world')
30
+ * // → '\u201CHello\u201D\u2014world'
31
+ * ```
32
+ */
33
+ export async function transformMarkdown(input, options = {}) {
34
+ const { emphasisMarker, strongMarker, bulletMarker, ruleMarker, ...punctilioOptions } = options;
35
+ const processor = unified()
36
+ .use(remarkParse)
37
+ .use(remarkPunctilio, punctilioOptions)
38
+ .use(remarkStringify, {
39
+ emphasis: emphasisMarker ?? "*",
40
+ strong: strongMarker ?? "*",
41
+ bullet: bulletMarker ?? "-",
42
+ rule: ruleMarker ?? "-",
43
+ ...(ruleMarker === "-" || ruleMarker === undefined ? { ruleRepetition: 4 } : {}),
44
+ });
45
+ const result = await processor.process(input);
46
+ return String(result);
47
+ }
48
+ //# sourceMappingURL=markdown.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"markdown.js","sourceRoot":"","sources":["../src/markdown.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,OAAO,EAAE,OAAO,EAAE,MAAM,SAAS,CAAA;AACjC,OAAO,WAAW,MAAM,cAAc,CAAA;AACtC,OAAO,eAAe,MAAM,kBAAkB,CAAA;AAE9C,OAAO,EAAE,eAAe,EAA+B,MAAM,aAAa,CAAA;AAyB1E;;;;;;;;;;;;;;;;;GAiBG;AACH,MAAM,CAAC,KAAK,UAAU,iBAAiB,CACrC,KAAa,EACb,UAA2B,EAAE;IAE7B,MAAM,EACJ,cAAc,EACd,YAAY,EACZ,YAAY,EACZ,UAAU,EACV,GAAG,gBAAgB,EACpB,GAAG,OAAO,CAAA;IAEX,MAAM,SAAS,GAAG,OAAO,EAAE;SACxB,GAAG,CAAC,WAAW,CAAC;SAChB,GAAG,CAAC,eAAe,EAAE,gBAAgB,CAAC;SACtC,GAAG,CAAC,eAAe,EAAE;QACpB,QAAQ,EAAE,cAAc,IAAI,GAAG;QAC/B,MAAM,EAAE,YAAY,IAAI,GAAG;QAC3B,MAAM,EAAE,YAAY,IAAI,GAAG;QAC3B,IAAI,EAAE,UAAU,IAAI,GAAG;QACvB,GAAG,CAAC,UAAU,KAAK,GAAG,IAAI,UAAU,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,cAAc,EAAE,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;KACjF,CAAC,CAAA;IAEJ,MAAM,MAAM,GAAG,MAAM,SAAS,CAAC,OAAO,CAAC,KAAK,CAAC,CAAA;IAC7C,OAAO,MAAM,CAAC,MAAM,CAAC,CAAA;AACvB,CAAC"}
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Remark plugin for applying punctilio typography transformations to Markdown.
3
+ *
4
+ * This plugin integrates punctilio with the unified/remark ecosystem,
5
+ * allowing you to apply smart typography transformations to Markdown ASTs.
6
+ *
7
+ * @packageDocumentation
8
+ */
9
+ import type { Root } from "mdast";
10
+ import type { Transformer } from "unified";
11
+ import { type TransformOptions } from "./index.js";
12
+ export type RemarkPunctilioOptions = TransformOptions;
13
+ /**
14
+ * Remark plugin that applies punctilio typography transformations to Markdown.
15
+ *
16
+ * @param options - Plugin configuration options
17
+ * @returns Unified transformer function
18
+ *
19
+ * @example
20
+ * ```ts
21
+ * import { unified } from 'unified'
22
+ * import remarkParse from 'remark-parse'
23
+ * import remarkStringify from 'remark-stringify'
24
+ * import remarkPunctilio from 'punctilio/remark'
25
+ *
26
+ * const result = await unified()
27
+ * .use(remarkParse)
28
+ * .use(remarkPunctilio, {
29
+ * punctuationStyle: 'american',
30
+ * dashStyle: 'american',
31
+ * })
32
+ * .use(remarkStringify)
33
+ * .process('"Hello," she said -- "it\'s nice."')
34
+ *
35
+ * // Output Markdown will have smart quotes and em-dashes
36
+ * ```
37
+ */
38
+ export declare function remarkPunctilio(options?: RemarkPunctilioOptions): Transformer<Root, Root>;
39
+ export default remarkPunctilio;
40
+ //# sourceMappingURL=remark.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"remark.d.ts","sourceRoot":"","sources":["../src/remark.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,KAAK,EAAE,IAAI,EAAyB,MAAM,OAAO,CAAA;AACxD,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,SAAS,CAAA;AAI1C,OAAO,EAAa,KAAK,gBAAgB,EAAE,MAAM,YAAY,CAAA;AAI7D,MAAM,MAAM,sBAAsB,GAAG,gBAAgB,CAAA;AAqDrD;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AACH,wBAAgB,eAAe,CAC7B,OAAO,GAAE,sBAA2B,GACnC,WAAW,CAAC,IAAI,EAAE,IAAI,CAAC,CAmDzB;AAED,eAAe,eAAe,CAAA"}
package/dist/remark.js ADDED
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Remark plugin for applying punctilio typography transformations to Markdown.
3
+ *
4
+ * This plugin integrates punctilio with the unified/remark ecosystem,
5
+ * allowing you to apply smart typography transformations to Markdown ASTs.
6
+ *
7
+ * @packageDocumentation
8
+ */
9
+ import { visitParents } from "unist-util-visit-parents";
10
+ import { transform } from "./index.js";
11
+ import { DEFAULT_SEPARATOR } from "./constants.js";
12
+ import { formatErrorString } from "./utils.js";
13
+ /**
14
+ * Maximum recursion depth for tree traversal functions.
15
+ * Prevents stack overflow from maliciously deep Markdown nesting.
16
+ */
17
+ const MAX_RECURSION_DEPTH = 1000;
18
+ /**
19
+ * MDAST node types that contain phrasing (inline) content and should
20
+ * have their text transformed as a single unit.
21
+ */
22
+ const PHRASING_CONTAINERS = new Set(["paragraph", "heading", "tableCell"]);
23
+ /**
24
+ * MDAST node types whose text content should not be transformed.
25
+ */
26
+ const SKIP_TYPES = new Set(["inlineCode", "html"]);
27
+ /**
28
+ * Recursively collects text nodes from a phrasing content tree,
29
+ * skipping code and HTML nodes.
30
+ *
31
+ * @param node - The phrasing content node to collect from
32
+ * @param depth - Current recursion depth (internal use)
33
+ * @returns Array of Text nodes
34
+ */
35
+ function flattenTextNodes(node, depth = 0) {
36
+ /* istanbul ignore if -- defensive: prevents stack overflow from malicious nesting */
37
+ if (depth > MAX_RECURSION_DEPTH) {
38
+ return [];
39
+ }
40
+ if (SKIP_TYPES.has(node.type)) {
41
+ return [];
42
+ }
43
+ if (node.type === "text") {
44
+ return [node];
45
+ }
46
+ if ("children" in node) {
47
+ return node.children.flatMap((child) => flattenTextNodes(child, depth + 1));
48
+ }
49
+ return [];
50
+ }
51
+ /**
52
+ * Remark plugin that applies punctilio typography transformations to Markdown.
53
+ *
54
+ * @param options - Plugin configuration options
55
+ * @returns Unified transformer function
56
+ *
57
+ * @example
58
+ * ```ts
59
+ * import { unified } from 'unified'
60
+ * import remarkParse from 'remark-parse'
61
+ * import remarkStringify from 'remark-stringify'
62
+ * import remarkPunctilio from 'punctilio/remark'
63
+ *
64
+ * const result = await unified()
65
+ * .use(remarkParse)
66
+ * .use(remarkPunctilio, {
67
+ * punctuationStyle: 'american',
68
+ * dashStyle: 'american',
69
+ * })
70
+ * .use(remarkStringify)
71
+ * .process('"Hello," she said -- "it\'s nice."')
72
+ *
73
+ * // Output Markdown will have smart quotes and em-dashes
74
+ * ```
75
+ */
76
+ export function remarkPunctilio(options = {}) {
77
+ const separator = options.separator ?? DEFAULT_SEPARATOR;
78
+ const transformFn = (text) => {
79
+ return transform(text, { ...options, separator });
80
+ };
81
+ return (tree) => {
82
+ visitParents(tree, (node, ancestors) => {
83
+ if (!PHRASING_CONTAINERS.has(node.type)) {
84
+ return;
85
+ }
86
+ /* istanbul ignore if -- defensive: standard MDAST never nests phrasing containers */
87
+ if (ancestors.some((a) => PHRASING_CONTAINERS.has(a.type))) {
88
+ return;
89
+ }
90
+ /* istanbul ignore if -- defensive: phrasing containers always have children */
91
+ if (!("children" in node)) {
92
+ return;
93
+ }
94
+ const textNodes = node.children.flatMap((child) => flattenTextNodes(child));
95
+ if (textNodes.length === 0) {
96
+ return;
97
+ }
98
+ // Same separator technique as the rehype plugin:
99
+ // append marker to each text node, concatenate, transform, split back
100
+ const markedContent = textNodes.map((n) => n.value + separator).join("");
101
+ const transformedContent = transformFn(markedContent);
102
+ const transformedFragments = transformedContent.split(separator).slice(0, -1);
103
+ /* istanbul ignore if -- defensive: transform should never consume separator chars */
104
+ if (transformedFragments.length !== textNodes.length) {
105
+ throw new Error(`Transformation altered the number of text nodes. ` +
106
+ `Expected ${textNodes.length}, got ${transformedFragments.length}. ` +
107
+ `Input: ${formatErrorString(markedContent, "input")}`);
108
+ }
109
+ textNodes.forEach((n, index) => {
110
+ n.value = transformedFragments[index];
111
+ });
112
+ });
113
+ };
114
+ }
115
+ export default remarkPunctilio;
116
+ //# sourceMappingURL=remark.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"remark.js","sourceRoot":"","sources":["../src/remark.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "punctilio",
3
- "version": "2.0.0",
3
+ "version": "2.1.1",
4
4
  "description": "Smart typography transformations: curly quotes, em-dashes, en-dashes, and more",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -13,6 +13,14 @@
13
13
  "./rehype": {
14
14
  "types": "./dist/rehype.d.ts",
15
15
  "import": "./dist/rehype.js"
16
+ },
17
+ "./remark": {
18
+ "types": "./dist/remark.d.ts",
19
+ "import": "./dist/remark.js"
20
+ },
21
+ "./markdown": {
22
+ "types": "./dist/markdown.d.ts",
23
+ "import": "./dist/markdown.js"
16
24
  }
17
25
  },
18
26
  "files": [
@@ -31,6 +39,8 @@
31
39
  "text-formatting",
32
40
  "rehype",
33
41
  "rehype-plugin",
42
+ "remark",
43
+ "remark-plugin",
34
44
  "unified",
35
45
  "markdown",
36
46
  "html"
@@ -52,6 +62,7 @@
52
62
  "@eslint/js": "^9.39.2",
53
63
  "@types/hast": "^3.0.4",
54
64
  "@types/jest": "^29.5.12",
65
+ "@types/mdast": "4.0.4",
55
66
  "@types/node": "^20.11.0",
56
67
  "eslint": "^9.39.2",
57
68
  "eslint-plugin-regexp": "^3.0.0",
@@ -59,6 +70,9 @@
59
70
  "jest": "^29.7.0",
60
71
  "rehype-parse": "9.0.1",
61
72
  "rehype-stringify": "10.0.1",
73
+ "remark-gfm": "^4.0.1",
74
+ "remark-parse": "11.0.0",
75
+ "remark-stringify": "11.0.0",
62
76
  "retext": "9.0.0",
63
77
  "retext-smartypants": "6.2.0",
64
78
  "smartquotes": "2.3.2",
@@ -71,11 +85,19 @@
71
85
  "unified": "11.0.5"
72
86
  },
73
87
  "peerDependencies": {
88
+ "remark-parse": "^11.0.0",
89
+ "remark-stringify": "^11.0.0",
74
90
  "unified": "^11.0.0"
75
91
  },
76
92
  "peerDependenciesMeta": {
77
93
  "unified": {
78
94
  "optional": true
95
+ },
96
+ "remark-parse": {
97
+ "optional": true
98
+ },
99
+ "remark-stringify": {
100
+ "optional": true
79
101
  }
80
102
  },
81
103
  "dependencies": {
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Convenience module for Markdown-to-Markdown typography transformations.
3
+ *
4
+ * Provides a single function that takes Markdown input and returns
5
+ * typographically improved Markdown output.
6
+ *
7
+ * Requires `unified`, `remark-parse`, and `remark-stringify` to be installed.
8
+ *
9
+ * @packageDocumentation
10
+ */
11
+
12
+ import { unified } from "unified"
13
+ import remarkParse from "remark-parse"
14
+ import remarkStringify from "remark-stringify"
15
+
16
+ import { remarkPunctilio, type RemarkPunctilioOptions } from "./remark.js"
17
+
18
+ /**
19
+ * Options for the Markdown transform pipeline.
20
+ */
21
+ export interface MarkdownOptions extends RemarkPunctilioOptions {
22
+ /**
23
+ * Character for emphasis (`*text*` vs `_text_`) and strong (`**text**` vs `__text__`).
24
+ * Default: "*"
25
+ */
26
+ emphasisMarker?: "*" | "_"
27
+
28
+ /**
29
+ * Character for strong emphasis (`**text**` vs `__text__`). Must match emphasisMarker.
30
+ * Default: "*"
31
+ */
32
+ strongMarker?: "*" | "_"
33
+
34
+ /** Bullet marker for output consistency. Default: "-" */
35
+ bulletMarker?: "-" | "*" | "+"
36
+
37
+ /** Thematic break marker. Default: "-" */
38
+ ruleMarker?: "-" | "*" | "_"
39
+ }
40
+
41
+ /**
42
+ * Transforms Markdown text with typographic improvements.
43
+ *
44
+ * Parses the input as Markdown, applies punctilio typography transformations
45
+ * (smart quotes, em-dashes, ellipses, etc.), and serializes back to Markdown.
46
+ *
47
+ * @param input - The Markdown text to transform
48
+ * @param options - Configuration options for both the transform and serializer
49
+ * @returns The typographically improved Markdown text
50
+ *
51
+ * @example
52
+ * ```ts
53
+ * import { transformMarkdown } from 'punctilio/markdown'
54
+ *
55
+ * await transformMarkdown('"Hello" -- world')
56
+ * // → '\u201CHello\u201D\u2014world'
57
+ * ```
58
+ */
59
+ export async function transformMarkdown(
60
+ input: string,
61
+ options: MarkdownOptions = {}
62
+ ): Promise<string> {
63
+ const {
64
+ emphasisMarker,
65
+ strongMarker,
66
+ bulletMarker,
67
+ ruleMarker,
68
+ ...punctilioOptions
69
+ } = options
70
+
71
+ const processor = unified()
72
+ .use(remarkParse)
73
+ .use(remarkPunctilio, punctilioOptions)
74
+ .use(remarkStringify, {
75
+ emphasis: emphasisMarker ?? "*",
76
+ strong: strongMarker ?? "*",
77
+ bullet: bulletMarker ?? "-",
78
+ rule: ruleMarker ?? "-",
79
+ ...(ruleMarker === "-" || ruleMarker === undefined ? { ruleRepetition: 4 } : {}),
80
+ })
81
+
82
+ const result = await processor.process(input)
83
+ return String(result)
84
+ }
package/src/remark.ts ADDED
@@ -0,0 +1,152 @@
1
+ /**
2
+ * Remark plugin for applying punctilio typography transformations to Markdown.
3
+ *
4
+ * This plugin integrates punctilio with the unified/remark ecosystem,
5
+ * allowing you to apply smart typography transformations to Markdown ASTs.
6
+ *
7
+ * @packageDocumentation
8
+ */
9
+
10
+ import type { Root, PhrasingContent, Text } from "mdast"
11
+ import type { Transformer } from "unified"
12
+
13
+ import { visitParents } from "unist-util-visit-parents"
14
+
15
+ import { transform, type TransformOptions } from "./index.js"
16
+ import { DEFAULT_SEPARATOR } from "./constants.js"
17
+ import { formatErrorString } from "./utils.js"
18
+
19
+ export type RemarkPunctilioOptions = TransformOptions
20
+
21
+ /**
22
+ * Maximum recursion depth for tree traversal functions.
23
+ * Prevents stack overflow from maliciously deep Markdown nesting.
24
+ */
25
+ const MAX_RECURSION_DEPTH = 1000
26
+
27
+ /**
28
+ * MDAST node types that contain phrasing (inline) content and should
29
+ * have their text transformed as a single unit.
30
+ */
31
+ const PHRASING_CONTAINERS = new Set(["paragraph", "heading", "tableCell"])
32
+
33
+ /**
34
+ * MDAST node types whose text content should not be transformed.
35
+ */
36
+ const SKIP_TYPES = new Set(["inlineCode", "html"])
37
+
38
+ /**
39
+ * Recursively collects text nodes from a phrasing content tree,
40
+ * skipping code and HTML nodes.
41
+ *
42
+ * @param node - The phrasing content node to collect from
43
+ * @param depth - Current recursion depth (internal use)
44
+ * @returns Array of Text nodes
45
+ */
46
+ function flattenTextNodes(
47
+ node: PhrasingContent,
48
+ depth: number = 0
49
+ ): Text[] {
50
+ /* istanbul ignore if -- defensive: prevents stack overflow from malicious nesting */
51
+ if (depth > MAX_RECURSION_DEPTH) {
52
+ return []
53
+ }
54
+
55
+ if (SKIP_TYPES.has(node.type)) {
56
+ return []
57
+ }
58
+
59
+ if (node.type === "text") {
60
+ return [node]
61
+ }
62
+
63
+ if ("children" in node) {
64
+ return (node.children as PhrasingContent[]).flatMap((child) =>
65
+ flattenTextNodes(child, depth + 1)
66
+ )
67
+ }
68
+
69
+ return []
70
+ }
71
+
72
+ /**
73
+ * Remark plugin that applies punctilio typography transformations to Markdown.
74
+ *
75
+ * @param options - Plugin configuration options
76
+ * @returns Unified transformer function
77
+ *
78
+ * @example
79
+ * ```ts
80
+ * import { unified } from 'unified'
81
+ * import remarkParse from 'remark-parse'
82
+ * import remarkStringify from 'remark-stringify'
83
+ * import remarkPunctilio from 'punctilio/remark'
84
+ *
85
+ * const result = await unified()
86
+ * .use(remarkParse)
87
+ * .use(remarkPunctilio, {
88
+ * punctuationStyle: 'american',
89
+ * dashStyle: 'american',
90
+ * })
91
+ * .use(remarkStringify)
92
+ * .process('"Hello," she said -- "it\'s nice."')
93
+ *
94
+ * // Output Markdown will have smart quotes and em-dashes
95
+ * ```
96
+ */
97
+ export function remarkPunctilio(
98
+ options: RemarkPunctilioOptions = {}
99
+ ): Transformer<Root, Root> {
100
+ const separator = options.separator ?? DEFAULT_SEPARATOR
101
+
102
+ const transformFn = (text: string): string => {
103
+ return transform(text, { ...options, separator })
104
+ }
105
+
106
+ return (tree: Root) => {
107
+ visitParents(tree, (node, ancestors) => {
108
+ if (!PHRASING_CONTAINERS.has(node.type)) {
109
+ return
110
+ }
111
+
112
+ /* istanbul ignore if -- defensive: standard MDAST never nests phrasing containers */
113
+ if (ancestors.some((a) => PHRASING_CONTAINERS.has(a.type))) {
114
+ return
115
+ }
116
+
117
+ /* istanbul ignore if -- defensive: phrasing containers always have children */
118
+ if (!("children" in node)) {
119
+ return
120
+ }
121
+
122
+ const textNodes = (node.children as PhrasingContent[]).flatMap((child) =>
123
+ flattenTextNodes(child)
124
+ )
125
+
126
+ if (textNodes.length === 0) {
127
+ return
128
+ }
129
+
130
+ // Same separator technique as the rehype plugin:
131
+ // append marker to each text node, concatenate, transform, split back
132
+ const markedContent = textNodes.map((n) => n.value + separator).join("")
133
+ const transformedContent = transformFn(markedContent)
134
+ const transformedFragments = transformedContent.split(separator).slice(0, -1)
135
+
136
+ /* istanbul ignore if -- defensive: transform should never consume separator chars */
137
+ if (transformedFragments.length !== textNodes.length) {
138
+ throw new Error(
139
+ `Transformation altered the number of text nodes. ` +
140
+ `Expected ${textNodes.length}, got ${transformedFragments.length}. ` +
141
+ `Input: ${formatErrorString(markedContent, "input")}`
142
+ )
143
+ }
144
+
145
+ textNodes.forEach((n, index) => {
146
+ n.value = transformedFragments[index]
147
+ })
148
+ })
149
+ }
150
+ }
151
+
152
+ export default remarkPunctilio
@@ -0,0 +1,71 @@
1
+ import { transformMarkdown } from "../markdown.js"
2
+ import { UNICODE_SYMBOLS } from "../constants.js"
3
+
4
+ const {
5
+ LEFT_DOUBLE_QUOTE: LDQ,
6
+ RIGHT_DOUBLE_QUOTE: RDQ,
7
+ RIGHT_SINGLE_QUOTE: RSQ,
8
+ EM_DASH,
9
+ ELLIPSIS,
10
+ } = UNICODE_SYMBOLS
11
+
12
+ describe("transformMarkdown", () => {
13
+ it("transforms basic typography", async () => {
14
+ const result = await transformMarkdown('"Hello," she said.', { nbsp: false })
15
+ expect(result.trimEnd()).toEqual(`${LDQ}Hello,${RDQ} she said.`)
16
+ })
17
+
18
+ it("preserves code blocks", async () => {
19
+ const input = '```\n"untouched"\n```'
20
+ const result = await transformMarkdown(input, { nbsp: false })
21
+ expect(result.trimEnd()).toEqual(input)
22
+ })
23
+
24
+ it("handles complex documents", async () => {
25
+ const input = [
26
+ '# "Welcome"',
27
+ "",
28
+ "It's great -- isn't it?",
29
+ "",
30
+ "Wait...",
31
+ ].join("\n")
32
+
33
+ const expected = [
34
+ `# ${LDQ}Welcome${RDQ}`,
35
+ "",
36
+ `It${RSQ}s great${EM_DASH}isn${RSQ}t it?`,
37
+ "",
38
+ `Wait${ELLIPSIS}`,
39
+ "", // remark-stringify adds trailing newline
40
+ ].join("\n")
41
+
42
+ expect(await transformMarkdown(input, { nbsp: false })).toEqual(expected)
43
+ })
44
+
45
+ it("passes through punctuationStyle option", async () => {
46
+ const american = await transformMarkdown('"Hello."', {
47
+ punctuationStyle: "american",
48
+ nbsp: false,
49
+ })
50
+ const british = await transformMarkdown('"Hello."', {
51
+ punctuationStyle: "british",
52
+ nbsp: false,
53
+ })
54
+ expect(american.trimEnd()).toEqual(`${LDQ}Hello.${RDQ}`)
55
+ expect(british.trimEnd()).toEqual(`${LDQ}Hello${RDQ}.`)
56
+ })
57
+
58
+ it.each([
59
+ ["bulletMarker", "- item", { bulletMarker: "+" as const }, "+ item"],
60
+ ["emphasisMarker", "*italic*", { emphasisMarker: "_" as const }, "_italic_"],
61
+ ["strongMarker", "**bold**", { strongMarker: "_" as const }, "__bold__"],
62
+ ["ruleMarker", "---", { ruleMarker: "*" as const }, "***"],
63
+ ])("passes through %s option", async (_name, input, options, expected) => {
64
+ expect((await transformMarkdown(input, { ...options, nbsp: false })).trimEnd()).toEqual(expected)
65
+ })
66
+
67
+ it("uses default options when none provided", async () => {
68
+ const result = await transformMarkdown('"Hello"')
69
+ expect(result.trimEnd()).toEqual(`${LDQ}Hello${RDQ}`)
70
+ })
71
+ })
@@ -0,0 +1,173 @@
1
+ import { unified } from "unified"
2
+ import remarkParse from "remark-parse"
3
+ import remarkGfm from "remark-gfm"
4
+ import remarkStringify from "remark-stringify"
5
+ import { remarkPunctilio, type RemarkPunctilioOptions } from "../remark.js"
6
+ import { UNICODE_SYMBOLS } from "../constants.js"
7
+
8
+ const {
9
+ LEFT_DOUBLE_QUOTE: LDQ,
10
+ RIGHT_DOUBLE_QUOTE: RDQ,
11
+ RIGHT_SINGLE_QUOTE: RSQ,
12
+ EM_DASH,
13
+ EN_DASH,
14
+ ELLIPSIS,
15
+ MULTIPLICATION,
16
+ NOT_EQUAL,
17
+ COPYRIGHT,
18
+ NBSP,
19
+ FRACTION_1_2,
20
+ } = UNICODE_SYMBOLS
21
+
22
+ async function processMarkdown(
23
+ markdown: string,
24
+ options?: RemarkPunctilioOptions
25
+ ): Promise<string> {
26
+ const result = await unified()
27
+ .use(remarkParse)
28
+ .use(remarkPunctilio, options)
29
+ .use(remarkStringify)
30
+ .process(markdown)
31
+ return String(result).trimEnd()
32
+ }
33
+
34
+ async function processGfmMarkdown(
35
+ markdown: string,
36
+ options?: RemarkPunctilioOptions
37
+ ): Promise<string> {
38
+ const result = await unified()
39
+ .use(remarkParse)
40
+ .use(remarkGfm)
41
+ .use(remarkPunctilio, options)
42
+ .use(remarkStringify)
43
+ .process(markdown)
44
+ return String(result).trimEnd()
45
+ }
46
+
47
+ describe("remarkPunctilio", () => {
48
+ describe("basic transformations (nbsp enabled)", () => {
49
+ it.each([
50
+ ["quotes", '"Hello," she said.', `${LDQ}Hello,${RDQ} she${NBSP}said.`],
51
+ ["apostrophes", "It's a test.", `It${RSQ}s${NBSP}a${NBSP}test.`],
52
+ ["em dashes", "Wait -- here it comes.", `Wait${EM_DASH}here it${NBSP}comes.`],
53
+ ["en dashes (number range)", "Pages 1-5", `Pages${NBSP}1${EN_DASH}5`],
54
+ ["ellipses", "Wait...", `Wait${ELLIPSIS}`],
55
+ ["multiplication", "5x5", `5${MULTIPLICATION}5`],
56
+ ["math symbols", "x != y", `x${NBSP}${NOT_EQUAL} y`],
57
+ ["legal symbols", "(c) 2024", `${COPYRIGHT}${NBSP}2024`],
58
+ ])("transforms %s", async (_name, input, expected) => {
59
+ expect(await processMarkdown(input)).toEqual(expected)
60
+ })
61
+ })
62
+
63
+ describe("basic transformations (nbsp disabled)", () => {
64
+ it.each([
65
+ ["quotes", '"Hello," she said.', `${LDQ}Hello,${RDQ} she said.`],
66
+ ["apostrophes", "It's a test.", `It${RSQ}s a test.`],
67
+ ["em dashes", "Wait -- here it comes.", `Wait${EM_DASH}here it comes.`],
68
+ ["ellipses", "Wait...", `Wait${ELLIPSIS}`],
69
+ ["multiplication", "5x5", `5${MULTIPLICATION}5`],
70
+ ["math symbols", "x != y", `x ${NOT_EQUAL} y`],
71
+ ["legal symbols", "(c) 2024", `${COPYRIGHT} 2024`],
72
+ ])("transforms %s", async (_name, input, expected) => {
73
+ expect(await processMarkdown(input, { nbsp: false })).toEqual(expected)
74
+ })
75
+ })
76
+
77
+ describe("inline element boundaries", () => {
78
+ it.each([
79
+ ["quotes spanning emphasis", '"Hello," *she* said.', `${LDQ}Hello,${RDQ} *she* said.`],
80
+ ["quotes inside emphasis", '*"Hello,"* she said.', `*${LDQ}Hello,${RDQ}* she said.`],
81
+ ["apostrophe in strong", "**It's** fine.", `**It${RSQ}s** fine.`],
82
+ ["dash between elements", "word *one* -- *two* word", `word *one*${EM_DASH}*two* word`],
83
+ ["deeply nested elements", '*"Hello **beautiful** world"*', `*${LDQ}Hello **beautiful** world${RDQ}*`],
84
+ ])("handles %s", async (_name, input, expected) => {
85
+ expect(await processMarkdown(input, { nbsp: false })).toEqual(expected)
86
+ })
87
+ })
88
+
89
+ describe("code preservation", () => {
90
+ it.each([
91
+ ["inline code", '`"Hello" -- test...`'],
92
+ ["fenced code blocks", '```\n"Hello" -- test...\n```'],
93
+ ["indented code blocks", ' "Hello" -- test...'],
94
+ ])("does not transform %s", async (_name, input) => {
95
+ const result = await processMarkdown(input, { nbsp: false })
96
+ expect(result).toContain('"Hello" -- test...')
97
+ expect(result).not.toContain(LDQ)
98
+ expect(result).not.toContain(EM_DASH)
99
+ expect(result).not.toContain(ELLIPSIS)
100
+ })
101
+
102
+ it("transforms text alongside inline code", async () => {
103
+ expect(await processMarkdown('`code` "Hello"', { nbsp: false }))
104
+ .toEqual(`\`code\` ${LDQ}Hello${RDQ}`)
105
+ })
106
+ })
107
+
108
+ describe("element types", () => {
109
+ it.each([
110
+ ["h1", '# "Hello"', `# ${LDQ}Hello${RDQ}`],
111
+ ["h2", '## It\'s nice', `## It${RSQ}s nice`],
112
+ ["h3", "### Wait -- really?", `### Wait${EM_DASH}really?`],
113
+ ["list items", '- "Hello" -- world', `* ${LDQ}Hello${RDQ}${EM_DASH}world`],
114
+ ["blockquotes", '> "Hello," she said.', `> ${LDQ}Hello,${RDQ} she said.`],
115
+ ])("transforms %s", async (_name, input, expected) => {
116
+ expect(await processMarkdown(input, { nbsp: false })).toEqual(expected)
117
+ })
118
+
119
+ it.each([
120
+ ["nested list items", '- outer "quote"\n - inner "quote"', `* outer ${LDQ}quote${RDQ}\n * inner ${LDQ}quote${RDQ}`],
121
+ ["link text", '["Hello," she said.](https://example.com)', `[${LDQ}Hello,${RDQ} she said.](https://example.com)`],
122
+ ["text around links", '"Hello" [link](url) "world"', `${LDQ}Hello${RDQ} [link](url) ${LDQ}world${RDQ}`],
123
+ ["text around images", '"Hello" ![img](url) "world"', `${LDQ}Hello${RDQ} ![img](url) ${LDQ}world${RDQ}`],
124
+ ["inline HTML boundaries", '"Hello"<br>"world"', `${LDQ}Hello${RDQ}<br>${LDQ}world${RDQ}`],
125
+ ["hard line breaks", '"Hello" \n"world"', `${LDQ}Hello${RDQ}\\\n${LDQ}world${RDQ}`],
126
+ ])("transforms text in %s", async (_name, input, expected) => {
127
+ expect(await processMarkdown(input, { nbsp: false })).toEqual(expected)
128
+ })
129
+ })
130
+
131
+ describe("GFM extensions", () => {
132
+ it.each([
133
+ ["table cells", '| "Hello" | world |\n| --- | --- |\n| "test" | data |', `| ${LDQ}Hello${RDQ} | world |\n| ------- | ----- |\n| ${LDQ}test${RDQ} | data |`],
134
+ ["strikethrough", '~~"Hello" -- world~~', `~~${LDQ}Hello${RDQ}${EM_DASH}world~~`],
135
+ ])("transforms %s", async (_name, input, expected) => {
136
+ expect(await processGfmMarkdown(input, { nbsp: false })).toEqual(expected)
137
+ })
138
+ })
139
+
140
+ describe("transform options passthrough", () => {
141
+ it.each([
142
+ ["punctuationStyle american", '"Hello."', { punctuationStyle: "american" as const, nbsp: false as const }, `${LDQ}Hello.${RDQ}`],
143
+ ["punctuationStyle british", '"Hello."', { punctuationStyle: "british" as const, nbsp: false as const }, `${LDQ}Hello${RDQ}.`],
144
+ ["dashStyle american", "word - word", { dashStyle: "american" as const, nbsp: false as const }, `word${EM_DASH}word`],
145
+ ["dashStyle british", "word - word", { dashStyle: "british" as const, nbsp: false as const }, `word ${EN_DASH} word`],
146
+ ["symbols disabled", "5x5", { symbols: false, nbsp: false as const }, "5x5"],
147
+ ["fractions enabled", "1/2 cup", { fractions: true, nbsp: false as const }, `${FRACTION_1_2} cup`],
148
+ ["fractions disabled", "1/2 cup", { fractions: false, nbsp: false as const }, "1/2 cup"],
149
+ ["custom separator", '"Hello"', { separator: "\uE001", nbsp: false as const }, `${LDQ}Hello${RDQ}`],
150
+ ])("respects %s", async (_name, input, options, expected) => {
151
+ expect(await processMarkdown(input, options)).toEqual(expected)
152
+ })
153
+ })
154
+
155
+ describe("complex documents", () => {
156
+ it("transforms a multi-paragraph document", async () => {
157
+ const input = '"Hello," she said.\n\nIt\'s a nice day -- isn\'t it?\n\nWait...'
158
+ const expected = `${LDQ}Hello,${RDQ} she said.\n\nIt${RSQ}s a nice day${EM_DASH}isn${RSQ}t it?\n\nWait${ELLIPSIS}`
159
+ expect(await processMarkdown(input, { nbsp: false })).toEqual(expected)
160
+ })
161
+
162
+ it("preserves code blocks among transformed paragraphs", async () => {
163
+ const input = '"Transform this"\n\n```\n"Leave this alone"\n```\n\n"Transform this too"'
164
+ const expected = `${LDQ}Transform this${RDQ}\n\n\`\`\`\n"Leave this alone"\n\`\`\`\n\n${LDQ}Transform this too${RDQ}`
165
+ expect(await processMarkdown(input, { nbsp: false })).toEqual(expected)
166
+ })
167
+ })
168
+
169
+ it("is idempotent", async () => {
170
+ const first = await processMarkdown('"Hello," she said -- "it\'s pages 1-5."', { nbsp: false })
171
+ expect(await processMarkdown(first, { nbsp: false })).toEqual(first)
172
+ })
173
+ })