Skip to content

Commit c3556be

Browse files
author
Andrew Marcuse
committed
AI-coded runecount
1 parent 878756d commit c3556be

4 files changed

Lines changed: 276 additions & 0 deletions

File tree

runecount.html

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<!doctype html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8" />
5+
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
6+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
7+
<title>Runecount</title>
8+
</head>
9+
<body>
10+
<div id="app"></div>
11+
<script type="module" src="/src/runecount.ts"></script>
12+
</body>
13+
</html>

src/main.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ document.querySelector<HTMLDivElement>('#app')!.innerHTML = `
2626
<p class="mt-2 text-base-content/75">Edit <code class="rounded bg-base-200 px-2 py-1">src/main.ts</code> and save to test <code class="rounded bg-base-200 px-2 py-1">HMR</code></p>
2727
</div>
2828
<a href="/bytecount.html" class="btn btn-outline btn-secondary">Open Bytecount</a>
29+
<a href="/runecount.html" class="btn btn-outline">Open Runecount</a>
2930
<button id="counter" type="button" class="btn btn-primary"></button>
3031
</div>
3132
</div>

src/runecount.ts

Lines changed: 261 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,261 @@
1+
import './style.css'
2+
import { renderHeader } from './components/header.ts'
3+
4+
type RuneStats = {
5+
count: number
6+
first: number
7+
last: number
8+
}
9+
10+
type CharsetKey = 'utf8' | 'utf16' | 'latin1'
11+
12+
const charsetToEncoding: Record<CharsetKey, string> = {
13+
utf8: 'utf-8',
14+
utf16: 'utf-16le',
15+
latin1: 'iso-8859-1',
16+
}
17+
18+
document.querySelector<HTMLDivElement>('#app')!.innerHTML = `
19+
<main class="min-h-screen bg-base-200" data-theme="light">
20+
<section class="mx-auto flex max-w-5xl flex-col gap-8 px-4 py-8 md:px-8 md:py-12">
21+
${renderHeader()}
22+
23+
<section class="rounded-box border border-base-300 bg-base-100 p-8 shadow-sm">
24+
<h1 class="text-3xl font-bold">Rune Count</h1>
25+
<p class="mt-3 text-base-content/70">Choose a text file and start counting Unicode code points.</p>
26+
27+
<form id="runecount-form" class="mt-6 flex flex-col gap-4" action="#" method="post">
28+
<label class="form-control w-full">
29+
<div class="label">
30+
<span class="label-text">Input file</span>
31+
</div>
32+
<input id="input-file" type="file" name="inputFile" class="file-input file-input-bordered w-full" />
33+
</label>
34+
35+
<label class="form-control w-full md:max-w-xs">
36+
<div class="label">
37+
<span class="label-text">Character set</span>
38+
</div>
39+
<select id="charset-select" name="charset" class="select select-bordered w-full">
40+
<option value="utf8" selected>UTF-8</option>
41+
<option value="utf16">UTF-16</option>
42+
<option value="latin1">Latin-1</option>
43+
</select>
44+
</label>
45+
46+
<div id="form-error" class="alert alert-error hidden" role="alert" aria-live="polite"></div>
47+
48+
<div>
49+
<button id="start-button" type="submit" class="btn btn-primary">Start</button>
50+
</div>
51+
</form>
52+
53+
<section id="results" class="mt-8 hidden">
54+
<h2 class="text-xl font-semibold">Code Point Counts</h2>
55+
<p id="results-summary" class="mt-2 text-base-content/70"></p>
56+
57+
<div class="mt-4 overflow-x-auto">
58+
<table class="table table-zebra">
59+
<thead>
60+
<tr>
61+
<th class="text-center">Char</th>
62+
<th class="text-center">Code Point</th>
63+
<th class="text-right">Count</th>
64+
<th class="text-right">First</th>
65+
<th class="text-right">Last</th>
66+
</tr>
67+
</thead>
68+
<tbody id="results-body"></tbody>
69+
</table>
70+
</div>
71+
</section>
72+
</section>
73+
</section>
74+
</main>
75+
`
76+
77+
const form = document.querySelector<HTMLFormElement>('#runecount-form')
78+
const fileInput = document.querySelector<HTMLInputElement>('#input-file')
79+
const charsetSelect = document.querySelector<HTMLSelectElement>('#charset-select')
80+
const formError = document.querySelector<HTMLDivElement>('#form-error')
81+
const startButton = document.querySelector<HTMLButtonElement>('#start-button')
82+
const resultsSection = document.querySelector<HTMLElement>('#results')
83+
const resultsSummary = document.querySelector<HTMLParagraphElement>('#results-summary')
84+
const resultsBody = document.querySelector<HTMLTableSectionElement>('#results-body')
85+
let errorTimeoutId: number | undefined
86+
87+
const hideError = () => {
88+
if (!formError) {
89+
return
90+
}
91+
92+
formError.textContent = ''
93+
formError.classList.add('hidden')
94+
95+
if (errorTimeoutId !== undefined) {
96+
window.clearTimeout(errorTimeoutId)
97+
errorTimeoutId = undefined
98+
}
99+
}
100+
101+
const showError = (message: string) => {
102+
if (!formError) {
103+
return
104+
}
105+
106+
formError.textContent = message
107+
formError.classList.remove('hidden')
108+
109+
if (errorTimeoutId !== undefined) {
110+
window.clearTimeout(errorTimeoutId)
111+
}
112+
113+
errorTimeoutId = window.setTimeout(() => {
114+
hideError()
115+
}, 5000)
116+
117+
document.addEventListener('click', hideError, { once: true })
118+
}
119+
120+
const isCharsetKey = (value: string): value is CharsetKey => value in charsetToEncoding
121+
122+
const countRunes = async (file: File, charset: CharsetKey): Promise<Map<number, RuneStats>> => {
123+
const runeCounts = new Map<number, RuneStats>()
124+
const fileBuffer = await file.arrayBuffer()
125+
const text = new TextDecoder(charsetToEncoding[charset]).decode(fileBuffer)
126+
127+
let runeOffset = 0
128+
for (const character of text) {
129+
const codePoint = character.codePointAt(0)
130+
131+
if (codePoint === undefined) {
132+
continue
133+
}
134+
135+
const existing = runeCounts.get(codePoint)
136+
137+
if (existing) {
138+
existing.count += 1
139+
existing.last = runeOffset
140+
runeOffset += 1
141+
continue
142+
}
143+
144+
runeCounts.set(codePoint, {
145+
count: 1,
146+
first: runeOffset,
147+
last: runeOffset,
148+
})
149+
150+
runeOffset += 1
151+
}
152+
153+
return runeCounts
154+
}
155+
156+
const formatOffsetHex = (offset: number): string => `0x${offset.toString(16).toUpperCase().padStart(4, '0')}`
157+
const formatOffsetTitle = (offset: number): string => `${offset.toLocaleString()} (decimal)`
158+
const formatCount = (count: number): string => count.toLocaleString()
159+
160+
const escapeHtml = (value: string): string =>
161+
value
162+
.replaceAll('&', '&amp;')
163+
.replaceAll('<', '&lt;')
164+
.replaceAll('>', '&gt;')
165+
.replaceAll('"', '&quot;')
166+
.replaceAll("'", '&#39;')
167+
168+
const toCharDisplay = (codePoint: number): string => {
169+
if (codePoint === 0x00ad) {
170+
return 'SOFT HYPHEN'
171+
}
172+
173+
if (codePoint === 9) {
174+
return 'TAB'
175+
}
176+
177+
if (codePoint === 10) {
178+
return 'LF'
179+
}
180+
181+
if (codePoint === 13) {
182+
return 'CR'
183+
}
184+
185+
if (codePoint === 32) {
186+
return 'SPACE'
187+
}
188+
189+
return escapeHtml(String.fromCodePoint(codePoint))
190+
}
191+
192+
const toCodePointDisplay = (codePoint: number): string => {
193+
const hex = codePoint.toString(16).toUpperCase().padStart(4, '0')
194+
195+
return `U+${hex}`
196+
}
197+
198+
const toCodePointUrl = (codePoint: number): string => {
199+
const hex = codePoint.toString(16).toUpperCase().padStart(4, '0')
200+
201+
return `https://www.fileformat.info/info/unicode/char/${hex}/index.htm`
202+
}
203+
204+
const renderRuneTable = (runeCounts: Map<number, RuneStats>, totalRunes: number) => {
205+
if (!resultsBody || !resultsSummary || !resultsSection) {
206+
return
207+
}
208+
209+
const rows = Array.from(runeCounts.entries())
210+
.sort(([left], [right]) => left - right)
211+
.map(
212+
([codePoint, stats]) =>
213+
`<tr><td class="text-center">${toCharDisplay(codePoint)}</td><td class="text-center"><a class="link link-primary" href="${toCodePointUrl(codePoint)}" target="_blank" rel="noreferrer">${toCodePointDisplay(codePoint)}</a></td><td class="text-right">${formatCount(stats.count)}</td><td class="text-right" title="${formatOffsetTitle(stats.first)}">${formatOffsetHex(stats.first)}</td><td class="text-right" title="${formatOffsetTitle(stats.last)}">${formatOffsetHex(stats.last)}</td></tr>`,
214+
)
215+
216+
resultsBody.innerHTML = rows.join('')
217+
resultsSummary.textContent = `${totalRunes.toLocaleString()} total code points, ${runeCounts.size} distinct values.`
218+
resultsSection.classList.remove('hidden')
219+
}
220+
221+
form?.addEventListener('submit', async (event) => {
222+
event.preventDefault()
223+
224+
const selectedFile = fileInput?.files?.[0]
225+
const selectedCharset = charsetSelect?.value ?? 'utf8'
226+
227+
if (!selectedFile) {
228+
if (resultsSection) {
229+
resultsSection.classList.add('hidden')
230+
}
231+
232+
showError('Please choose a file before starting.')
233+
234+
return
235+
}
236+
237+
if (!isCharsetKey(selectedCharset)) {
238+
showError('Please choose a supported character set.')
239+
return
240+
}
241+
242+
hideError()
243+
244+
if (startButton) {
245+
startButton.disabled = true
246+
startButton.textContent = 'Counting...'
247+
}
248+
249+
try {
250+
const runeCounts = await countRunes(selectedFile, selectedCharset)
251+
const totalRunes = Array.from(runeCounts.values()).reduce((total, stats) => total + stats.count, 0)
252+
renderRuneTable(runeCounts, totalRunes)
253+
} catch {
254+
showError('Unable to read the selected file.')
255+
} finally {
256+
if (startButton) {
257+
startButton.disabled = false
258+
startButton.textContent = 'Start'
259+
}
260+
}
261+
})

vite.config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ export default defineConfig({
99
input: {
1010
main: resolve(__dirname, 'index.html'),
1111
bytecount: resolve(__dirname, 'bytecount.html'),
12+
runecount: resolve(__dirname, 'runecount.html'),
1213
},
1314
},
1415
},

0 commit comments

Comments
 (0)