fix-smart-quotes 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +81 -0
- package/index.js +310 -0
- package/package.json +30 -0
- package/test/fixtures/english.md +8 -0
- package/test/fixtures/german.md +8 -0
- package/test/fixtures/protected.md +14 -0
- package/test/test-markdown-links.md +32 -0
- package/test/test.js +94 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Johannes Kleske
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# fix-smart-quotes
|
|
2
|
+
|
|
3
|
+
Convert straight quotes to proper typographic ("smart") quotes in Markdown files.
|
|
4
|
+
|
|
5
|
+
| Language | Before | After |
|
|
6
|
+
|----------|--------|-------|
|
|
7
|
+
| German | `"text"` | `„text"` (U+201E / U+201C) |
|
|
8
|
+
| English | `"text"` | `"text"` (U+201C / U+201D) |
|
|
9
|
+
|
|
10
|
+
## Why?
|
|
11
|
+
|
|
12
|
+
When AI coding assistants edit Markdown files, they often replace typographic quotes with straight quotes. This is intentional—straight quotes are safer for code and avoid encoding issues.
|
|
13
|
+
|
|
14
|
+
But for prose text, proper typography matters.
|
|
15
|
+
|
|
16
|
+
**Example - before AI edit:**
|
|
17
|
+
```
|
|
18
|
+
Sie sagte: „Das ist wichtig."
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
**After AI edit:**
|
|
22
|
+
```
|
|
23
|
+
Sie sagte: "Das ist wichtig."
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
This tool restores the correct quotes—either manually via CLI, or automatically after each Claude Code edit via hook.
|
|
27
|
+
|
|
28
|
+
## Installation
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
npm install -g fix-smart-quotes
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Or use directly without installing:
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
npx fix-smart-quotes file.md
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## CLI Usage
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
# Single file
|
|
44
|
+
fix-smart-quotes README.md
|
|
45
|
+
|
|
46
|
+
# Multiple files (shell expands glob)
|
|
47
|
+
fix-smart-quotes docs/*.md
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Claude Code Hook
|
|
51
|
+
|
|
52
|
+
The primary use case: automatically fix quotes in files that Claude Code just edited. The hook runs **only** on files modified by Claude's Write or Edit tools—not on your entire project.
|
|
53
|
+
|
|
54
|
+
Add to `~/.claude/settings.json`:
|
|
55
|
+
|
|
56
|
+
```json
|
|
57
|
+
{
|
|
58
|
+
"hooks": {
|
|
59
|
+
"PostToolUse": [{
|
|
60
|
+
"matcher": "Write|Edit",
|
|
61
|
+
"hooks": [{
|
|
62
|
+
"type": "command",
|
|
63
|
+
"command": "npx fix-smart-quotes \"$FILE_PATH\"",
|
|
64
|
+
"timeout": 30
|
|
65
|
+
}]
|
|
66
|
+
}]
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
This means: After Claude writes or edits a file, immediately run the quote fixer on that specific file.
|
|
72
|
+
|
|
73
|
+
## Features
|
|
74
|
+
|
|
75
|
+
- **Auto-detects language** via `lang:` frontmatter or content heuristics
|
|
76
|
+
- **Protects technical syntax:** code blocks, inline code, HTML attributes, Liquid/Jekyll templates, Kramdown attributes, Markdown links
|
|
77
|
+
- **Zero dependencies**
|
|
78
|
+
|
|
79
|
+
## License
|
|
80
|
+
|
|
81
|
+
MIT
|
package/index.js
ADDED
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
const fs = require("fs");
|
|
4
|
+
const path = require("path");
|
|
5
|
+
|
|
6
|
+
// === LANGUAGE DETECTION ===
|
|
7
|
+
|
|
8
|
+
const GERMAN_MARKERS = new Set([
|
|
9
|
+
"und", "der", "die", "das", "ist", "für", "mit", "auf", "ein", "eine",
|
|
10
|
+
"nicht", "sich", "auch", "dass", "werden", "sein", "haben", "können",
|
|
11
|
+
"mehr", "oder", "wenn", "aber", "wird", "sind", "wurde", "durch",
|
|
12
|
+
"bei", "nach", "vom", "zum", "zur", "aus", "wie", "kann", "noch",
|
|
13
|
+
"nur", "über", "diese", "dieser", "dieses", "einem", "einen", "einer"
|
|
14
|
+
]);
|
|
15
|
+
|
|
16
|
+
const ENGLISH_MARKERS = new Set([
|
|
17
|
+
"the", "and", "is", "of", "to", "in", "that", "for", "with", "this",
|
|
18
|
+
"from", "are", "have", "was", "been", "will", "would", "could", "should",
|
|
19
|
+
"which", "their", "there", "about", "into", "what", "when", "where",
|
|
20
|
+
"can", "has", "had", "but", "not", "you", "all", "were", "they", "be",
|
|
21
|
+
"how", "than", "then", "some", "these", "those", "such", "only", "also"
|
|
22
|
+
]);
|
|
23
|
+
|
|
24
|
+
// Quote characters by language
|
|
25
|
+
const QUOTES = {
|
|
26
|
+
de: {
|
|
27
|
+
openDouble: "\u201E", // „
|
|
28
|
+
closeDouble: "\u201C", // "
|
|
29
|
+
openSingle: "\u201A", // ‚
|
|
30
|
+
closeSingle: "\u2018" // '
|
|
31
|
+
},
|
|
32
|
+
en: {
|
|
33
|
+
openDouble: "\u201C", // "
|
|
34
|
+
closeDouble: "\u201D", // "
|
|
35
|
+
openSingle: "\u2018", // '
|
|
36
|
+
closeSingle: "\u2019" // '
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
// Check frontmatter for lang: field
|
|
41
|
+
function getLanguageFromFrontmatter(text) {
|
|
42
|
+
const frontmatterMatch = text.match(/^---\n([\s\S]*?)\n---/);
|
|
43
|
+
if (frontmatterMatch) {
|
|
44
|
+
const langMatch = frontmatterMatch[1].match(/^lang:\s*(de|en)\s*$/m);
|
|
45
|
+
if (langMatch) {
|
|
46
|
+
return langMatch[1];
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Language detection via word frequency heuristics
|
|
53
|
+
function detectLanguageFromContent(text) {
|
|
54
|
+
const words = text.toLowerCase().match(/\b[a-zäöüß]+\b/g) || [];
|
|
55
|
+
let germanScore = 0;
|
|
56
|
+
let englishScore = 0;
|
|
57
|
+
|
|
58
|
+
for (const word of words) {
|
|
59
|
+
if (GERMAN_MARKERS.has(word)) germanScore++;
|
|
60
|
+
if (ENGLISH_MARKERS.has(word)) englishScore++;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Default to German on tie or insufficient data
|
|
64
|
+
return englishScore > germanScore ? "en" : "de";
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Main language detection function
|
|
68
|
+
function detectLanguage(text) {
|
|
69
|
+
// Priority 1: Frontmatter
|
|
70
|
+
const frontmatterLang = getLanguageFromFrontmatter(text);
|
|
71
|
+
if (frontmatterLang) {
|
|
72
|
+
return frontmatterLang;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Priority 2: Content analysis
|
|
76
|
+
return detectLanguageFromContent(text);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// === INLINE PROTECTION PATTERNS ===
|
|
80
|
+
// These patterns protect technical areas within lines
|
|
81
|
+
|
|
82
|
+
// Liquid Tags: {% ... %}
|
|
83
|
+
const LIQUID_TAG_PATTERN = /\{%[\s\S]*?%\}/g;
|
|
84
|
+
|
|
85
|
+
// Liquid Output: {{ ... }}
|
|
86
|
+
const LIQUID_OUTPUT_PATTERN = /\{\{[\s\S]*?\}\}/g;
|
|
87
|
+
|
|
88
|
+
// Kramdown attributes (Jekyll): {: .class} or {: title="..."}
|
|
89
|
+
// IMPORTANT: Must come BEFORE HTML_ATTR!
|
|
90
|
+
const KRAMDOWN_ATTR_PATTERN = /\{:[^}]*\}/g;
|
|
91
|
+
|
|
92
|
+
// HTML attributes: href="...", style='...', class="...", data-foo="...", etc.
|
|
93
|
+
const HTML_ATTR_PATTERN = /\b([a-z][a-z0-9-]*)\s*=\s*(["'])((?:(?!\2)[^\\]|\\.)*)(\2)/gi;
|
|
94
|
+
|
|
95
|
+
// Markdown links: [text](url) and [text](url "title")
|
|
96
|
+
// Match entire construct to protect URLs and titles with any quote mix
|
|
97
|
+
const MD_LINK_PATTERN = /\[([^\]]*)\]\([^)]+\)/g;
|
|
98
|
+
|
|
99
|
+
// Inline code: `...`
|
|
100
|
+
const INLINE_CODE_PATTERN = /`[^`]+`/g;
|
|
101
|
+
|
|
102
|
+
// === PROTECT/RESTORE SYSTEM ===
|
|
103
|
+
|
|
104
|
+
// Placeholder markers (no null bytes as they cause issues)
|
|
105
|
+
const PROTECT_START = "__PROT_";
|
|
106
|
+
const PROTECT_END = "_TORP__";
|
|
107
|
+
|
|
108
|
+
function createProtector() {
|
|
109
|
+
const segments = [];
|
|
110
|
+
|
|
111
|
+
function protect(text, pattern) {
|
|
112
|
+
return text.replace(pattern, (match) => {
|
|
113
|
+
const index = segments.length;
|
|
114
|
+
segments.push(match);
|
|
115
|
+
return `${PROTECT_START}${index}${PROTECT_END}`;
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function restore(text) {
|
|
120
|
+
const pattern = new RegExp(`${PROTECT_START}(\\d+)${PROTECT_END}`, "g");
|
|
121
|
+
let result = text;
|
|
122
|
+
let prevResult;
|
|
123
|
+
// Repeat until no placeholders remain (for nested protected patterns)
|
|
124
|
+
do {
|
|
125
|
+
prevResult = result;
|
|
126
|
+
result = result.replace(pattern, (_, index) => {
|
|
127
|
+
return segments[parseInt(index, 10)];
|
|
128
|
+
});
|
|
129
|
+
} while (result !== prevResult);
|
|
130
|
+
return result;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function reset() {
|
|
134
|
+
segments.length = 0;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return { protect, restore, reset };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Helper: Is this character a letter?
|
|
141
|
+
function isLetter(char) {
|
|
142
|
+
return /[a-zA-ZäöüßÄÖÜ]/.test(char);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Normalize typographic quotes to straight quotes
|
|
146
|
+
function normalizeQuotes(text) {
|
|
147
|
+
return text
|
|
148
|
+
// Double quotes → straight "
|
|
149
|
+
.replace(/[\u201C\u201D\u201E\u201F\u00AB\u00BB]/g, '"')
|
|
150
|
+
// Single quotes → straight '
|
|
151
|
+
.replace(/[\u2018\u2019\u201A\u201B]/g, "'");
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Replace quotes in text, updating toggle state
|
|
155
|
+
function replaceQuotesInText(text, state, quotes) {
|
|
156
|
+
const normalizedText = normalizeQuotes(text);
|
|
157
|
+
let processed = "";
|
|
158
|
+
|
|
159
|
+
for (let k = 0; k < normalizedText.length; k++) {
|
|
160
|
+
const currentChar = normalizedText[k];
|
|
161
|
+
const prevChar = k > 0 ? normalizedText[k - 1] : "";
|
|
162
|
+
const nextChar = k < normalizedText.length - 1 ? normalizedText[k + 1] : "";
|
|
163
|
+
|
|
164
|
+
if (currentChar === '"') {
|
|
165
|
+
// Straight double quote
|
|
166
|
+
if (state.doubleQuoteOpen) {
|
|
167
|
+
processed += quotes.openDouble;
|
|
168
|
+
} else {
|
|
169
|
+
processed += quotes.closeDouble;
|
|
170
|
+
}
|
|
171
|
+
state.doubleQuoteOpen = !state.doubleQuoteOpen;
|
|
172
|
+
} else if (currentChar === "'") {
|
|
173
|
+
// Straight single quote - ignore apostrophes within words
|
|
174
|
+
const isApostrophe = isLetter(prevChar) && isLetter(nextChar);
|
|
175
|
+
|
|
176
|
+
if (isApostrophe) {
|
|
177
|
+
// Keep apostrophes unchanged (e.g., "it's", "We've")
|
|
178
|
+
processed += currentChar;
|
|
179
|
+
} else {
|
|
180
|
+
// Replace as quote
|
|
181
|
+
if (state.singleQuoteOpen) {
|
|
182
|
+
processed += quotes.openSingle;
|
|
183
|
+
} else {
|
|
184
|
+
processed += quotes.closeSingle;
|
|
185
|
+
}
|
|
186
|
+
state.singleQuoteOpen = !state.singleQuoteOpen;
|
|
187
|
+
}
|
|
188
|
+
} else {
|
|
189
|
+
processed += currentChar;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return processed;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Process a single file
|
|
196
|
+
function processFile(filePath) {
|
|
197
|
+
const content = fs.readFileSync(filePath, "utf8");
|
|
198
|
+
const lines = content.split("\n");
|
|
199
|
+
const protectedRanges = [];
|
|
200
|
+
|
|
201
|
+
// Detect YAML frontmatter (only at file start)
|
|
202
|
+
let inFrontmatter = false;
|
|
203
|
+
for (let i = 0; i < lines.length; i++) {
|
|
204
|
+
if (i === 0 && lines[i].trim() === "---") {
|
|
205
|
+
inFrontmatter = true;
|
|
206
|
+
protectedRanges.push({ start: i, end: -1, type: "frontmatter" });
|
|
207
|
+
} else if (inFrontmatter && lines[i].trim() === "---") {
|
|
208
|
+
protectedRanges[protectedRanges.length - 1].end = i;
|
|
209
|
+
inFrontmatter = false;
|
|
210
|
+
break;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Detect code blocks
|
|
215
|
+
let inCodeBlock = false;
|
|
216
|
+
for (let i = 0; i < lines.length; i++) {
|
|
217
|
+
if (lines[i].trim().startsWith("```")) {
|
|
218
|
+
if (!inCodeBlock) {
|
|
219
|
+
protectedRanges.push({ start: i, end: -1, type: "codeblock" });
|
|
220
|
+
inCodeBlock = true;
|
|
221
|
+
} else {
|
|
222
|
+
protectedRanges[protectedRanges.length - 1].end = i;
|
|
223
|
+
inCodeBlock = false;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Check if line is fully protected
|
|
229
|
+
function isProtectedLine(lineIndex) {
|
|
230
|
+
return protectedRanges.some(
|
|
231
|
+
(range) => lineIndex >= range.start && lineIndex <= range.end
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Detect language
|
|
236
|
+
const lang = detectLanguage(content);
|
|
237
|
+
const quotes = QUOTES[lang];
|
|
238
|
+
|
|
239
|
+
// Create protector instance
|
|
240
|
+
const protector = createProtector();
|
|
241
|
+
|
|
242
|
+
// Initialize quote state OUTSIDE processLine so it persists across lines
|
|
243
|
+
const state = { doubleQuoteOpen: true, singleQuoteOpen: true };
|
|
244
|
+
|
|
245
|
+
// Process line with protect → replace → restore
|
|
246
|
+
function processLine(line) {
|
|
247
|
+
protector.reset();
|
|
248
|
+
|
|
249
|
+
// PROTECT: Order matters!
|
|
250
|
+
let processed = protector.protect(line, INLINE_CODE_PATTERN);
|
|
251
|
+
processed = protector.protect(processed, LIQUID_TAG_PATTERN);
|
|
252
|
+
processed = protector.protect(processed, LIQUID_OUTPUT_PATTERN);
|
|
253
|
+
processed = protector.protect(processed, KRAMDOWN_ATTR_PATTERN);
|
|
254
|
+
processed = protector.protect(processed, HTML_ATTR_PATTERN);
|
|
255
|
+
processed = protector.protect(processed, MD_LINK_PATTERN);
|
|
256
|
+
|
|
257
|
+
// REPLACE: Replace quotes using shared state (persists across lines)
|
|
258
|
+
processed = replaceQuotesInText(processed, state, quotes);
|
|
259
|
+
|
|
260
|
+
// RESTORE: Restore protected areas
|
|
261
|
+
processed = protector.restore(processed);
|
|
262
|
+
|
|
263
|
+
return processed;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Process all lines
|
|
267
|
+
const resultLines = [];
|
|
268
|
+
for (let i = 0; i < lines.length; i++) {
|
|
269
|
+
if (isProtectedLine(i)) {
|
|
270
|
+
resultLines.push(lines[i]);
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
resultLines.push(processLine(lines[i]));
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
fs.writeFileSync(filePath, resultLines.join("\n"), "utf8");
|
|
277
|
+
const langLabel = lang === "de" ? "German" : "English";
|
|
278
|
+
console.log(`\u2713 Fixed quotes (${langLabel}): ${filePath}`);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// === MAIN ===
|
|
282
|
+
|
|
283
|
+
function main() {
|
|
284
|
+
const args = process.argv.slice(2);
|
|
285
|
+
|
|
286
|
+
if (args.length === 0) {
|
|
287
|
+
console.error("Usage: fix-smart-quotes <file.md> [file2.md ...]");
|
|
288
|
+
process.exit(1);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
let hasErrors = false;
|
|
292
|
+
|
|
293
|
+
for (const arg of args) {
|
|
294
|
+
try {
|
|
295
|
+
if (!fs.existsSync(arg)) {
|
|
296
|
+
console.error(`Error: File not found: ${arg}`);
|
|
297
|
+
hasErrors = true;
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
300
|
+
processFile(arg);
|
|
301
|
+
} catch (err) {
|
|
302
|
+
console.error(`Error processing ${arg}: ${err.message}`);
|
|
303
|
+
hasErrors = true;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
process.exit(hasErrors ? 1 : 0);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
main();
|
package/package.json
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "fix-smart-quotes",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Fix straight quotes to smart quotes in Markdown (DE/EN)",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"bin": {
|
|
7
|
+
"fix-smart-quotes": "./index.js"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"test": "node test/test.js"
|
|
11
|
+
},
|
|
12
|
+
"keywords": [
|
|
13
|
+
"markdown",
|
|
14
|
+
"typography",
|
|
15
|
+
"quotes",
|
|
16
|
+
"smart-quotes",
|
|
17
|
+
"german",
|
|
18
|
+
"english",
|
|
19
|
+
"claude-code"
|
|
20
|
+
],
|
|
21
|
+
"author": "Johannes Kleske",
|
|
22
|
+
"license": "MIT",
|
|
23
|
+
"repository": {
|
|
24
|
+
"type": "git",
|
|
25
|
+
"url": "https://github.com/jkleske/fix-smart-quotes"
|
|
26
|
+
},
|
|
27
|
+
"engines": {
|
|
28
|
+
"node": ">=14"
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
---
|
|
2
|
+
lang: en
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
# Markdown Link Title Protection Test
|
|
6
|
+
|
|
7
|
+
## Test Case 1: Link with double-quoted title
|
|
8
|
+
[test](https://example.com "title")
|
|
9
|
+
|
|
10
|
+
## Test Case 2: Link with single-quoted title
|
|
11
|
+
[test](https://example.com 'title')
|
|
12
|
+
|
|
13
|
+
## Test Case 3: Link with double-quoted title containing single quotes
|
|
14
|
+
[test](https://example.com "title with 'quotes'")
|
|
15
|
+
|
|
16
|
+
## Test Case 4: Link with single-quoted title containing double quotes
|
|
17
|
+
[test](https://example.com 'title with "quotes"')
|
|
18
|
+
|
|
19
|
+
## Test Case 5: Link without title
|
|
20
|
+
[test](https://example.com)
|
|
21
|
+
|
|
22
|
+
## Test Case 6: Multiple links on same line
|
|
23
|
+
Here are [link1](https://example.com "first title") and [link2](https://example.com 'second title') together.
|
|
24
|
+
|
|
25
|
+
## Test Case 7: Complex nested quotes in title
|
|
26
|
+
[complex](https://example.com "He said 'Hello' and she replied")
|
|
27
|
+
|
|
28
|
+
## Test Case 8: Regular quotes OUTSIDE links should still be converted
|
|
29
|
+
This is “text with quotes” that should be converted to smart quotes.
|
|
30
|
+
|
|
31
|
+
## Test Case 9: Mixed - link protection + normal conversion
|
|
32
|
+
This “sentence” has [a link](https://example.com "with a title") and “more quotes” to convert.
|
package/test/test.js
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
const fs = require("fs");
|
|
2
|
+
const path = require("path");
|
|
3
|
+
const { execSync } = require("child_process");
|
|
4
|
+
|
|
5
|
+
const FIXTURES_DIR = path.join(__dirname, "fixtures");
|
|
6
|
+
const CLI = path.join(__dirname, "..", "index.js");
|
|
7
|
+
|
|
8
|
+
// Test helper: copy fixture, run CLI, check result, cleanup
|
|
9
|
+
function testFile(fixtureName, expectedPatterns, unexpectedPatterns = []) {
|
|
10
|
+
const fixture = path.join(FIXTURES_DIR, fixtureName);
|
|
11
|
+
const temp = path.join(FIXTURES_DIR, `_temp_${fixtureName}`);
|
|
12
|
+
|
|
13
|
+
// Copy fixture to temp
|
|
14
|
+
fs.copyFileSync(fixture, temp);
|
|
15
|
+
|
|
16
|
+
try {
|
|
17
|
+
// Run CLI
|
|
18
|
+
execSync(`node "${CLI}" "${temp}"`, { stdio: "pipe" });
|
|
19
|
+
|
|
20
|
+
// Read result
|
|
21
|
+
const result = fs.readFileSync(temp, "utf8");
|
|
22
|
+
|
|
23
|
+
// Check expected patterns
|
|
24
|
+
for (const pattern of expectedPatterns) {
|
|
25
|
+
if (!result.includes(pattern)) {
|
|
26
|
+
throw new Error(`Expected pattern not found: ${pattern}`);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Check unexpected patterns
|
|
31
|
+
for (const pattern of unexpectedPatterns) {
|
|
32
|
+
if (result.includes(pattern)) {
|
|
33
|
+
throw new Error(`Unexpected pattern found: ${pattern}`);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
console.log(`✓ ${fixtureName}`);
|
|
38
|
+
return true;
|
|
39
|
+
} catch (err) {
|
|
40
|
+
console.error(`✗ ${fixtureName}: ${err.message}`);
|
|
41
|
+
return false;
|
|
42
|
+
} finally {
|
|
43
|
+
// Cleanup
|
|
44
|
+
if (fs.existsSync(temp)) {
|
|
45
|
+
fs.unlinkSync(temp);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Run tests
|
|
51
|
+
console.log("Running tests...\n");
|
|
52
|
+
|
|
53
|
+
let passed = 0;
|
|
54
|
+
let failed = 0;
|
|
55
|
+
|
|
56
|
+
// Test 1: German quotes
|
|
57
|
+
if (testFile("german.md", [
|
|
58
|
+
"\u201ETest\u201C", // „Test"
|
|
59
|
+
"\u201EHallo Welt\u201C" // „Hallo Welt"
|
|
60
|
+
])) {
|
|
61
|
+
passed++;
|
|
62
|
+
} else {
|
|
63
|
+
failed++;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Test 2: English quotes
|
|
67
|
+
if (testFile("english.md", [
|
|
68
|
+
"\u201Ctest\u201D", // "test"
|
|
69
|
+
"\u201CHello World\u201D" // "Hello World"
|
|
70
|
+
])) {
|
|
71
|
+
passed++;
|
|
72
|
+
} else {
|
|
73
|
+
failed++;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Test 3: Protected areas unchanged
|
|
77
|
+
if (testFile("protected.md", [
|
|
78
|
+
'href="https://example.com"', // HTML attribute unchanged
|
|
79
|
+
'style="color: red;"', // HTML attribute unchanged
|
|
80
|
+
'"%Y-%m-%d"', // Liquid unchanged
|
|
81
|
+
'title="tooltip"', // Kramdown unchanged
|
|
82
|
+
'`echo "test"`' // Inline code unchanged
|
|
83
|
+
], [
|
|
84
|
+
'href=\u201E', // Should NOT have German quotes in href
|
|
85
|
+
'href=\u201C' // Should NOT have English quotes in href
|
|
86
|
+
])) {
|
|
87
|
+
passed++;
|
|
88
|
+
} else {
|
|
89
|
+
failed++;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Summary
|
|
93
|
+
console.log(`\n${passed} passed, ${failed} failed`);
|
|
94
|
+
process.exit(failed > 0 ? 1 : 0);
|