From c8d2968b6231c49c3fff1c972e408992f91ce03e Mon Sep 17 00:00:00 2001 From: Archie Date: Fri, 23 Oct 2026 03:55:05 +0100 Subject: [PATCH 01/80] fix(case-converter): keep . and / in the prose styles (#274) Title Case, Sentence case, lowercase and UPPERCASE split words on `.` and `/` like the identifier styles do, then joined them back with spaces, so `3.5` became `3 5`, `a/b` became `a b` and sentences lost their full stops. The prose styles now split only on `_`, `-` and whitespace (plus case boundaries), so `.` and `/` stay in the word like `,` and `!` already do. `user_id`, `userId` and `kebab-case-name` still give `User Id` / `Kebab Case Name` in Title Case, and the identifier styles don't change. Closes #270 Claude-Session: https://claude.ai/code/session_01STJRQtEaEPSLfiWGMn8Syr Co-authored-by: Claude Opus 5.5 --- lib/__tests__/case-utils.test.ts | 16 ++++++++++++++++ lib/case-utils.ts | 16 ++++++++++------ 2 files changed, 26 insertions(+), 6 deletions(-) diff --git a/lib/__tests__/case-utils.test.ts b/lib/__tests__/case-utils.test.ts index b5be638..3313681 100644 --- a/lib/__tests__/case-utils.test.ts +++ b/lib/__tests__/case-utils.test.ts @@ -94,4 +94,20 @@ describe("toCase", () => { expect(toCase("Hello, World!", "lower")).toBe("hello, world!"); expect(toCase("Hello, World!", "upper")).toBe("HELLO, WORLD!"); }); + + it("keeps . and / in the prose styles", () => { + expect(toCase("Hello. World.", "lower")).toBe("hello. world."); + expect(toCase("Hello. World.", "title")).toBe("Hello. World."); + expect(toCase("it's 3.5 km away", "lower")).toBe("it's 3.5 km away"); + expect(toCase("Read the docs at example.com/start.", "lower")).toBe("read the docs at example.com/start."); + expect(toCase("Pick a/b, then stop", "upper")).toBe("PICK A/B, THEN STOP"); + }); + + it("still splits _, - and case boundaries in the prose styles", () => { + expect(toCase("user_id", "title")).toBe("User Id"); + expect(toCase("userId", "title")).toBe("User Id"); + expect(toCase("kebab-case-name", "title")).toBe("Kebab Case Name"); + expect(toCase("user.id", "camel")).toBe("userId"); + expect(toCase("user/id", "snake")).toBe("user_id"); + }); }); diff --git a/lib/case-utils.ts b/lib/case-utils.ts index a351d25..2f14dd3 100644 --- a/lib/case-utils.ts +++ b/lib/case-utils.ts @@ -28,13 +28,13 @@ export const CASE_STYLES: { id: CaseStyle; label: string; example: string }[] = /** * Splits identifiers and phrases into lowercase words. Handles separators - * (spaces, `_`, `-`, `.`, `/`), camelCase and PascalCase boundaries, acronyms - * (`XMLHttpRequest` -> xml, http, request), digits, and non-ASCII letters. + * (spaces, `_`, `-`, `.`, `/` by default), camelCase and PascalCase boundaries, + * acronyms (`XMLHttpRequest` -> xml, http, request), digits, and non-ASCII letters. */ -export function splitWords(input: string): string[] { +export function splitWords(input: string, separators = /[_\-./\s]+/): string[] { const words: string[] = []; // Collapse every separator run first so " Hello World " behaves. - for (const chunk of input.split(/[_\-./\s]+/)) { + for (const chunk of input.split(separators)) { if (!chunk) continue; // lower-or-digit followed by upper: "userId" -> "user Id" // acronym run followed by a capitalised word: "XMLHttp" -> "XML Http" @@ -51,9 +51,13 @@ export function splitWords(input: string): string[] { // Identifier styles keep only letters and digits; the prose styles keep punctuation. const IDENTIFIER_STYLES = new Set(["camel", "pascal", "snake", "constant", "kebab", "dot"]); +// In prose, `.` and `/` are punctuation ("3.5", "a/b", a full stop), not word breaks. +const PROSE_SEPARATORS = /[_\-\s]+/; + function convertLine(line: string, style: CaseStyle): string { - let words = splitWords(line); - if (IDENTIFIER_STYLES.has(style)) { + const identifier = IDENTIFIER_STYLES.has(style); + let words = identifier ? splitWords(line) : splitWords(line, PROSE_SEPARATORS); + if (identifier) { words = words.map((w) => w.replace(/[^\p{L}\p{M}\p{N}]/gu, "")).filter(Boolean); } if (words.length === 0) return ""; From e5aab2045f7e87bd29c61e62085acf33afe27cbb Mon Sep 17 00:00:00 2001 From: Bhushan Agrawal Date: Fri, 23 Oct 2026 08:56:06 +0530 Subject: [PATCH 02/80] feat(guides): add JWT decoding and claims guide (#36) (#273) * feat(guides): add JWT decoding and claims guide (#36) * docs(guides): correct JWT decoding guide details --- app/guides/decode-jwt/opengraph-image.tsx | 12 + app/guides/decode-jwt/page.tsx | 540 ++++++++++++++++++++++ lib/guides.ts | 68 +++ 3 files changed, 620 insertions(+) create mode 100644 app/guides/decode-jwt/opengraph-image.tsx create mode 100644 app/guides/decode-jwt/page.tsx diff --git a/app/guides/decode-jwt/opengraph-image.tsx b/app/guides/decode-jwt/opengraph-image.tsx new file mode 100644 index 0000000..796d616 --- /dev/null +++ b/app/guides/decode-jwt/opengraph-image.tsx @@ -0,0 +1,12 @@ +import { getGuide } from "@/lib/guides"; +import { renderGuideOgImage, OG_SIZE, OG_CONTENT_TYPE } from "@/lib/og"; + +const guide = getGuide("decode-jwt")!; + +export const alt = guide.ogTitle; +export const size = OG_SIZE; +export const contentType = OG_CONTENT_TYPE; + +export default function Image() { + return renderGuideOgImage(guide); +} diff --git a/app/guides/decode-jwt/page.tsx b/app/guides/decode-jwt/page.tsx new file mode 100644 index 0000000..2a1ab8d --- /dev/null +++ b/app/guides/decode-jwt/page.tsx @@ -0,0 +1,540 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import { + ArrowRight, + BookOpen, + Calendar, + CheckCircle2, + Clock, + ShieldAlert, + ShieldCheck, +} from "lucide-react"; +import { getGuide, getRelatedGuides } from "@/lib/guides"; +import { generateGuideMetadata } from "@/lib/toolMetadata"; +import { + guideArticleJsonLd, + guideHowToJsonLd, + guideBreadcrumbJsonLd, + guideFaqJsonLd, +} from "@/lib/jsonLd"; +import { Faq } from "@/components/shared/Faq"; + +const guide = getGuide("decode-jwt")!; +const relatedGuides = getRelatedGuides(guide.slug); + +const exampleToken = + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyXzEwNDIiLCJyb2xlIjoiYWRtaW4iLCJpYXQiOjE3OTI3MTM2MDAsImV4cCI6MTc5MjcxNzIwMH0.ZGVtby1zaWduYXR1cmUtcGxhY2Vob2xkZXI"; + +export const metadata: Metadata = generateGuideMetadata(guide); + +export default function DecodeJwtGuidePage() { + const faqData = guideFaqJsonLd(guide); + + return ( + <> + \n\n\n\n[javascript](javascript:alert(1))' }, + }); + + await screen.findByText("Preview"); + const preview = document.querySelector(".markdown-preview"); + expect(preview?.querySelector("script")).toBeNull(); + expect(preview?.querySelector("[onerror]")).toBeNull(); + expect(preview?.querySelector('a[href^="javascript:"]')).toBeNull(); + }); +}); diff --git a/app/(tools)/markdown-preview/MarkdownPreviewClient.tsx b/app/(tools)/markdown-preview/MarkdownPreviewClient.tsx new file mode 100644 index 0000000..b8501bd --- /dev/null +++ b/app/(tools)/markdown-preview/MarkdownPreviewClient.tsx @@ -0,0 +1,84 @@ +"use client"; + +import { useMemo, useRef, useState, useSyncExternalStore } from "react"; +import DOMPurify from "dompurify"; +import { CopyButton } from "@/components/shared/CopyButton"; +import { getMarkdownStats, insertMarkdown, renderMarkdown } from "@/lib/markdown-preview"; + +const starter = `# Hello, Markdown!\n\nWrite on the left and see **GitHub-flavored Markdown** rendered here.\n\n- [x] Live preview\n- [ ] Copy sanitized HTML\n\n| Feature | Status |\n| --- | --- |\n| Tables | Ready |\n| Code | \`const answer = 42\` |\n\n~~~ts\nconst greeting = "Hello, world!";\n~~~`; +const subscribeToMountedState = () => () => {}; + +function sanitizePreviewHtml(markdown: string) { + const clean = DOMPurify.sanitize(renderMarkdown(markdown), { + FORBID_TAGS: ["script"], + FORBID_ATTR: ["onerror", "onload", "onclick"], + }); + const template = document.createElement("template"); + template.innerHTML = clean; + template.content.querySelectorAll("script").forEach((element) => element.remove()); + template.content.querySelectorAll("*").forEach((element) => { + for (const attribute of Array.from(element.attributes)) { + const name = attribute.name.toLowerCase(); + if (name.startsWith("on") || ((name === "href" || name === "src") && /^\s*javascript:/i.test(attribute.value))) { + element.removeAttribute(attribute.name); + } + } + }); + return template.innerHTML; +} + +export function MarkdownPreviewClient() { + const [markdown, setMarkdown] = useState(starter); + const [darkPreview, setDarkPreview] = useState(false); + const mounted = useSyncExternalStore(subscribeToMountedState, () => true, () => false); + const textareaRef = useRef(null); + const html = useMemo(() => { + return mounted ? sanitizePreviewHtml(markdown) : ""; + }, [markdown, mounted]); + const { words, chars, lines, readingMinutes } = getMarkdownStats(markdown); + + function insert(before: string, after = before, placeholder = "text") { + const input = textareaRef.current; + if (!input) return; + const start = input.selectionStart; + const end = input.selectionEnd; + const result = insertMarkdown(markdown, start, end, before, after, placeholder); + setMarkdown(result.value); + requestAnimationFrame(() => { input.focus(); input.setSelectionRange(result.selectionStart, result.selectionEnd); }); + } + + function download() { + const url = URL.createObjectURL(new Blob([markdown], { type: "text/markdown" })); + const link = document.createElement("a"); link.href = url; link.download = "document.md"; link.click(); URL.revokeObjectURL(url); + } + + function insertImage() { + insert("![alt text](", ")", "https://example.com/image.png"); + } + + function insertTable() { + const input = textareaRef.current; + const position = input?.selectionStart ?? markdown.length; + const table = "| Header | Header |\n| --- | --- |\n| Cell | Cell |\n"; + setMarkdown(markdown.slice(0, position) + table + markdown.slice(position)); + requestAnimationFrame(() => input?.focus()); + } + + return
+
+ {[['Bold','**','**'],['Italic','*','*'],['Code','`','`'],['Link','[','](https://example.com)'],['H2','## ','']].map(([label, before, after]) => )} + + + + + + + +
+
+
Markdown