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 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,8 @@
1
+ ---
2
+ title: Test
3
+ lang: en
4
+ ---
5
+
6
+ This is a "test" with 'quotes'.
7
+
8
+ She said: "Hello World" and left.
@@ -0,0 +1,8 @@
1
+ ---
2
+ title: Test
3
+ lang: de
4
+ ---
5
+
6
+ Das ist ein "Test" mit 'Anführungszeichen'.
7
+
8
+ Er sagte: "Hallo Welt" und ging.
@@ -0,0 +1,14 @@
1
+ ---
2
+ title: "Test Document"
3
+ lang: de
4
+ ---
5
+
6
+ Normal "text" here.
7
+
8
+ <a href="https://example.com" style="color: red;">Link</a>
9
+
10
+ {{ variable | date: "%Y-%m-%d" }}
11
+
12
+ {: .class title="tooltip"}
13
+
14
+ Der Befehl `echo "test"` funktioniert.
@@ -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);