A => .editorconfig +7 -0
@@ 1,7 @@
+root = true
+
+[*]
+charset = utf-8
+end_of_line = lf
+indent_style = tab
+max_line_length = 100
A => .gitignore +5 -0
@@ 1,5 @@
+.kak*
+dist/
+node_modules/
+www/
+zig*
A => .gitmodules +3 -0
@@ 1,3 @@
+[submodule "deps/kana"]
+ path = deps/kana
+ url = https://git.sr.ht/~gbrlsnchs/kana
A => LICENSE +16 -0
@@ 1,16 @@
+Copyright 2023 Gabriel Sanches
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
+associated documentation files (the "Software"), to deal in the Software without restriction,
+including without limitation the rights to use, copy, modify, merge, publish, distribute,
+sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all copies or substantial
+portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT
+NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES
+OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
A => build.zig +13 -0
@@ 1,13 @@
+const std = @import("std");
+const zig = std.zig;
+
+pub fn build(b: *std.build.Builder) void {
+ const mode = b.standardReleaseOptions();
+ const target = zig.CrossTarget.parse(.{ .arch_os_abi = "wasm32-freestanding" }) catch unreachable;
+
+ const lib = b.addSharedLibrary("kana", "src/kana.zig", .unversioned);
+ lib.setBuildMode(mode);
+ lib.setTarget(target);
+ lib.addPackagePath("kana", "deps/kana/src/lib.zig");
+ lib.install();
+}
A => deno.json +13 -0
@@ 1,13 @@
+{
+ "fmt": {
+ "options": {
+ "useTabs": true,
+ "lineWidth": 100
+ }
+ },
+ "tasks": {
+ "build": "deno run --allow-read --allow-write --allow-run ./scripts/build.ts",
+ "serve": "deno run --allow-read --allow-net=:8000 src/server.ts",
+ "prepare": "deno task build && mkdir -p dist && cp -r www src/server.ts dist/"
+ }
+}
A => deno.lock +20 -0
@@ 1,20 @@
+{
+ "version": "2",
+ "remote": {
+ "https://deno.land/std@0.177.0/async/abortable.ts": "73acfb3ed7261ce0d930dbe89e43db8d34e017b063cf0eaa7d215477bf53442e",
+ "https://deno.land/std@0.177.0/async/deadline.ts": "c5facb0b404eede83e38bd2717ea8ab34faa2ffb20ef87fd261fcba32ba307aa",
+ "https://deno.land/std@0.177.0/async/debounce.ts": "adab11d04ca38d699444ac8a9d9856b4155e8dda2afd07ce78276c01ea5a4332",
+ "https://deno.land/std@0.177.0/async/deferred.ts": "42790112f36a75a57db4a96d33974a936deb7b04d25c6084a9fa8a49f135def8",
+ "https://deno.land/std@0.177.0/async/delay.ts": "73aa04cec034c84fc748c7be49bb15cac3dd43a57174bfdb7a4aec22c248f0dd",
+ "https://deno.land/std@0.177.0/async/mod.ts": "f04344fa21738e5ad6bea37a6bfffd57c617c2d372bb9f9dcfd118a1b622e576",
+ "https://deno.land/std@0.177.0/async/mux_async_iterator.ts": "70c7f2ee4e9466161350473ad61cac0b9f115cff4c552eaa7ef9d50c4cbb4cc9",
+ "https://deno.land/std@0.177.0/async/pool.ts": "fd082bd4aaf26445909889435a5c74334c017847842ec035739b4ae637ae8260",
+ "https://deno.land/std@0.177.0/async/retry.ts": "5efa3ba450ac0c07a40a82e2df296287b5013755d232049efd7ea2244f15b20f",
+ "https://deno.land/std@0.177.0/async/tee.ts": "47e42d35f622650b02234d43803d0383a89eb4387e1b83b5a40106d18ae36757",
+ "https://deno.land/std@0.177.0/encoding/base64.ts": "7de04c2f8aeeb41453b09b186480be90f2ff357613b988e99fabb91d2eeceba1",
+ "https://deno.land/std@0.177.0/http/http_status.ts": "8a7bcfe3ac025199ad804075385e57f63d055b2aed539d943ccc277616d6f932",
+ "https://deno.land/std@0.177.0/http/server.ts": "cbb17b594651215ba95c01a395700684e569c165a567e4e04bba327f41197433",
+ "https://deno.land/x/minifier@v1.1.1/mod.ts": "f911c7e17079a31709dd4041207a48b3c6df50961d8687029681776f98005b89",
+ "https://deno.land/x/minifier@v1.1.1/wasm.js": "3226ccb35a4fcf31362cc50b5ea2e1276aaae01d116dc090f38ee8169e015ebc"
+ }
+}
A => deps/kana +1 -0
@@ 1,1 @@
+Subproject commit c97ac47ac1e6ce4a3db29e18fbdae11fea3e92d9
A => scripts/build.ts +26 -0
@@ 1,26 @@
+import { Language, minify } from "https://deno.land/x/minifier@v1.1.1/mod.ts";
+
+await Deno.mkdir("www", { recursive: true });
+
+async function minifyFile(lang: Language, src: string, dst?: string): Promise<void> {
+ const encoder = new TextEncoder();
+ const decoder = new TextDecoder();
+ const bytes = await Deno.readFile(`src/site/${src}`);
+ const minified = minify(lang, decoder.decode(bytes));
+
+ return Deno.writeFile(`www/${dst || src}`, encoder.encode(minified));
+}
+
+const tasks = [
+ minifyFile(Language.HTML, "index.html"),
+ minifyFile(Language.CSS, "style.css", "style.min.css"),
+ minifyFile(Language.JS, "kana.js", "kana.min.js"),
+ Deno.run({
+ cmd: ["zig", "build", "-Drelease-fast", "--prefix", "www"],
+ env: {
+ "DESTDIR": "",
+ },
+ }).status(),
+];
+
+await Promise.all(tasks);
A => src/kana.zig +65 -0
@@ 1,65 @@
+const std = @import("std");
+const kana = @import("kana");
+
+const heap = std.heap;
+const mem = std.mem;
+
+var gpa = heap.GeneralPurposeAllocator(.{}){};
+const allocator = gpa.allocator();
+
+pub fn log(
+ comptime message_level: std.log.Level,
+ comptime scope: @Type(.EnumLiteral),
+ comptime format: []const u8,
+ args: anytype,
+) void {
+ _ = message_level;
+ _ = scope;
+ _ = format;
+ _ = args;
+}
+
+extern fn handleResult(ptr: [*]const u8, len: usize) void;
+
+export fn allocString(len: usize) [*]const u8 {
+ const slice = allocator.alloc(u8, len) catch @panic("failed to allocate memory");
+ return slice.ptr;
+}
+
+export fn freeString(ptr: [*:0]u8) void {
+ allocator.free(mem.span(ptr));
+}
+
+export fn transliterate(
+ input: [*:0]const u8,
+ katakana: bool,
+ extended: bool,
+ punctuation: bool,
+ force_prolongation: bool,
+ kana_toggle: u8,
+ raw_toggle: u8,
+ prolongation_reset: u8,
+ vowel_shortener: u8,
+ virtual_stop: u8,
+) void {
+ const c_input = mem.span(input);
+ const result = kana.transliterate(
+ allocator,
+ c_input,
+ .{
+ .start_with_katakana = katakana,
+ .extended_katakana = extended,
+ .parse_punctuation = punctuation,
+ .force_prolongation = force_prolongation,
+ .special_chars = .{
+ .kana = kana_toggle,
+ .raw_text = raw_toggle,
+ .reset_prolongation = prolongation_reset,
+ .small_vowel = vowel_shortener,
+ .virt_stop = virtual_stop,
+ },
+ },
+ ) catch |err| @panic(@errorName(err));
+
+ handleResult(result.ptr, result.len);
+}
A => src/server.ts +51 -0
@@ 1,51 @@
+import { serve } from "https://deno.land/std@0.177.0/http/server.ts";
+import { Status } from "https://deno.land/std@0.177.0/http/http_status.ts";
+
+const [index, style, core, wasm] = await Promise.all([
+ Deno.readFile("www/index.html"),
+ Deno.readFile("www/style.min.css"),
+ Deno.readFile("www/kana.min.js"),
+ Deno.readFile("www/lib/kana.wasm"),
+]);
+
+function handler(req: Request) {
+ const { pathname } = new URL(req.url);
+
+ if (pathname === "/") {
+ return new Response(index, {
+ headers: {
+ "content-type": "text/html",
+ },
+ });
+ }
+
+ if (pathname.startsWith("/style.css")) {
+ return new Response(style, {
+ headers: {
+ "content-type": "text/css",
+ },
+ });
+ }
+
+ if (pathname.startsWith("/kana.js")) {
+ return new Response(core, {
+ headers: {
+ "content-type": "text/javascript",
+ },
+ });
+ }
+
+ if (pathname.startsWith("/kana.wasm")) {
+ return new Response(wasm, {
+ headers: {
+ "content-type": "application/wasm",
+ },
+ });
+ }
+
+ return Response.redirect("/");
+}
+
+serve(handler, {
+ port: 8000,
+});
A => src/site/index.html +73 -0
@@ 1,73 @@
+<!DOCTYPE html>
+<html>
+ <head>
+ <title>kana.guru - Convert romaji to hiragana/katakana</title>
+ <meta charset="utf-8" />
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
+ <link rel="stylesheet" href="style.css" />
+ <script type="module" src="kana.js"></script>
+ </head>
+ <body>
+ <div>
+ <h2>
+ かな.グル
+ <span id="copyright">
+ © <span id="copyright-year"></span> Gabriel Sanches /
+ <a href="https://git.sr.ht/~gbrlsnchs/kana-guru">source code</a>
+ </span>
+ </h2>
+
+ <h3>Options</h3>
+ <div>
+ <input id="katakana" type="checkbox" />
+ <label for="katakana">Start with katakana</label>
+ </div>
+
+ <div>
+ <input id="extended" type="checkbox" />
+ <label for="extended">Extended katakana</label>
+ </div>
+
+ <div>
+ <input id="punctuation" type="checkbox" />
+ <label for="punctuation">Parse punctuation</label>
+ </div>
+
+ <div>
+ <input id="force-prolongation" type="checkbox" />
+ <label for="force-prolongation">Force prolongation</label>
+ </div>
+
+ <div>
+ <input id="kana-toggle" type="checkbox" />
+ <label for="kana-toggle">Use @ to toggle kanas</label>
+ </div>
+
+ <div>
+ <input id="raw-toggle" type="checkbox" />
+ <label for="raw-toggle">Use # to toggle raw text</label>
+ </div>
+
+ <div>
+ <input id="prolongation-reset" type="checkbox" />
+ <label for="prolongation-reset">Use ^ to reset prolongation</label>
+ </div>
+
+ <div>
+ <input id="vowel-shortener" type="checkbox" />
+ <label for="vowel-shortener">Use _ to shorten vowel</label>
+ </div>
+
+ <div>
+ <input id="virtual-stop" type="checkbox" />
+ <label for="virtual-stop">Use % to add virtual stop</label>
+ </div>
+
+ <h3>Input</h3>
+ <textarea id="romaji" autocomplete="off" spellcheck="false"></textarea>
+
+ <h3>Output <span id="copy" tabindex="0">(copy)</span></h3>
+ <p id="result"></p>
+ </div>
+ </body>
+</html>
A => src/site/kana.js +90 -0
@@ 1,90 @@
+let result = "";
+const inputs = {
+ romaji: document.getElementById("romaji"),
+ katakana: document.getElementById("katakana"),
+ extended: document.getElementById("extended"),
+ punctuation: document.getElementById("punctuation"),
+ forceProlongation: document.getElementById("force-prolongation"),
+ kanaToggle: document.getElementById("kana-toggle"),
+ rawToggle: document.getElementById("raw-toggle"),
+ prolongationReset: document.getElementById("prolongation-reset"),
+ vowelShortener: document.getElementById("vowel-shortener"),
+ virtualStop: document.getElementById("virtual-stop"),
+};
+const output = document.getElementById("result");
+const loading = document.getElementById("loading");
+const copy = document.getElementById("copy");
+
+const encodeString = (value) => {
+ const buffer = new TextEncoder().encode(value);
+ const ptr = allocString(buffer.length + 1);
+ const slice = new Uint8Array(memory.buffer, ptr, buffer.length + 1);
+ slice.set(buffer);
+ slice[buffer.length] = 0;
+
+ return ptr;
+};
+
+const decodeString = (ptr, len) => {
+ const slice = new Uint8Array(memory.buffer, ptr, len);
+
+ return new TextDecoder().decode(slice);
+};
+
+const parseCheckbox = (el) => {
+ if (!el) {
+ return false;
+ }
+
+ return el.checked;
+};
+
+const parseAscii = (el, char) => {
+ return parseCheckbox(el) ? char.charCodeAt(0) : 0;
+};
+
+const convert = () => {
+ setTimeout(() => {}, 0);
+ const ptr = encodeString(inputs.romaji.value);
+
+ transliterate(
+ ptr,
+ parseCheckbox(inputs.katakana),
+ parseCheckbox(inputs.extended),
+ parseCheckbox(inputs.punctuation),
+ parseCheckbox(inputs.forceProlongation),
+ parseAscii(inputs.kanaToggle, "@"),
+ parseAscii(inputs.rawToggle, "#"),
+ parseAscii(inputs.prolongationReset, "^"),
+ parseAscii(inputs.vowelShortener, "_"),
+ parseAscii(inputs.virtualStop, "%"),
+ );
+ freeString(ptr);
+};
+
+const initialYear = 2023;
+const currentYear = new Date().getUTCFullYear();
+const copyright = document.getElementById("copyright-year");
+copyright.innerText = initialYear === currentYear ? initialYear : `${initialYear}-${currentYear}`;
+
+const {
+ instance: {
+ exports: { memory, allocString, freeString, transliterate },
+ },
+} = await WebAssembly.instantiateStreaming(fetch("kana.wasm"), {
+ env: {
+ handleResult(ptr, len) {
+ output.innerText = len > 0 ? decodeString(ptr, len) : "";
+ },
+ },
+});
+
+for (let [key, value] of Object.entries(inputs)) {
+ value.addEventListener(key === "romaji" ? "keyup" : "input", () => {
+ convert();
+ });
+}
+
+copy.addEventListener("click", () => {
+ navigator.clipboard.writeText(output.innerText || output.textContent);
+});
A => src/site/style.css +129 -0
@@ 1,129 @@
+:root {
+ --bg-color: #1d1f21;
+ --fg-color: #c5c8c6;
+ --gray: #373b41;
+ --copy: #8c9440;
+ --link: #5f819d;
+}
+
+@media (prefers-color-scheme: light) {
+ :root {
+ --bg-color: #c5c8c6;
+ --fg-color: #1d1f21;
+ --copy: #de935f;
+ --link: #a54242;
+ }
+}
+
+body {
+ margin: 0;
+ padding: 0;
+ width: 100vw;
+ display: flex;
+ justify-content: center;
+ background-color: var(--bg-color);
+ color: var(--fg-color);
+ overflow: auto;
+ font-family: monospace;
+}
+
+body > div {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ width: 640px;
+ padding: 16px;
+}
+
+@media (max-width: 640px) {
+ body > div {
+ width: 100vw;
+ }
+}
+
+h2 {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+}
+
+@media (max-width: 640px) {
+ h2 {
+ flex-direction: column;
+ gap: 8px;
+ }
+}
+
+h2,
+h3 {
+ overflow: hidden;
+ position: relative;
+ margin-bottom: 0;
+}
+
+p {
+ margin: 0;
+}
+
+h3:first-of-type:after,
+h3:last-of-type:after {
+ content: "";
+ width: 100%;
+ height: 1px;
+ background: var(--gray);
+ position: absolute;
+ top: 50%;
+ margin-left: 1rem;
+}
+
+a {
+ color: var(--gray);
+ text-decoration: none;
+}
+
+a:hover {
+ color: var(--link);
+}
+
+input[type="checkbox"] {
+ margin-right: 8px;
+}
+
+label:hover,
+input[type="checkbox"]:hover {
+ cursor: pointer;
+}
+
+textarea {
+ height: 8rem;
+ resize: none;
+ overflow-y: scroll;
+ font-size: inherit;
+ border: 1px solid var(--gray);
+ padding: 8px;
+}
+
+textarea,
+input[type="checkbox"] {
+ background-color: var(--bg);
+ color: var(--fg);
+}
+
+#copyright {
+ color: var(--gray);
+ font-size: 0.8rem;
+}
+
+#result {
+ white-space: pre-line;
+ overflow-wrap: break-word;
+}
+
+#copy {
+ font-size: 0.8rem;
+}
+
+#copy:hover {
+ cursor: pointer;
+ color: var(--copy);
+}