Skip to content

Commit 06d00e9

Browse files
KEN0421claude
andcommitted
feat: add jwt-decoder, csv-json-converter, loan-amortization-schedule (111 tools)
Three new tools, full 6-locale messages each: 1. jwt-decoder (text) - Decode JWT into header/payload/signature parts - Auto-format iat/exp/nbf claims as ISO dates - Flag expired/not-yet-valid tokens visually - Browser-only — token never transmitted 2. csv-json-converter (text) - RFC 4180 compliant CSV parser (quoted fields, escaped quotes, multi-line) - Bidirectional: CSV→JSON (object array or array-of-arrays) JSON→CSV (auto-detects shape) - Configurable delimiter (comma/semicolon/tab/pipe) 3. loan-amortization-schedule (finance) - Full month-by-month amortization table - Principal/interest split per payment, ending balance - Monthly summary, condensed/expanded view, CSV download - Complements existing loan-calculator + mortgage-calculator Plus comprehensive smoke test (scripts/smoke-test.mjs) verified all 694 production URLs: HTTP 200, html lang correct, 7 hreflang links, SoftwareApplication + FAQPage JSON-LD present. 0 issues. Tool count: 108 → 111. Pages built: 695 → 713 (+18 = 3 × 6 locales). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 7737c30 commit 06d00e9

26 files changed

Lines changed: 2083 additions & 0 deletions

File tree

