clew-code 0.2.29 → 0.2.31

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.
Files changed (39) hide show
  1. package/README.md +20 -4
  2. package/bin/clew.cjs +2 -2
  3. package/dist/main.js +2015 -2015
  4. package/package.json +5 -2
  5. package/scripts/auto-close-duplicates.ts +277 -0
  6. package/scripts/backfill-duplicate-comments.ts +213 -0
  7. package/scripts/codegraph.ts +221 -0
  8. package/scripts/comment-on-duplicates.sh +95 -0
  9. package/scripts/edit-issue-labels.sh +84 -0
  10. package/scripts/final-peer-rename.mjs +46 -0
  11. package/scripts/fix-encoding.mjs +83 -0
  12. package/scripts/fix-inconsistencies.mjs +67 -0
  13. package/scripts/generate-docs.ts +307 -0
  14. package/scripts/gh.sh +96 -0
  15. package/scripts/install.ps1 +40 -0
  16. package/scripts/install.sh +65 -0
  17. package/scripts/issue-lifecycle.ts +38 -0
  18. package/scripts/lifecycle-comment.ts +53 -0
  19. package/scripts/normalize-html.mjs +37 -0
  20. package/scripts/preload.ts +159 -0
  21. package/scripts/rename-content-peer-to-swarm.mjs +67 -0
  22. package/scripts/rename-hooks-mesh.mjs +6 -0
  23. package/scripts/rename-peer-to-swarm.mjs +62 -0
  24. package/scripts/run_devcontainer_claude_code.ps1 +152 -0
  25. package/scripts/scrape.py +82 -0
  26. package/scripts/session.ts +195 -0
  27. package/scripts/sweep.ts +168 -0
  28. package/scripts/write-discovery-test.cjs +93 -0
  29. package/scripts/write-discovery-test2.cjs +65 -0
  30. package/scripts/write-server-final.cjs +108 -0
  31. package/scripts/write-server-test.cjs +69 -0
  32. package/scripts/write-server-test2.cjs +99 -0
  33. package/scripts/write-server-test3.cjs +59 -0
  34. package/scripts/write-server-test4.cjs +108 -0
  35. package/scripts/write-server-v2.cjs +142 -0
  36. package/scripts/write-server-v2b.cjs +129 -0
  37. package/scripts/write-server-v2c.cjs +136 -0
  38. package/scripts/write-store-test.cjs +12 -0
  39. package/scripts/write-store-test2.cjs +163 -0