‎site/scripts/smoke-test.mjs‎

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
// Comprehensive smoke test: every (locale, tool) URL — verifies HTTP 200,
2+
// correct <html lang>, 6 hreflang alternates, structured data presence.
3+
4+
import { readdirSync } from "node:fs";
5+
import path from "node:path";
6+
7+
const ORIGIN = "https://tools.appdevelopsk.com";
8+
const LOCALES = ["en", "ja", "zh-CN", "zh-TW", "ko", "es"];
9+
10+
const TOOLS_DIR = path.join(process.cwd(), "src/tools");
11+
const slugs = readdirSync(TOOLS_DIR).filter((s) => !s.startsWith("."));
12+
13+
const targets = [];
14+
// non-locale-specific
15+
targets.push({ kind: "root-redirect", url: `${ORIGIN}/` });
16+
targets.push({ kind: "sitemap", url: `${ORIGIN}/sitemap.xml` });
17+
targets.push({ kind: "robots", url: `${ORIGIN}/robots.txt` });
18+
targets.push({ kind: "ads", url: `${ORIGIN}/ads.txt` });
19+
// per-locale index pages
20+
for (const L of LOCALES) {
21+
targets.push({ kind: "locale-home", url: `${ORIGIN}/${L}`, locale: L });
22+
targets.push({ kind: "locale-tools", url: `${ORIGIN}/${L}/tools`, locale: L });
23+
targets.push({ kind: "locale-privacy", url: `${ORIGIN}/${L}/privacy`, locale: L });
24+
targets.push({ kind: "locale-terms", url: `${ORIGIN}/${L}/terms`, locale: L });
25+
targets.push({ kind: "locale-about", url: `${ORIGIN}/${L}/about`, locale: L });
26+
targets.push({ kind: "locale-contact", url: `${ORIGIN}/${L}/contact`, locale: L });
27+
targets.push({ kind: "locale-disclosure", url: `${ORIGIN}/${L}/disclosure`, locale: L });
28+
for (const slug of slugs) {
29+
targets.push({ kind: "tool", url: `${ORIGIN}/${L}/tools/${slug}`, locale: L, slug });
30+
}
31+
}
32+
33+
console.log(`[smoke] Auditing ${targets.length} URLs concurrently (limit 16)…`);
34+
35+
const concurrency = 16;
36+
const issues = [];
37+
let pos = 0;
38+
let done = 0;
39+
40+
async function checkOne(t) {
41+
try {
42+
const res = await fetch(t.url, { redirect: "manual" });
43+
const status = res.status;
44+
if (t.kind === "root-redirect") {
45+
if (status < 300 || status >= 400) issues.push({ ...t, issue: `expected redirect, got HTTP ${status}` });
46+
return;
47+
}
48+
if (status !== 200) {
49+
issues.push({ ...t, issue: `HTTP ${status}` });
50+
return;
51+
}
52+
if (t.kind !== "tool" && t.kind !== "locale-home" && t.kind !== "locale-tools" && !t.kind.startsWith("locale-")) {
53+
return; // skip HTML checks for non-html
54+
}
55+
if (!t.locale) return;
56+
const html = await res.text();
57+
58+
// <html lang="...">
59+
const langMatch = html.match(/<html\s+[^>]*lang="([^"]+)"/);
60+
if (!langMatch || langMatch[1].toLowerCase() !== t.locale.toLowerCase()) {
61+
issues.push({ ...t, issue: `wrong html lang (got ${langMatch?.[1]} want ${t.locale})` });
62+
}
63+
64+
// hreflang count: 6 locales + x-default = 7
65+
const hrefLangCount = (html.match(/hrefLang="/g) || []).length;
66+
if (hrefLangCount < 7) {
67+
issues.push({ ...t, issue: `hreflang count ${hrefLangCount} < 7` });
68+
}
69+
70+
// For tool pages, check SoftwareApplication & FAQPage in JSON-LD
71+
if (t.kind === "tool") {
72+
if (!html.includes('"@type":"SoftwareApplication"')) {
73+
issues.push({ ...t, issue: "missing SoftwareApplication JSON-LD" });
74+
}
75+
if (!html.includes('"@type":"FAQPage"')) {
76+
issues.push({ ...t, issue: "missing FAQPage JSON-LD" });
77+
}
78+
}
79+
} catch (err) {
80+
issues.push({ ...t, issue: `fetch error: ${err.message}` });
81+
} finally {
82+
done++;
83+
if (done % 50 === 0 || done === targets.length) {
84+
process.stdout.write(`\r[smoke] ${done}/${targets.length} (${Math.round((done/targets.length)*100)}%) — issues: ${issues.length} `);
85+
}
86+
}
87+
}
88+
89+
async function worker() {
90+
while (pos < targets.length) {
91+
const t = targets[pos++];
92+
if (!t) return;
93+
await checkOne(t);
94+
}
95+
}
96+
97+
const start = Date.now();
98+
await Promise.all(Array.from({ length: concurrency }, worker));
99+
const elapsed = ((Date.now() - start) / 1000).toFixed(1);
100+
101+
console.log(`\n[smoke] Done in ${elapsed}s\n`);
102+
103+
// Group issues
104+
const byIssue = new Map();
105+
for (const i of issues) {
106+
const key = i.issue;
107+
if (!byIssue.has(key)) byIssue.set(key, []);
108+
byIssue.get(key).push(i);
109+
}
110+
111+
if (issues.length === 0) {
112+
console.log("✅ ALL PASS — 0 issues across " + targets.length + " URLs");
113+
} else {
114+
console.log("❌ " + issues.length + " issues found:\n");
115+
for (const [issue, list] of [...byIssue.entries()].sort((a, b) => b[1].length - a[1].length)) {
116+
console.log(` [${list.length}] ${issue}`);
117+
for (const x of list.slice(0, 3)) console.log(` ${x.url}`);
118+
if (list.length > 3) console.log(` … and ${list.length - 3} more`);
119+
}
120+
process.exit(1);
121+
}

‎site/src/lib/tools/registry.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,9 @@ import ruleOf72 from "@/tools/rule-of-72-calculator";
108108
import cagr from "@/tools/cagr-calculator";
109109
import stopwatch from "@/tools/stopwatch";
110110
import caesar from "@/tools/caesar-cipher";
111+
import jwtDec from "@/tools/jwt-decoder";
112+
import csvJson from "@/tools/csv-json-converter";
113+
import amortization from "@/tools/loan-amortization-schedule";
111114

112115
/**
113116
* 全ツールの中央レジストリ。新規ツール追加時はここに登録するだけで
@@ -222,6 +225,9 @@ export const TOOLS: ToolDefinition[] = [
222225
cagr,
223226
stopwatch,
224227
caesar,
228+
jwtDec,
229+
csvJson,
230+
amortization,
225231
];
226232

227233
const SLUG_INDEX = new Map(TOOLS.map((t) => [t.meta.slug, t]));
Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
"use client";
2+
3+
import { useMemo, useState } from "react";
4+
import { useTranslations } from "next-intl";
5+
6+
// RFC 4180 compliant CSV parser (handles quoted fields with commas, escaped quotes, line breaks).
7+
function parseCsv(text: string, delimiter: string): string[][] {
8+
const rows: string[][] = [];
9+
let row: string[] = [];
10+
let field = "";
11+
let inQuotes = false;
12+
let i = 0;
13+
while (i < text.length) {
14+
const ch = text[i];
15+
if (inQuotes) {
16+
if (ch === '"') {
17+
if (text[i + 1] === '"') {
18+
field += '"';
19+
i += 2;
20+
continue;
21+
}
22+
inQuotes = false;
23+
i++;
24+
continue;
25+
}
26+
field += ch;
27+
i++;
28+
} else {
29+
if (ch === '"') {
30+
inQuotes = true;
31+
i++;
32+
} else if (ch === delimiter) {
33+
row.push(field);
34+
field = "";
35+
i++;
36+
} else if (ch === "\n" || ch === "\r") {
37+
row.push(field);
38+
rows.push(row);
39+
row = [];
40+
field = "";
41+
if (ch === "\r" && text[i + 1] === "\n") i += 2;
42+
else i++;
43+
} else {
44+
field += ch;
45+
i++;
46+
}
47+
}
48+
}
49+
if (field || row.length) {
50+
row.push(field);
51+
rows.push(row);
52+
}
53+
return rows.filter((r) => r.length > 1 || (r.length === 1 && r[0] !== ""));
54+
}
55+
56+
function escapeCsvField(value: unknown, delimiter: string): string {
57+
const s = value === null || value === undefined ? "" : String(value);
58+
if (s.includes(delimiter) || s.includes('"') || s.includes("\n") || s.includes("\r")) {
59+
return '"' + s.replace(/"/g, '""') + '"';
60+
}
61+
return s;
62+
}
63+
64+
export default function CsvJsonConverter() {
65+
const t = useTranslations("tools.csv-json-converter");
66+
const [direction, setDirection] = useState<"csvToJson" | "jsonToCsv">("csvToJson");
67+
const [delimiter, setDelimiter] = useState(",");
68+
const [headerRow, setHeaderRow] = useState(true);
69+
const [input, setInput] = useState(
70+
"name,age,city\nAlice,30,Tokyo\nBob,25,Seoul\nCarol,42,Madrid"
71+
);
72+
73+
const result = useMemo(() => {
74+
if (!input.trim()) return null;
75+
try {
76+
if (direction === "csvToJson") {
77+
const rows = parseCsv(input, delimiter);
78+
if (rows.length === 0) return { ok: true as const, value: "[]" };
79+
if (headerRow) {
80+
const headers = rows[0]!;
81+
const data = rows.slice(1).map((r) => {
82+
const obj: Record<string, string> = {};
83+
for (let i = 0; i < headers.length; i++) {
84+
const key = headers[i]!;
85+
obj[key] = r[i] ?? "";
86+
}
87+
return obj;
88+
});
89+
return { ok: true as const, value: JSON.stringify(data, null, 2) };
90+
}
91+
return { ok: true as const, value: JSON.stringify(rows, null, 2) };
92+
} else {
93+
const data: unknown = JSON.parse(input);
94+
if (!Array.isArray(data)) throw new Error("expected an array of objects or arrays");
95+
if (data.length === 0) return { ok: true as const, value: "" };
96+
// Decide if array-of-objects or array-of-arrays
97+
if (typeof data[0] === "object" && data[0] !== null && !Array.isArray(data[0])) {
98+
const headers = [...new Set(data.flatMap((r) => Object.keys(r as object)))];
99+
const lines = [headers.map((h) => escapeCsvField(h, delimiter)).join(delimiter)];
100+
for (const row of data) {
101+
const r = row as Record<string, unknown>;
102+
lines.push(headers.map((h) => escapeCsvField(r[h], delimiter)).join(delimiter));
103+
}
104+
return { ok: true as const, value: lines.join("\n") };
105+
}
106+
// Array of arrays
107+
const lines = (data as unknown[][]).map((row) => row.map((v) => escapeCsvField(v, delimiter)).join(delimiter));
108+
return { ok: true as const, value: lines.join("\n") };
109+
}
110+
} catch (err) {
111+
return { ok: false as const, error: (err as Error).message };
112+
}
113+
}, [direction, input, delimiter, headerRow]);
114+
115+
async function copy() {
116+
if (result?.ok) await navigator.clipboard.writeText(result.value);
117+
}
118+
119+
return (
120+
<div>
121+
<div className="mb-4 inline-flex rounded-lg border border-slate-300 p-1 dark:border-slate-700">
122+
<button
123+
onClick={() => setDirection("csvToJson")}
124+
className={`rounded px-3 py-1 text-sm ${direction === "csvToJson" ? "bg-slate-900 text-white dark:bg-slate-100 dark:text-slate-900" : ""}`}
125+
>
126+
{t("direction.csvToJson")}
127+
</button>
128+
<button
129+
onClick={() => setDirection("jsonToCsv")}
130+
className={`rounded px-3 py-1 text-sm ${direction === "jsonToCsv" ? "bg-slate-900 text-white dark:bg-slate-100 dark:text-slate-900" : ""}`}
131+
>
132+
{t("direction.jsonToCsv")}
133+
</button>
134+
</div>
135+
136+
<div className="grid gap-3 sm:grid-cols-[auto_auto_1fr]">
137+
<label className="block">
138+
<span className="text-sm font-medium">{t("input.delimiter")}</span>
139+
<select
140+
value={delimiter}
141+
onChange={(e) => setDelimiter(e.target.value)}
142+
className="mt-1 rounded border border-slate-300 px-2 py-1 text-sm dark:border-slate-700 dark:bg-slate-900"
143+
>
144+
<option value=",">{t("delim.comma")}</option>
145+
<option value=";">{t("delim.semicolon")}</option>
146+
<option value={"\t"}>{t("delim.tab")}</option>
147+
<option value="|">{t("delim.pipe")}</option>
148+
</select>
149+
</label>
150+
{direction === "csvToJson" && (
151+
<label className="flex items-end gap-2 text-sm">
152+
<input type="checkbox" checked={headerRow} onChange={(e) => setHeaderRow(e.target.checked)} className="size-4" />
153+
<span>{t("input.headerRow")}</span>
154+
</label>
155+
)}
156+
</div>
157+
158+
<label className="mt-4 block">
159+
<span className="text-sm font-medium">{direction === "csvToJson" ? t("input.csv") : t("input.json")}</span>
160+
<textarea
161+
value={input}
162+
onChange={(e) => setInput(e.target.value)}
163+
rows={8}
164+
spellCheck={false}
165+
className="mt-1 w-full rounded border border-slate-300 px-3 py-2 font-mono text-sm dark:border-slate-700 dark:bg-slate-900"
166+
/>
167+
</label>
168+
169+
<div className="mt-4">
170+
<div className="flex items-center justify-between">
171+
<span className="text-sm font-medium">{t("output")}</span>
172+
<button onClick={copy} disabled={!result?.ok} className="rounded border border-slate-300 px-3 py-1 text-xs hover:bg-slate-100 disabled:opacity-40 dark:border-slate-700 dark:hover:bg-slate-800">
173+
{t("copy")}
174+
</button>
175+
</div>
176+
{!result ? (
177+
<div className="mt-1 rounded border border-slate-200 bg-slate-50 p-3 text-sm text-slate-500 dark:border-slate-800 dark:bg-slate-900/50">{t("empty")}</div>
178+
) : !result.ok ? (
179+
<div className="mt-1 rounded border border-rose-300 bg-rose-50 p-3 text-sm text-rose-900 dark:border-rose-900 dark:bg-rose-900/20 dark:text-rose-200">
180+
{t("error.label")}: {result.error}
181+
</div>
182+
) : (
183+
<pre className="mt-1 overflow-auto whitespace-pre rounded border border-slate-200 bg-slate-50 p-3 font-mono text-sm dark:border-slate-800 dark:bg-slate-900/50">{result.value}</pre>
184+
)}
185+
</div>
186+
</div>
187+
);
188+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import type { ToolDefinition } from "@/lib/tools/types";
2+
import Component from "./Component";
3+
4+
const def: ToolDefinition = {
5+
meta: {
6+
slug: "csv-json-converter",
7+
category: "text",
8+
applicationCategory: "DeveloperApplication",
9+
updatedAt: "2026-05-08",
10+
related: ["json-formatter", "markdown-to-html", "text-replace"],
11+
primaryKeyword: { en: "CSV to JSON converter", ja: "CSV JSON 変換" },
12+
hasHowTo: true,
13+
hasFaq: true,
14+
},
15+
Component,
16+
};
17+
18+
export default def;

0 commit comments

Comments
 (0)