@@ -0,0 +1,221 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * Code Graph Generator
4
+ *
5
+ * Scans src/ and builds a persistent structure map of the codebase.
6
+ * Uses ripgrep (fast, available in the project) for text patterns.
7
+ *
8
+ * Usage: bun run scripts/codegraph.ts
9
+ */
10
+
11
+ import { readFileSync, writeFileSync, mkdirSync } from 'fs'
12
+ import { join, dirname } from 'path'
13
+ import { execSync } from 'child_process'
14
+
15
+ const ROOT = join(import.meta.dirname, '..')
16
+ const SRC = join(ROOT, 'src')
17
+ const OUT = join(ROOT, '.clew', 'CODEGRAPH.md')
18
+
19
+ function rg(args: string): string {
20
+ try {
21
+ return execSync(`rg ${args}`, { cwd: ROOT, encoding: 'utf-8', timeout: 60000, stdio: ['pipe', 'pipe', 'ignore'] })
22
+ } catch (e: any) {
23
+ return e.stdout || ''
24
+ }
25
+ }
26
+
27
+ interface Module {
28
+ path: string
29
+ lines: number
30
+ exports: string[]
31
+ types: string[]
32
+ classNames: string[]
33
+ imports: string[]
34
+ todos: number
35
+ }
36
+
37
+ // ── 1. File list ──
38
+ console.log('📁 Scanning files...')
39
+ const fileLines = rg('--count --sort path --type ts "^" src/').trim().split('\n').filter(Boolean)
40
+
41
+ const files = new Map<string, Module>()
42
+
43
+ for (const entry of fileLines) {
44
+ const colon = entry.lastIndexOf(':')
45
+ const relPath = entry.slice(0, colon).replace(/\\/g, '/').replace(/^src\//, '')
46
+ const lineCount = parseInt(entry.slice(colon + 1)) || 0
47
+ if (!relPath || relPath.startsWith('..')) continue
48
+
49
+ files.set(relPath, {
50
+ path: relPath,
51
+ lines: lineCount,
52
+ exports: [],
53
+ types: [],
54
+ classNames: [],
55
+ imports: [],
56
+ todos: 0,
57
+ })
58
+ }
59
+ console.log(` ${files.size} files`)
60
+
61
+ // ── 2. Exports via ripgrep (bulk) ──
62
+ console.log('🔍 Exports...')
63
+
64
+ type Field = 'exports' | 'types' | 'classNames'
65
+ const patterns: { rgPattern: string; field: Field }[] = [
66
+ { rgPattern: 'export (async )?function [a-zA-Z_$][a-zA-Z0-9_$]*', field: 'exports' },
67
+ { rgPattern: 'export const [a-zA-Z_$][a-zA-Z0-9_$]*', field: 'exports' },
68
+ { rgPattern: 'export (abstract )?class [a-zA-Z_$][a-zA-Z0-9_$]*', field: 'classNames' },
69
+ { rgPattern: 'export interface [a-zA-Z_$][a-zA-Z0-9_$]*', field: 'types' },
70
+ { rgPattern: 'export type [a-zA-Z_$][a-zA-Z0-9_$]*', field: 'types' },
71
+ ]
72
+
73
+ for (const p of patterns) {
74
+ // Single rg call per pattern — fast
75
+ const out = rg(`--with-filename --only-matching --no-line-number "${p.rgPattern}" src/`).trim()
76
+ for (const line of out.split('\n').filter(Boolean)) {
77
+ const fileSep = line.indexOf(':')
78
+ if (fileSep === -1) continue
79
+ const rawPath = line.slice(0, fileSep).replace(/\\/g, '/').replace(/^src\//, '')
80
+ const matched = line.slice(fileSep + 1).trim()
81
+ const name = matched.split(/\s+/).pop()
82
+ if (!name || !rawPath) continue
83
+ const f = files.get(rawPath)
84
+ if (f && !(f[p.field] as string[]).includes(name)) {
85
+ (f[p.field] as string[]).push(name)
86
+ }
87
+ }
88
+ }
89
+
90
+ // ── 3. Imports + TODOs ──
91
+ console.log('📎 Dependencies...')
92
+
93
+ for (const [relPath, info] of files) {
94
+ try {
95
+ const content = readFileSync(join(SRC, relPath), 'utf-8')
96
+
97
+ // Internal imports
98
+ const srcImports: string[] = []
99
+ for (const line of content.split('\n')) {
100
+ const m = line.match(/from\s+['"](src\/[^'"]+)['"]/)
101
+ if (m) srcImports.push(m[1])
102
+ }
103
+ info.imports = [...new Set(srcImports)]
104
+
105
+ // TODOs
106
+ info.todos = (content.match(/\b(TODO|FIXME|HACK|XXX)\b/g) || []).length
107
+ } catch {
108
+ // skip unreadable files
109
+ }
110
+ }
111
+
112
+ // Also add class names to exports
113
+ for (const f of files.values()) {
114
+ for (const cls of f.classNames) {
115
+ if (!f.exports.includes(cls)) f.exports.push(cls)
116
+ }
117
+ }
118
+
119
+ // ── 4. Generate markdown ──
120
+ console.log('📝 Generating...')
121
+
122
+ const totalLines = [...files.values()].reduce((a, f) => a + f.lines, 0)
123
+ const totalExports = [...files.values()].reduce((a, f) => a + f.exports.length, 0)
124
+ const totalTodos = [...files.values()].reduce((a, f) => a + f.todos, 0)
125
+
126
+ const md: string[] = []
127
+ md.push('# Code Graph')
128
+ md.push('')
129
+ md.push(`_Auto-generated ${new Date().toISOString().slice(0, 10)}_`)
130
+ md.push('')
131
+ md.push(`- **Source files**: ${files.size}`)
132
+ md.push(`- **Total lines**: ${totalLines.toLocaleString()}`)
133
+ md.push(`- **Exports**: ${totalExports}`)
134
+ md.push(`- **TODOs**: ${totalTodos}`)
135
+ md.push('')
136
+
137
+ // Group by top directory
138
+ const dirMap = new Map<string, Module[]>()
139
+ for (const f of files.values()) {
140
+ const dir = f.path.split('/')[0]
141
+ if (!dirMap.has(dir)) dirMap.set(dir, [])
142
+ dirMap.get(dir)!.push(f)
143
+ }
144
+
145
+ for (const [dir, dirFiles] of [...dirMap.entries()].sort()) {
146
+ md.push(`## ${dir}/`)
147
+ md.push('')
148
+ dirFiles.sort((a, b) => b.lines - a.lines)
149
+
150
+ for (const f of dirFiles) {
151
+ if (f.lines < 25 && !f.exports.length && !f.types.length) continue
152
+
153
+ const badges = [`${f.lines} lines`]
154
+ if (f.todos) badges.push(`⚠️ ${f.todos}`)
155
+
156
+ md.push(`### \`${f.path}\``)
157
+ md.push(`_${badges.join(', ')}_`)
158
+
159
+ if (f.exports.length) {
160
+ const list = f.exports.slice(0, 10).join(', ')
161
+ md.push(`- exports: ${list}${f.exports.length > 10 ? ` [+${f.exports.length - 10}]` : ''}`)
162
+ }
163
+ if (f.types.length) {
164
+ const list = f.types.slice(0, 6).join(', ')
165
+ md.push(`- types: ${list}${f.types.length > 6 ? ` [+${f.types.length - 6}]` : ''}`)
166
+ }
167
+ if (f.imports.length) {
168
+ const list = f.imports.slice(0, 5).join(', ')
169
+ md.push(`- deps: ${list}${f.imports.length > 5 ? ` (+${f.imports.length - 5})` : ''}`)
170
+ }
171
+ md.push('')
172
+ }
173
+ }
174
+
175
+ // Hot dependencies
176
+ const depCount = new Map<string, number>()
177
+ for (const f of files.values()) {
178
+ for (const dep of f.imports) {
179
+ depCount.set(dep, (depCount.get(dep) || 0) + 1)
180
+ }
181
+ }
182
+
183
+ const sortedDeps = [...depCount.entries()].sort((a, b) => b[1] - a[1]).slice(0, 15)
184
+ if (sortedDeps.length) {
185
+ md.push('---')
186
+ md.push('## Hot Dependencies')
187
+ md.push('')
188
+ for (const [dep, count] of sortedDeps) {
189
+ md.push(`- \`${dep}\` — imported by ${count} files`)
190
+ }
191
+ md.push('')
192
+ }
193
+
194
+ // Large files
195
+ const largeFiles = [...files.values()].filter(f => f.lines > 300).sort((a, b) => b.lines - a.lines)
196
+ if (largeFiles.length) {
197
+ md.push('---')
198
+ md.push('## Large Files (>300 lines)')
199
+ md.push('')
200
+ for (const f of largeFiles) {
201
+ md.push(`- \`${f.path}\` — ${f.lines} lines${f.todos ? ', ⚠️' : ''}`)
202
+ }
203
+ md.push('')
204
+ }
205
+
206
+ // TODO hotspots
207
+ const todoFiles = [...files.values()].filter(f => f.todos > 2).sort((a, b) => b.todos - a.todos).slice(0, 10)
208
+ if (todoFiles.length) {
209
+ md.push('---')
210
+ md.push('## TODO Hotspots')
211
+ md.push('')
212
+ for (const f of todoFiles) {
213
+ md.push(`- \`${f.path}\` — ${f.todos} TODOs`)
214
+ }
215
+ md.push('')
216
+ }
217
+
218
+ mkdirSync(dirname(OUT), { recursive: true })
219
+ writeFileSync(OUT, md.join('\n'), 'utf-8')
220
+ console.log(`\n✅ ${OUT}`)
221
+ console.log(` ${files.size} files | ${totalLines.toLocaleString()} lines | ${totalExports} exports`)
@@ -0,0 +1,95 @@
1
+ #!/usr/bin/env bash
2
+ #
3
+ # Comments on a GitHub issue with a list of potential duplicates.
4
+ # Usage: ./comment-on-duplicates.sh --potential-duplicates 456 789 101
5
+ #
6
+ # The base issue number is read from the workflow event payload.
7
+ #
8
+
9
+ set -euo pipefail
10
+
11
+ REPO="JonusNattapong/ClaudeCode"
12
+
13
+ # Read from event payload so the issue number is bound to the triggering event.
14
+ # Falls back to workflow_dispatch inputs for manual runs.
15
+ BASE_ISSUE=$(jq -r '.issue.number // .inputs.issue_number // empty' "${GITHUB_EVENT_PATH:?GITHUB_EVENT_PATH not set}")
16
+ if ! [[ "$BASE_ISSUE" =~ ^[0-9]+$ ]]; then
17
+ echo "Error: no issue number in event payload" >&2
18
+ exit 1
19
+ fi
20
+
21
+ DUPLICATES=()
22
+
23
+ # Parse arguments
24
+ while [[ $# -gt 0 ]]; do
25
+ case $1 in
26
+ --potential-duplicates)
27
+ shift
28
+ while [[ $# -gt 0 && ! "$1" =~ ^-- ]]; do
29
+ DUPLICATES+=("$1")
30
+ shift
31
+ done
32
+ ;;
33
+ *)
34
+ echo "Error: unknown argument (only --potential-duplicates is accepted)" >&2
35
+ exit 1
36
+ ;;
37
+ esac
38
+ done
39
+
40
+ # Validate duplicates
41
+ if [[ ${#DUPLICATES[@]} -eq 0 ]]; then
42
+ echo "Error: --potential-duplicates requires at least one issue number" >&2
43
+ exit 1
44
+ fi
45
+
46
+ if [[ ${#DUPLICATES[@]} -gt 3 ]]; then
47
+ echo "Error: --potential-duplicates accepts at most 3 issues" >&2
48
+ exit 1
49
+ fi
50
+
51
+ for dup in "${DUPLICATES[@]}"; do
52
+ if ! [[ "$dup" =~ ^[0-9]+$ ]]; then
53
+ echo "Error: duplicate issue must be a number, got: $dup" >&2
54
+ exit 1
55
+ fi
56
+ done
57
+
58
+ # Validate that base issue exists
59
+ if ! gh issue view "$BASE_ISSUE" --repo "$REPO" &>/dev/null; then
60
+ echo "Error: issue #$BASE_ISSUE does not exist in $REPO" >&2
61
+ exit 1
62
+ fi
63
+
64
+ # Validate that all duplicate issues exist
65
+ for dup in "${DUPLICATES[@]}"; do
66
+ if ! gh issue view "$dup" --repo "$REPO" &>/dev/null; then
67
+ echo "Error: issue #$dup does not exist in $REPO" >&2
68
+ exit 1
69
+ fi
70
+ done
71
+
72
+ # Build comment body
73
+ COUNT=${#DUPLICATES[@]}
74
+ if [[ $COUNT -eq 1 ]]; then
75
+ HEADER="Found 1 possible duplicate issue:"
76
+ else
77
+ HEADER="Found $COUNT possible duplicate issues:"
78
+ fi
79
+
80
+ BODY="$HEADER"$'\n\n'
81
+ INDEX=1
82
+ for dup in "${DUPLICATES[@]}"; do
83
+ BODY+="$INDEX. https://github.com/$REPO/issues/$dup"$'\n'
84
+ ((INDEX++))
85
+ done
86
+
87
+ BODY+=$'\n'"This issue will be automatically closed as a duplicate in 3 days."$'\n\n'
88
+ BODY+="- If your issue is a duplicate, please close it and 👍 the existing issue instead"$'\n'
89
+ BODY+="- To prevent auto-closure, add a comment or 👎 this comment"$'\n\n'
90
+ BODY+="🤖 Generated with [Claude Code](https://claude.ai/code)"
91
+
92
+ # Post the comment
93
+ gh issue comment "$BASE_ISSUE" --repo "$REPO" --body "$BODY"
94
+
95
+ echo "Posted duplicate comment on issue #$BASE_ISSUE"
@@ -0,0 +1,84 @@
1
+ #!/usr/bin/env bash
2
+ #
3
+ # Edits labels on a GitHub issue.
4
+ # Usage: ./edit-issue-labels.sh --add-label bug --add-label needs-triage --remove-label untriaged
5
+ #
6
+ # The issue number is read from the workflow event payload.
7
+ #
8
+
9
+ set -euo pipefail
10
+
11
+ # Read from event payload so the issue number is bound to the triggering event.
12
+ # Falls back to workflow_dispatch inputs for manual runs.
13
+ ISSUE=$(jq -r '.issue.number // .inputs.issue_number // empty' "${GITHUB_EVENT_PATH:?GITHUB_EVENT_PATH not set}")
14
+ if ! [[ "$ISSUE" =~ ^[0-9]+$ ]]; then
15
+ echo "Error: no issue number in event payload" >&2
16
+ exit 1
17
+ fi
18
+
19
+ ADD_LABELS=()
20
+ REMOVE_LABELS=()
21
+
22
+ # Parse arguments
23
+ while [[ $# -gt 0 ]]; do
24
+ case $1 in
25
+ --add-label)
26
+ ADD_LABELS+=("$2")
27
+ shift 2
28
+ ;;
29
+ --remove-label)
30
+ REMOVE_LABELS+=("$2")
31
+ shift 2
32
+ ;;
33
+ *)
34
+ echo "Error: unknown argument (only --add-label and --remove-label are accepted)" >&2
35
+ exit 1
36
+ ;;
37
+ esac
38
+ done
39
+
40
+ if [[ ${#ADD_LABELS[@]} -eq 0 && ${#REMOVE_LABELS[@]} -eq 0 ]]; then
41
+ exit 1
42
+ fi
43
+
44
+ # Fetch valid labels from the repo
45
+ VALID_LABELS=$(gh label list --limit 500 --json name --jq '.[].name')
46
+
47
+ # Filter to only labels that exist in the repo
48
+ FILTERED_ADD=()
49
+ for label in "${ADD_LABELS[@]}"; do
50
+ if echo "$VALID_LABELS" | grep -qxF "$label"; then
51
+ FILTERED_ADD+=("$label")
52
+ fi
53
+ done
54
+
55
+ FILTERED_REMOVE=()
56
+ for label in "${REMOVE_LABELS[@]}"; do
57
+ if echo "$VALID_LABELS" | grep -qxF "$label"; then
58
+ FILTERED_REMOVE+=("$label")
59
+ fi
60
+ done
61
+
62
+ if [[ ${#FILTERED_ADD[@]} -eq 0 && ${#FILTERED_REMOVE[@]} -eq 0 ]]; then
63
+ exit 0
64
+ fi
65
+
66
+ # Build gh command arguments
67
+ GH_ARGS=("issue" "edit" "$ISSUE")
68
+
69
+ for label in "${FILTERED_ADD[@]}"; do
70
+ GH_ARGS+=("--add-label" "$label")
71
+ done
72
+
73
+ for label in "${FILTERED_REMOVE[@]}"; do
74
+ GH_ARGS+=("--remove-label" "$label")
75
+ done
76
+
77
+ gh "${GH_ARGS[@]}"
78
+
79
+ if [[ ${#FILTERED_ADD[@]} -gt 0 ]]; then
80
+ echo "Added: ${FILTERED_ADD[*]}"
81
+ fi
82
+ if [[ ${#FILTERED_REMOVE[@]} -gt 0 ]]; then
83
+ echo "Removed: ${FILTERED_REMOVE[*]}"
84
+ fi
@@ -0,0 +1,46 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+
4
+ function walk(dir) {
5
+ const r = [];
6
+ for (const e of fs.readdirSync(dir, {withFileTypes:true})) {
7
+ const f = path.join(dir, e.name);
8
+ if (e.isDirectory()) {
9
+ if (!e.name.startsWith('.') && e.name !== 'node_modules' && e.name !== 'dist') r.push(...walk(f));
10
+ } else if (e.name.endsWith('.ts') || e.name.endsWith('.tsx')) r.push(f);
11
+ }
12
+ return r;
13
+ }
14
+
15
+ const files = walk('src');
16
+ let count = 0;
17
+ for (const f of files) {
18
+ let c = fs.readFileSync(f, 'utf-8');
19
+ const orig = c;
20
+
21
+ c = c.replace(/\/peer\//g, '/swarm/');
22
+ c = c.replace(/\bPeer(Broadcast|Disconnect|Discover|Join|ListMessages|ListRoles|Ping|Run|SendMessage|SetName|SetRole|Share|Spawn|Info|Help|Server|Store|Discovery|StatusLine|Type)Tool\b/g, 'Swarm$1Tool');
23
+ c = c.replace(/\bPeer(Broadcast|Disconnect|Discover|Join|ListMessages|ListRoles|Ping|Run|SendMessage|SetName|SetRole|Share|Spawn|Info|Help|Server|Store|Discovery|StatusLine|Type)\b/g, 'Swarm$1');
24
+ c = c.replace(/\bProcessPeer(Provider|Tool)/g, 'ProcessSwarm$1');
25
+ c = c.replace(/\bCodexPeer/g, 'CodexSwarm');
26
+ c = c.replace(/\bgetProcessPeerProvider\b/g, 'getProcessSwarmProvider');
27
+ c = c.replace(/\bgetProcessPeerProviderIds\b/g, 'getProcessSwarmProviderIds');
28
+ c = c.replace(/\bmyPeerId\b/g, 'mySwarmId');
29
+ c = c.replace(/\bpeerId\b/g, 'swarmId');
30
+ c = c.replace(/\bPEER_(\w+)_TOOL_NAME\b/g, 'SWARM_$1_TOOL_NAME');
31
+ c = c.replace(/\bpeer(Server|Store|Discovery|Port|Name|Info|List|Count|Connection|Sharing|Health)\b/g, 'swarm$1');
32
+ c = c.replace(/\bgetGlobalPeer(Server|Store)\b/g, 'getGlobalSwarm$1');
33
+ c = c.replace(/\bnotifyPeerFeedback\b/g, 'notifySwarmFeedback');
34
+ c = c.replace(/\bsetPeerFeedbackHandler\b/g, 'setSwarmFeedbackHandler');
35
+ c = c.replace(/\bPeerFeedback\b/g, 'SwarmFeedback');
36
+ c = c.replace(/\bpeer-feedback/g, 'swarm-feedback');
37
+ c = c.replace(/\bpeerFeedback/g, 'swarmFeedback');
38
+ c = c.replace(/\/peer\b/g, '/swarm');
39
+ c = c.replace(/['"]peer-/g, (m) => m.replace('peer-', 'swarm-'));
40
+ c = c.replace(/\bspawnPeerTerminal\b/g, 'spawnSwarmTerminal');
41
+ c = c.replace(/\bparseProcessPeerRunArgs\b/g, 'parseProcessSwarmRunArgs');
42
+ c = c.replace(/\bProcessPeerRunArgs\b/g, 'ProcessSwarmRunArgs');
43
+
44
+ if (c !== orig) { fs.writeFileSync(f, c, 'utf-8'); count++; }
45
+ }
46
+ console.log('Updated:', count, 'files');
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Fix mojibake encoding in all docs HTML files.
3
+ *
4
+ * Problem: Files were double-encoded — original UTF-8 bytes were
5
+ * misinterpreted as CP1252 then re-encoded as UTF-8, producing
6
+ * characters like — instead of — and ค instead of ค.
7
+ *
8
+ * This script reverses the damage by mapping each Unicode char
9
+ * back to its CP1252 byte and re-decoding as UTF-8.
10
+ * Legitimate Unicode characters (Thai, arrows, emoji) pass through.
11
+ */
12
+ import fs from 'node:fs';
13
+ import path from 'node:path';
14
+
15
+ const cp1252 = new Map([
16
+ [0x20ac, 0x80], [0x201a, 0x82], [0x0192, 0x83], [0x201e, 0x84],
17
+ [0x2026, 0x85], [0x2020, 0x86], [0x2021, 0x87], [0x02c6, 0x88],
18
+ [0x2030, 0x89], [0x0160, 0x8a], [0x2039, 0x8b], [0x0152, 0x8c],
19
+ [0x017d, 0x8e], [0x2018, 0x91], [0x2019, 0x92], [0x201c, 0x93],
20
+ [0x201d, 0x94], [0x2022, 0x95], [0x2013, 0x96], [0x2014, 0x97],
21
+ [0x02dc, 0x98], [0x2122, 0x99], [0x0161, 0x9a], [0x203a, 0x9b],
22
+ [0x0153, 0x9c], [0x017e, 0x9e], [0x0178, 0x9f],
23
+ ]);
24
+
25
+ function fixMojibake(text) {
26
+ if (text.charCodeAt(0) === 0xfeff) text = text.slice(1); // strip BOM
27
+
28
+ const bytes = [];
29
+ for (let i = 0; i < text.length; i++) {
30
+ const code = text.charCodeAt(i);
31
+ if (code <= 0xff) {
32
+ bytes.push(code);
33
+ } else if (cp1252.has(code)) {
34
+ bytes.push(cp1252.get(code));
35
+ } else {
36
+ const buf = Buffer.from(text[i], 'utf-8');
37
+ for (const b of buf) bytes.push(b);
38
+ }
39
+ }
40
+ return Buffer.from(bytes).toString('utf-8');
41
+ }
42
+
43
+ // Walk docs/ recursively
44
+ function walk(dir) {
45
+ const results = [];
46
+ const list = fs.readdirSync(dir);
47
+ for (const entry of list) {
48
+ const full = path.join(dir, entry);
49
+ const stat = fs.statSync(full);
50
+ if (stat.isDirectory()) {
51
+ results.push(...walk(full));
52
+ } else if (entry.endsWith('.html')) {
53
+ results.push(full);
54
+ }
55
+ }
56
+ return results;
57
+ }
58
+
59
+ const files = walk('docs');
60
+ let changed = 0;
61
+ let errors = 0;
62
+
63
+ for (const f of files) {
64
+ try {
65
+ const content = fs.readFileSync(f, 'utf-8');
66
+ const fixed = fixMojibake(content);
67
+
68
+ // skip if no change
69
+ if (fixed === content) {
70
+ console.log(` ✓ ${f} — already clean`);
71
+ continue;
72
+ }
73
+
74
+ fs.writeFileSync(f, fixed, 'utf-8');
75
+ console.log(` ✔ ${f}`);
76
+ changed++;
77
+ } catch (err) {
78
+ console.error(` ✘ ${f}: ${err.message}`);
79
+ errors++;
80
+ }
81
+ }
82
+
83
+ console.log(`\nDone: ${changed} files fixed, ${errors} errors`);
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Normalize all HTML files to consistent structure:
3
+ * - Empty header (JS-injected)
4
+ * - main.js script at end of body
5
+ */
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+ import { fileURLToPath } from 'node:url';
9
+
10
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
11
+ const docsDir = path.resolve(__dirname, '../docs');
12
+
13
+ function walk(dir) {
14
+ const results = [];
15
+ const list = fs.readdirSync(dir);
16
+ for (const entry of list) {
17
+ const full = path.join(dir, entry);
18
+ const stat = fs.statSync(full);
19
+ if (stat.isDirectory()) results.push(...walk(full));
20
+ else if (entry.endsWith('.html')) results.push(full);
21
+ }
22
+ return results;
23
+ }
24
+
25
+ function relativePath(from, to) {
26
+ const rel = path.relative(path.dirname(from), to);
27
+ return rel.startsWith('.') ? rel : './' + rel;
28
+ }
29
+
30
+ const files = walk(docsDir);
31
+ let fixed = 0;
32
+
33
+ for (const filePath of files) {
34
+ let c = fs.readFileSync(filePath, 'utf-8');
35
+ const name = path.basename(filePath);
36
+
37
+ // Skip landing page (different structure by design)
38
+ if (name === 'index.html' || name === 'index.th.html') continue;
39
+
40
+ // Fix 1: Replace inline header with empty header
41
+ if (!c.includes('<header class="header"></header>')) {
42
+ c = c.replace(/<header class="header">[\s\S]*?<\/header>\s*/,
43
+ '<header class="header"></header>\n');
44
+ }
45
+
46
+ // Fix 2: Add main.js if missing
47
+ if (!c.includes('main.js')) {
48
+ const jsRelPath = relativePath(filePath, path.join(docsDir, 'js/main.js'));
49
+ c = c.replace('</body>', `<script src="${jsRelPath}"></script>\n</body>`);
50
+ }
51
+
52
+ // Fix 3: Ensure Google Fonts includes Noto Sans Thai for .th.html files
53
+ if (name.endsWith('.th.html') && !c.includes('Noto+Sans+Thai')) {
54
+ c = c.replace(
55
+ /family=JetBrains\+Mono:[^"'&]+/,
56
+ '$&, &family=Noto+Sans+Thai:wght@400;500;600;700'
57
+ );
58
+ }
59
+
60
+ // Fix 4: Clean up old lang-wrap styles in features/internals
61
+ c = c.replace(/<div class="lang-wrap">[\s\S]*?<\/div>\s*<\/div>\s*/, '');
62
+
63
+ fs.writeFileSync(filePath, c, 'utf-8');
64
+ fixed++;
65
+ }
66
+
67
+ console.log(`Normalized ${fixed} files`);