From da7e603570dd1014c21d78ed5cb43d84087ec9d7 Mon Sep 17 00:00:00 2001 From: Brian Funk Date: Fri, 2 Oct 2026 00:40:59 -0400 Subject: [PATCH 01/36] feat: v1.1.0 - harden library, add CLI, types, and 8 new styles Library: - Smart tokenizer for code styles (camelCase, _, -, ., / boundaries) - Real slug (diacritics folded, ASCII only); kebab keeps Unicode - Unicode-aware title, sentence, alternate; grapheme-safe reverse/flip - Conventional ASCII leet map, case preserved - Empty string returns '' instead of false - strict option throws TypeError/RangeError - capstringAll() and CATEGORIES exports - New styles: smallcaps, bubble, wide, strike, clap, morse, binary, piglatin Tooling: - CLI (cli.js + bin/capstring.js) with --all, --list, --json, stdin - Hand-written index.d.ts with Style union; drift test - files whitelist (drop .npmignore), sideEffects: false, prepublishOnly - 100% coverage thresholds, Node 24 in CI, CLI smoke test Docs: README regrouped with behavior notes, CHANGELOG 1.1.0, CLAUDE.md, AGENTS.md --- .github/workflows/ci.yml | 11 +- .npmignore | 42 ---- AGENTS.md | 59 ++--- CHANGELOG.md | 37 ++++ CLAUDE.md | 33 +-- README.md | 164 +++++++++----- bin/capstring.js | 16 ++ cli.js | 138 ++++++++++++ eslint.config.js | 7 +- index.d.ts | 57 +++++ index.js | 467 ++++++++++++++++++++++++++++++--------- package-lock.json | 8 +- package.json | 52 ++++- test/cli.test.js | 144 ++++++++++++ test/index.test.js | 327 +++++++++++++++++++-------- test/types.test.js | 32 +++ vitest.config.js | 4 +- 17 files changed, 1244 insertions(+), 354 deletions(-) delete mode 100644 .npmignore create mode 100755 bin/capstring.js create mode 100644 cli.js create mode 100644 index.d.ts create mode 100644 test/cli.test.js create mode 100644 test/types.test.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a278bc3..04497d8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,7 +11,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - node-version: [18, 20, 22] + node-version: [18, 20, 22, 24] steps: - uses: actions/checkout@v4 @@ -36,6 +36,15 @@ jobs: if: matrix.node-version != 18 run: npm run test:coverage + - name: CLI smoke test + run: | + node bin/capstring.js --version + test "$(echo 'hello world' | node bin/capstring.js title)" = "Hello World" + test "$(node bin/capstring.js kebab 'XMLHttpRequest')" = "xml-http-request" + + - name: Verify package contents + run: npm pack --dry-run + - name: Upload coverage uses: codecov/codecov-action@v4 if: matrix.node-version == 20 diff --git a/.npmignore b/.npmignore deleted file mode 100644 index d3e0ac2..0000000 --- a/.npmignore +++ /dev/null @@ -1,42 +0,0 @@ -# Tests -test -tests -testing - -# Services -.codeclimate.yml -.travis.yml -codecov.yml -.gitignore - -# Logs -logs -*.log -npm-debug.log* - -# Runtime data -pids -*.pid -*.seed - -# Coverage directory used by tools like istanbul -coverage - -# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) -.grunt - -# node-waf configuration -.lock-wscript - -# Compiled binary addons (http://nodejs.org/api/addons.html) -build/Release - -# Dependency directories -node_modules -jspm_packages - -# Optional npm cache directory -.npm - -# Optional REPL history -.node_repl_history diff --git a/AGENTS.md b/AGENTS.md index add67be..008728a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,57 +2,66 @@ ## Overview -`capstring` is a lightweight JavaScript library for text capitalization and transformation. Zero dependencies, 24+ styles. +`capstring` is a lightweight JavaScript library for text capitalization and transformation. +Zero dependencies, 37 styles, Unicode and emoji safe, ships a CLI and TypeScript types. ## Quick Start ```javascript -import capstring from 'capstring'; +import capstring, { capstringAll } from 'capstring'; -capstring('hello world', 'title'); // 'Hello World' -capstring('hello world', 'camel'); // 'helloWorld' -capstring('hello world', 'kebab'); // 'hello-world' -capstring('hello world', 'constant'); // 'HELLO_WORLD' +capstring('hello world', 'title'); // 'Hello World' +capstring('XMLHttpRequest', 'kebab'); // 'xml-http-request' +capstring('Crème Brûlée', 'slug'); // 'creme-brulee' +capstringAll('hi').upper; // 'HI' ``` -## API +```bash +npx capstring kebab "Hello World" # hello-world +echo hi | npx capstring --all # every style +``` -### Main Function +## API ```javascript -capstring(str, style) +capstring(str, style = 'same', { strict = false } = {}) ``` - `str` - String to transform - `style` - Style name (default: 'same') -- Returns: Transformed string, or `false` if input invalid - -### Helper Functions +- `strict` - Throw `TypeError` / `RangeError` instead of returning `false` / the input +- Returns: Transformed string; `''` for empty input; `false` if `str` is not a string ```javascript -import { getStyles, isValidStyle, STYLES } from 'capstring'; +import { capstringAll, getStyles, isValidStyle, STYLES, CATEGORIES } from 'capstring'; -getStyles(); // ['same', 'none', 'proper', ...] -isValidStyle('kebab'); // true -STYLES; // Frozen array of all style names +capstringAll('hi'); // { same: 'hi', none: '', ... } in STYLES order +getStyles(); // fresh copy of STYLES +isValidStyle('kebab'); // true +STYLES; // frozen array of all 37 style names +CATEGORIES; // frozen { case, code, fun, encoding, art } ``` -## All 24 Styles +## All 37 Styles | Category | Styles | |----------|--------| -| Case | same, none, proper, title, sentence, upper, lower, swap | -| Code | camel, pascal, snake, kebab, slug, constant, python, dot, path | -| Fun | leet, reverse, sponge, mock, alternate, crazy, random | +| case | same, none, proper, title, sentence, upper, lower, swap | +| code | camel, pascal, snake, kebab, slug, constant, python, dot, path, train, hashtag, acronym | +| fun | reverse, sponge, mock, alternate, crazy, random, clap, piglatin | +| encoding | leet, rot13, morse, binary | +| art | flip, smallcaps, bubble, wide, strike | + +## Files + +- `index.js` - the whole library. Must stay browser-safe: no `node:` imports, no `process`. +- `cli.js` - CLI logic as `main(argv, io)`; `bin/capstring.js` is the executable shim. +- `index.d.ts` - hand-written types. `test/types.test.js` fails if the `Style` union drifts from `STYLES`. ## Testing ```bash npm test # Run tests npm run lint # Run linter -npm run test:coverage # Coverage report +npm run test:coverage # Coverage report (100% thresholds enforced) ``` - -## Related - -- **cAPIta** - REST API for capstring (https://github.com/brianfunk/cAPIta) diff --git a/CHANGELOG.md b/CHANGELOG.md index e48c5aa..58704a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,43 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.1.0] - 2026-10-02 + +### Added + +- **8 new styles** (37 total): `smallcaps`, `bubble`, `wide`, `strike`, `clap`, `morse`, `binary`, `piglatin` +- **CLI**: `npx capstring '); + expect(await text('/api/styles.yaml')).toContain('styles:\n - "same"'); + expect(await text('/api/styles.csv')).toMatch(/^count,styles,categories\n"37","same\|none\|/); + expect(await text('/api/styles.html')).toContain('count37'); }); it('rejects extra segments', async () => { @@ -74,34 +86,110 @@ describe('GET /api/styles', () => { }); }); -describe('GET /api/:style/:text', () => { - it('transforms text', async () => { - const { res, body } = await json('/api/title/hello%20world'); - expect(res.status).toBe(200); - expect(body).toEqual({ input: 'hello world', style: 'title', output: 'Hello World' }); +describe('output formats', () => { + it('json is the default with no extension and no header', async () => { + const res = await call('/api/title/hello%20world'); + expect(res.headers.get('Content-Type')).toBe('application/json; charset=utf-8'); + expect(await res.json()).toEqual({ input: 'hello world', style: 'title', output: 'Hello World' }); + }); + + it('selects by extension', async () => { + expect(await text('/api/title/hello%20world.txt')).toBe('Hello World'); + expect((await call('/api/title/hello%20world.txt')).headers.get('Content-Type')).toBe('text/plain; charset=utf-8'); + expect(await text('/api/title/hello%20world.html')).toContain('

Hello World

'); + expect((await call('/api/title/hello.html')).headers.get('Content-Type')).toBe('text/html; charset=utf-8'); + expect(await text('/api/upper/hello.xml')).toBe('\n\n hello\n \n HELLO\n'); + expect((await call('/api/upper/hello.xml')).headers.get('Content-Type')).toBe('application/xml; charset=utf-8'); + expect(await text('/api/upper/hello.yaml')).toBe('input: "hello"\nstyle: "upper"\noutput: "HELLO"'); + expect(await text('/api/upper/hello.yml')).toBe('input: "hello"\nstyle: "upper"\noutput: "HELLO"'); + expect((await call('/api/upper/hello.yaml')).headers.get('Content-Type')).toBe('text/yaml; charset=utf-8'); + expect(await text('/api/upper/hello.csv')).toBe('input,style,output\n"hello","upper","HELLO"'); + expect((await call('/api/upper/hello.csv')).headers.get('Content-Type')).toBe('text/csv; charset=utf-8'); + expect(await text('/api/upper/hello.JSON')).toBe('{"input":"hello","style":"upper","output":"HELLO"}'); + }); + + it('selects by ?format=', async () => { + expect(await text('/api/upper/hello?format=txt')).toBe('HELLO'); + expect(await text('/api/upper/hello?format=yml')).toContain('output: "HELLO"'); + }); + + it('selects by Accept header, first recognised type wins', async () => { + const accept = (value) => call('/api/upper/hello', { headers: { Accept: value } }); + expect(await (await accept('text/plain')).text()).toBe('HELLO'); + expect((await accept('text/html, application/json')).headers.get('Content-Type')).toBe('text/html; charset=utf-8'); + expect((await accept('application/xml;q=0.9')).headers.get('Content-Type')).toBe('application/xml; charset=utf-8'); + expect((await accept('text/csv')).headers.get('Content-Type')).toBe('text/csv; charset=utf-8'); + expect((await accept('application/yaml')).headers.get('Content-Type')).toBe('text/yaml; charset=utf-8'); + expect((await accept('*/*')).headers.get('Content-Type')).toBe('application/json; charset=utf-8'); + expect((await accept('image/png')).headers.get('Content-Type')).toBe('application/json; charset=utf-8'); + }); + + it('extension beats ?format= beats Accept', async () => { + const res = await call('/api/upper/hello.txt?format=xml', { headers: { Accept: 'text/html' } }); + expect(res.headers.get('Content-Type')).toBe('text/plain; charset=utf-8'); + const res2 = await call('/api/upper/hello?format=xml', { headers: { Accept: 'text/html' } }); + expect(res2.headers.get('Content-Type')).toBe('application/xml; charset=utf-8'); }); - it('returns "" for the none style', async () => { - const { res, body } = await json('/api/none/hello'); - expect(res.status).toBe(200); - expect(body.output).toBe(''); + it('jsonp wraps the JSON in a guarded callback', async () => { + const res = await call('/api/upper/hello.jsonp?callback=cb'); + expect(res.headers.get('Content-Type')).toBe('application/javascript; charset=utf-8'); + expect(await res.text()).toBe('/**/ typeof cb === \'function\' && cb({"input":"hello","style":"upper","output":"HELLO"});'); + expect(await text('/api/upper/hello.jsonp')).toContain('callback('); + expect(await text('/api/upper/hello', { headers: { Accept: 'application/javascript' } })).toContain('callback('); }); - it('supports format=txt', async () => { - const res = await call('/api/title/hello%20world?format=txt'); - expect(res.headers.get('Content-Type')).toBe('text/plain; charset=utf-8'); - expect(await res.text()).toBe('Hello World'); + it('rejects unsafe jsonp callbacks and never wraps errors', async () => { + const { res, body } = await json('/api/upper/hello.jsonp?callback=alert(1)'); + expect(res.status).toBe(400); + expect(body.error.code).toBe('invalid_callback'); + const err = await call('/api/nope/hello.jsonp'); + expect(err.headers.get('Content-Type')).toBe('application/json; charset=utf-8'); }); - it('supports pretty', async () => { - const res = await call('/api/upper/hi?pretty=1'); - expect(await res.text()).toContain('\n "input"'); + it('rejects unknown formats but treats unknown extensions as text', async () => { + expect((await json('/api/title/hi?format=pdf')).body.error.code).toBe('invalid_format'); + expect((await json('/api/upper/file.pdf')).body).toEqual({ input: 'file.pdf', style: 'upper', output: 'FILE.PDF' }); + expect((await json('/api/upper/.txt')).body.output).toBe('.TXT'); // a bare extension is text, not a format }); - it('rejects unknown format', async () => { - const { res, body } = await json('/api/title/hi?format=xml'); - expect(res.status).toBe(400); - expect(body.error.code).toBe('invalid_format'); + it('?text= keeps a trailing .txt as text', async () => { + expect((await json('/api/upper/x?text=notes.txt')).body.output).toBe('NOTES.TXT'); + }); + + it('escapes HTML and XML in those formats only', async () => { + const xss = '%3Cscript%3Ealert(1)%3C%2Fscript%3E'; + expect(await text(`/api/same/${xss}.html`)).toContain('<script>alert(1)</script>'); + expect(await text(`/api/same/${xss}.html`)).not.toContain(''); + expect((await json(`/api/same/${xss}`)).body.output).toBe(''); + expect(await text('/api/same/a%22b%26c%27d.html')).toContain('a"b&c'd'); + }); + + it('csv and yaml escape their own delimiters', async () => { + expect(await text('/api/same/say%20%22hi%22%2C%20ok.csv')).toBe('input,style,output\n"say ""hi"", ok","same","say ""hi"", ok"'); + expect(await text('/api/same/line%0Abreak.yaml')).toContain('output: "line\\nbreak"'); + }); + + it('supports pretty JSON', async () => { + expect(await text('/api/upper/hi?pretty=1')).toContain('\n "input"'); + }); + + it('renders errors in the requested format', async () => { + expect(await text('/api/nope/hi.txt')).toBe('error: unknown_style - Unknown style "nope". See /api/styles.'); + expect(await text('/api/nope/hi.xml')).toContain('unknown_style'); + expect(await text('/api/nope/hi.html')).toContain('unknown_style'); + expect((await call('/api/nope/hi.csv')).status).toBe(404); + }); +}); + +describe('GET /api/:style/:text', () => { + it('returns "" for the none style', async () => { + const { res, body } = await json('/api/none/hello'); + expect(res.status).toBe(200); + expect(body.output).toBe(''); + expect(await text('/api/none/hello.txt')).toBe(''); }); it('joins extra path segments with slashes and lets ?text override', async () => { @@ -110,8 +198,7 @@ describe('GET /api/:style/:text', () => { }); it('decodes unicode', async () => { - const { body } = await json('/api/slug/Cr%C3%A8me%20Br%C3%BBl%C3%A9e'); - expect(body.output).toBe('creme-brulee'); + expect((await json('/api/slug/Cr%C3%A8me%20Br%C3%BBl%C3%A9e')).body.output).toBe('creme-brulee'); }); it('marks random as uncacheable', async () => { @@ -133,6 +220,9 @@ describe('GET /api/:style/:text', () => { expect(res.status).toBe(400); expect(body.error.code).toBe('missing_text'); } + const asText = await call('/api/title.txt'); + expect(asText.status).toBe(400); + expect(await asText.text()).toContain('missing_text'); }); it('413s long text', async () => { @@ -167,19 +257,20 @@ describe('GET /api/all/:text', () => { expect(res.headers.get('Cache-Control')).toBe('no-store'); }); - it('format=txt escapes line breaks inside results', async () => { - const text = await (await call('/api/all/a%0Ab?format=txt')).text(); - const lines = text.split('\n'); + it('txt gives one tab-separated line per style with line breaks escaped', async () => { + const lines = (await text('/api/all/a%0Ab.txt')).split('\n'); expect(lines).toHaveLength(STYLES.length); expect(lines[0]).toBe('same\ta\\nb'); - expect((await (await call('/api/all/a%E2%80%A8b?format=txt')).text()).split('\n')[0]).toBe('same\ta\\u2028b'); + expect((await text('/api/all/a%E2%80%A8b.txt')).split('\n')[0]).toBe('same\ta\\u2028b'); }); - it('format=txt gives one tab-separated line per style', async () => { - const text = await (await call('/api/all/hi?format=txt')).text(); - const lines = text.split('\n'); - expect(lines).toHaveLength(STYLES.length); - expect(lines[0]).toBe('same\thi'); + it('csv gives one row per style; xml and yaml nest the results', async () => { + const csv = (await text('/api/all/hi.csv')).split('\n'); + expect(csv[0]).toBe('style,output'); + expect(csv).toHaveLength(STYLES.length + 1); + expect(csv[1]).toBe('"same","hi"'); + expect(await text('/api/all/hi.xml')).toContain('\n hi'); + expect(await text('/api/all/hi.yaml')).toContain('results:\n same: "hi"'); }); it('400s missing text', async () => { @@ -190,9 +281,10 @@ describe('GET /api/all/:text', () => { describe('GET /api/chain/:styles/:text', () => { it('applies styles in order with + or ,', async () => { const { body } = await json('/api/chain/upper+reverse/hello'); - expect(body).toEqual({ input: 'hello', styles: ['upper', 'reverse'], output: 'OLLEH' }); + expect(body).toEqual({ input: 'hello', style: 'upper+reverse', styles: ['upper', 'reverse'], output: 'OLLEH' }); expect((await json('/api/chain/lower,title,kebab/HELLO%20WORLD')).body.output).toBe('hello-world'); - expect(await (await call('/api/chain/upper/hi?format=txt')).text()).toBe('HI'); + expect(await text('/api/chain/upper/hi.txt')).toBe('HI'); + expect(await text('/api/chain/upper+reverse/hello.xml')).toContain('\n \n \n '); }); it('bounds intermediate output so expanding chains cannot exhaust memory', async () => { @@ -232,19 +324,25 @@ describe('POST /api/batch', () => { }); }); - it('honours format=txt with one output line per text (errors become empty lines)', async () => { - const res = await post('/api/batch?format=txt', { style: 'upper', texts: ['a', 7, 'b'] }); + it('accepts "inputs" as an alias for "texts"', async () => { + const res = await post('/api/batch', { style: 'upper', inputs: ['a'] }); + expect((await res.json()).results).toEqual([{ input: 'a', output: 'A' }]); + }); + + it('honours txt and csv', async () => { + const res = await post('/api/batch.txt', { style: 'upper', texts: ['a', 7, 'b'] }); expect(res.headers.get('Content-Type')).toBe('text/plain; charset=utf-8'); expect(await res.text()).toBe('A\n\nB'); - const multi = await post('/api/batch?format=txt', { style: 'same', texts: ['a\nb', 'c'] }); - expect(await multi.text()).toBe('a\\nb\nc'); + expect(await (await post('/api/batch?format=txt', { style: 'same', texts: ['a\nb', 'c'] })).text()).toBe('a\\nb\nc'); + expect(await (await post('/api/batch.csv', { style: 'upper', texts: ['a', 7] })).text()) + .toBe('input,output,error\n"a","A",""\n"7","","not_a_string"'); }); it('reports per-item problems inline', async () => { const res = await post('/api/batch', { style: 'upper', texts: ['ok', 42, 'a'.repeat(2001)] }); const body = await res.json(); expect(body.results[1]).toEqual({ input: 42, output: null, error: 'not_a_string' }); - expect(body.results[2].error).toBe('text_too_long'); + expect(body.results[2]).toEqual({ input: `${'a'.repeat(50)}...`, output: null, error: 'text_too_long' }); }); it('validates body', async () => { @@ -261,11 +359,8 @@ describe('POST /api/batch', () => { const bad = await post('/api/batch', '{not json'); expect(bad.status).toBe(400); expect((await bad.json()).error.code).toBe('invalid_json'); - - const wrongType = await post('/api/batch', '{}', { 'Content-Type': 'text/plain' }); - expect(wrongType.status).toBe(415); - const noType = await post('/api/batch', '{}', {}); - expect(noType.status).toBe(415); + expect((await post('/api/batch', '{}', { 'Content-Type': 'text/plain' })).status).toBe(415); + expect((await post('/api/batch', '{}', {})).status).toBe(415); }); it('405s GET with Allow', async () => { @@ -279,60 +374,44 @@ describe('POST /api/batch', () => { }); }); -describe('GET /api/badge/:style/:text', () => { - it('returns an SVG badge', async () => { - const res = await call('/api/badge/sponge/hello%20world'); +describe('GET /api/count/:text', () => { + it('counts words and characters (code points, not UTF-16 units)', async () => { + const { res, body } = await json('/api/count/hello%20big%20world'); expect(res.status).toBe(200); - expect(res.headers.get('Content-Type')).toBe('image/svg+xml; charset=utf-8'); - expect(res.headers.get('Cache-Control')).toBe('public, max-age=86400'); - expect(res.headers.get('Access-Control-Allow-Origin')).toBe('*'); - const svg = await res.text(); - expect(svg).toMatch(/^sponge<'); - expect(svg).toContain('>HeLlO WoRlD<'); + expect(body).toEqual({ input: 'hello big world', words: 3, characters: 15, charactersNoSpaces: 13 }); + expect((await json('/api/count/%F0%9F%98%80%20a')).body).toEqual({ input: '😀 a', words: 2, characters: 3, charactersNoSpaces: 2 }); + expect((await json('/api/count/%20%20spaced%20%20')).body.words).toBe(1); }); - it('supports ?label= and escapes XML', async () => { - const svg = await (await call('/api/badge/same/a%3Cb%26c?label=x%22y')).text(); - expect(svg).toContain('>x"y<'); - expect(svg).toContain('>a<b&c<'); - expect(svg).not.toContain(' { + expect(await text('/api/count/hello%20world.txt')).toBe('words: 2, chars: 11, chars (no spaces): 10'); + expect(await text('/api/count/hi.html')).toContain('words1'); + expect(await text('/api/count/hi.csv')).toBe('input,words,characters,charactersNoSpaces\n"hi","1","2","2"'); }); - it('handles empty output and random', async () => { - expect((await call('/api/badge/none/hi')).status).toBe(200); - expect((await call('/api/badge/random/hi')).headers.get('Cache-Control')).toBe('no-store'); - }); - - it('limits label length', async () => { - expect((await call(`/api/badge/upper/hi?label=${'x'.repeat(100)}`)).status).toBe(200); - const { res, body } = await json(`/api/badge/upper/hi?label=${'x'.repeat(101)}`); - expect(res.status).toBe(400); - expect(body.error.code).toBe('label_too_long'); - }); - - it('validates style and text', async () => { - expect((await json('/api/badge/nope/hi')).body.error.code).toBe('unknown_style'); - expect((await json('/api/badge')).body.error.code).toBe('unknown_style'); - expect((await json('/api/badge/upper')).body.error.code).toBe('missing_text'); + it('validates text', async () => { + expect((await json('/api/count')).body.error.code).toBe('missing_text'); + expect((await call(`/api/count/${'a'.repeat(2001)}`)).status).toBe(413); }); }); describe('GET /api/lorem/:count', () => { - it('defaults to 50 words', async () => { + it('defaults to 50 words, capitalized, ending in a period', async () => { const { res, body } = await json('/api/lorem'); expect(res.status).toBe(200); expect(body.count).toBe(50); expect(body.style).toBeNull(); expect(body.output.split(' ')).toHaveLength(50); - expect(body.output.startsWith('lorem ipsum dolor')).toBe(true); + expect(body.output.startsWith('Lorem ipsum dolor')).toBe(true); + expect(body.output.endsWith('.')).toBe(true); expect(res.headers.get('Cache-Control')).toBe('public, max-age=86400'); }); - it('honours count, ?style= and format=txt', async () => { - const { body } = await json('/api/lorem/3?style=title'); - expect(body).toEqual({ count: 3, style: 'title', output: 'Lorem Ipsum Dolor' }); - expect(await (await call('/api/lorem/2?format=txt')).text()).toBe('lorem ipsum'); + it('honours count, ?style=, and formats', async () => { + expect((await json('/api/lorem/3?style=kebab')).body).toEqual({ count: 3, style: 'kebab', output: 'lorem-ipsum-dolor' }); + expect((await json('/api/lorem/1')).body.output).toBe('Lorem.'); + expect(await text('/api/lorem/2.txt')).toBe('Lorem ipsum.'); + expect(await text('/api/lorem/2.html')).toContain('Lorem ipsum.'); expect((await json('/api/lorem/1000')).body.output.split(' ')).toHaveLength(1000); expect((await call('/api/lorem/2?style=random')).headers.get('Cache-Control')).toBe('no-store'); }); @@ -376,9 +455,14 @@ describe('GET /api/spell/:text', () => { expect(body.corrections).toEqual([]); }); - it('applies ?style= after correcting and supports txt', async () => { + it('applies ?style= after correcting and supports every format', async () => { expect((await json('/api/spell/helo%20speling?style=title')).body.output).toBe('Hello Spelling'); - expect(await (await call('/api/spell/helo?format=txt')).text()).toBe('hello'); + expect(await text('/api/spell/helo.txt')).toBe('hello'); + expect(await text('/api/spell/helo.html')).toContain('hello'); + expect(await text('/api/spell/helo.xml')).toContain('\n \n helo\n hello'); + expect(await text('/api/spell/helo.yaml')).toContain('corrections:\n -\n from: "helo"\n to: "hello"'); + expect(await text('/api/spell/hello.yaml')).toContain('corrections: []'); + expect(await text('/api/spell/helo.csv')).toBe('input,style,output,corrections,limited\n"helo","","hello","{""from"":""helo"",""to"":""hello""}","false"'); expect((await call('/api/spell/helo?style=random')).headers.get('Cache-Control')).toBe('no-store'); }); @@ -386,7 +470,7 @@ describe('GET /api/spell/:text', () => { const words = Array(55).fill('xqzjvwpk').concat(['helo']); const { body } = await json(`/api/spell/${encodeURIComponent(words.join(' '))}`); expect(body.limited).toBe(true); - expect(body.corrections).toEqual([]); // 'helo' came after the budget ran out + expect(body.corrections).toEqual([]); expect(body.output.endsWith(' helo')).toBe(true); }); @@ -407,15 +491,59 @@ describe('GET /api/spell/:text', () => { }); }); +describe('GET /api/badge/:style/:text', () => { + it('returns an SVG badge', async () => { + const res = await call('/api/badge/sponge/hello%20world'); + expect(res.status).toBe(200); + expect(res.headers.get('Content-Type')).toBe('image/svg+xml; charset=utf-8'); + expect(res.headers.get('Cache-Control')).toBe('public, max-age=86400'); + expect(res.headers.get('Access-Control-Allow-Origin')).toBe('*'); + const svg = await res.text(); + expect(svg).toMatch(/^sponge<'); + expect(svg).toContain('>HeLlO WoRlD<'); + }); + + it('supports ?label= and escapes XML', async () => { + const svg = await text('/api/badge/same/a%3Cb%26c?label=x%22y'); + expect(svg).toContain('>x"y<'); + expect(svg).toContain('>a<b&c<'); + expect(svg).not.toContain(' { + expect((await call('/api/badge/none/hi')).status).toBe(200); + expect((await call('/api/badge/random/hi')).headers.get('Cache-Control')).toBe('no-store'); + }); + + it('limits label length', async () => { + expect((await call(`/api/badge/upper/hi?label=${'x'.repeat(100)}`)).status).toBe(200); + const { res, body } = await json(`/api/badge/upper/hi?label=${'x'.repeat(101)}`); + expect(res.status).toBe(400); + expect(body.error.code).toBe('label_too_long'); + }); + + it('validates style and text', async () => { + expect((await json('/api/badge/nope/hi')).body.error.code).toBe('unknown_style'); + expect((await json('/api/badge')).body.error.code).toBe('unknown_style'); + expect((await json('/api/badge/upper')).body.error.code).toBe('missing_text'); + }); +}); + describe('misc', () => { it('answers OPTIONS preflight with 204 and CORS', async () => { const res = await call('/api/anything', { method: 'OPTIONS' }); expect(res.status).toBe(204); expect(res.headers.get('Access-Control-Allow-Methods')).toBe('GET, POST, OPTIONS'); + expect(res.headers.get('Access-Control-Allow-Headers')).toContain('Accept'); }); it('405s POST /api and POST /api/styles', async () => { expect((await post('/api', {})).status).toBe(405); expect((await post('/api/styles', {})).status).toBe(405); }); + + it('bad encoding in a path with an extension still reports bad_encoding', async () => { + expect((await json('/api/upper/%E0%A4%A.txt')).body.error.code).toBe('bad_encoding'); + }); }); diff --git a/test/browser-safe.test.js b/test/browser-safe.test.js index 845228b..5555557 100644 --- a/test/browser-safe.test.js +++ b/test/browser-safe.test.js @@ -17,11 +17,16 @@ describe('index.js stays browser-safe', () => { const app = read('web/app.js'); expect(app).toMatch(/from\s+'\.\/capstring\.js'/); expect(read('web/index.html')).toContain(' + 404 · capstring - + + + -
+
+

ԀUNOℲ ┴ON

-

That page does not exist in any of our 37 styles.

- -
+

That page does not exist in any style.

+

Looking for the API? Try /api/styles or /api/flip/not%20found.

+
+ diff --git a/web/app.js b/web/app.js index e92d27e..1580368 100644 --- a/web/app.js +++ b/web/app.js @@ -9,10 +9,13 @@ const MAX_CHAIN_OUTPUT = 20000; // binary/morse expand ~9x per step; keep shared const input = document.getElementById('t'); const results = document.getElementById('results'); const curl = document.getElementById('curl'); +const curlLink = document.getElementById('curl-link'); +const formatSelect = document.getElementById('format'); const toast = document.getElementById('toast'); const chainBar = document.getElementById('chain'); const chainList = document.getElementById('chain-list'); const chainOut = document.getElementById('chain-out'); +const spellButton = document.getElementById('spell'); const outputs = new Map(); let selectedStyle = null; let chain = []; @@ -23,7 +26,7 @@ const buildRows = () => { for (const [category, styles] of Object.entries(CATEGORIES)) { const section = document.createElement('section'); const h2 = document.createElement('h2'); - h2.textContent = LABELS[category] ?? category; + h2.textContent = `${LABELS[category] ?? category} (${styles.length})`; const ul = document.createElement('ul'); for (const style of styles) { const li = document.createElement('li'); @@ -52,12 +55,15 @@ const buildRows = () => { section.append(h2, ul); results.append(section); } - document.getElementById('count').textContent = String(STYLES.length); + document.getElementById('count').textContent = `in ${STYLES.length} ways`; // the only place the count appears, and it is computed }; /** Current text, falling back to the default so the grid is never empty */ const currentText = () => input.value || DEFAULT_TEXT; +/** encodeURIComponent plus the shell metacharacters it leaves alone (' ( ) * !), so the curl line pastes cleanly */ +const shellSafeEncode = (text) => encodeURIComponent(text).replace(/[!'()*]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`); + /** Output of the current chain applied to the current text, stopping once it grows past the bound */ const chainResult = () => { let acc = currentText(); @@ -68,15 +74,14 @@ const chainResult = () => { return acc; }; -/** encodeURIComponent plus the shell metacharacters it leaves alone (' ( ) * !), so the curl line pastes cleanly */ -const shellSafeEncode = (text) => encodeURIComponent(text).replace(/[!'()*]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`); - -/** The curl command for the chain, the selected style, or all styles */ -const curlFor = (text) => { +/** The API URL for the chain, the selected style, or all styles, with the chosen format extension */ +const apiUrl = (text) => { + const format = formatSelect.value; const encoded = shellSafeEncode(text); - if (chain.length) return `curl ${API}/chain/${chain.join('+')}/${encoded}`; - if (selectedStyle) return `curl ${API}/${selectedStyle}/${encoded}`; - return `curl ${API}/all/${encoded}`; + const path = chain.length ? `chain/${chain.join('+')}/${encoded}` : selectedStyle ? `${selectedStyle}/${encoded}` : `all/${encoded}`; + const ext = format === 'json' ? '' : `.${format}`; + const query = format === 'jsonp' ? '?callback=cb' : ''; + return `${API}/${path}${ext}${query}`; }; /** Render the chain bar */ @@ -94,13 +99,19 @@ const renderChain = () => { chainOut.textContent = chain.length ? chainResult() : ''; }; +/** Refresh the curl line and its clickable link */ +const renderCurl = () => { + const url = apiUrl(currentText()); + curl.textContent = `curl ${url}`; + curlLink.href = url; +}; + /** Recompute every output, the chain, and the curl line */ const render = () => { - const text = currentText(); - const all = capstringAll(text); + const all = capstringAll(currentText()); for (const [style, el] of outputs) el.textContent = all[style]; renderChain(); - curl.textContent = curlFor(text); + renderCurl(); }; /** Show a transient toast message */ @@ -108,7 +119,7 @@ const showToast = (message) => { toast.textContent = message; toast.classList.add('show'); clearTimeout(toastTimer); - toastTimer = setTimeout(() => toast.classList.remove('show'), 1800); + toastTimer = setTimeout(() => toast.classList.remove('show'), 2200); }; /** Copy text to the clipboard, reporting success or failure in the toast */ @@ -123,11 +134,13 @@ const copy = async (text, label) => { } }; -/** Keep the URL shareable: ?t=&chain=a+b */ +/** Keep the URL shareable: ?t=&style= + + +
+
Loading developer docs…
+
+ + + + + + + + + + + diff --git a/web/dev/library.md b/web/dev/library.md new file mode 100644 index 0000000..05745c1 --- /dev/null +++ b/web/dev/library.md @@ -0,0 +1,80 @@ +# Library reference + +Everything exported by `capstring`. + +## `capstring(str, style?, options?)` + +Default export and named export. + +| Parameter | Type | Default | Description | +|---|---|---|---| +| `str` | `string` | | Text to transform | +| `style` | `Style` | `'same'` | A style name | +| `options.strict` | `boolean` | `false` | Throw instead of returning `false` / the input | + +**Returns** `string`, or `false` when `str` is not a string and `strict` is off. + +```js +capstring('hello world', 'title'); // 'Hello World' +capstring('hello world', 'title', { strict: true }); +``` + +Throws in strict mode: + +- `TypeError` when `str` is not a string +- `RangeError` when `style` is not a known style + +## `capstringAll(str, options?)` + +Runs every style and returns an object keyed by style name in `STYLES` order. Same `options` and same non-string behavior as `capstring`. + +```js +capstringAll('hi'); +// { same: 'hi', none: '', proper: 'hi', title: 'Hi', ..., piglatin: 'ihay' } +``` + +## `STYLES` + +Frozen `readonly string[]` of all style names. Order is stable across releases; new styles are appended. + +## `CATEGORIES` + +Frozen object grouping every style exactly once: + +```js +{ + case: ['same', 'none', 'proper', 'title', 'sentence', 'upper', 'lower', 'swap'], + code: ['camel', 'pascal', 'snake', 'kebab', 'slug', 'constant', 'python', 'dot', 'path', 'train', 'hashtag', 'acronym'], + fun: ['reverse', 'sponge', 'mock', 'alternate', 'crazy', 'random', 'clap', 'piglatin'], + encoding: ['leet', 'rot13', 'morse', 'binary'], + art: ['flip', 'smallcaps', 'bubble', 'wide', 'strike'] +} +``` + +Use it to build grouped UIs, like the [playground](/) does. + +## `getStyles()` + +Returns a fresh, mutable copy of `STYLES`. + +## `isValidStyle(value)` + +`true` when `value` is a style name. In TypeScript this is a type guard: inside the `if`, `value` narrows to `Style`. + +```ts +const input: string = getUserInput(); +if (isValidStyle(input)) { + capstring('hi', input); // input is Style here +} +``` + +## Package layout + +| File | Purpose | +|---|---| +| `index.js` | The whole library, browser-safe (no Node imports) | +| `index.d.ts` | Hand-written TypeScript declarations | +| `cli.js` | CLI logic as a testable `main(argv, io)` | +| `bin/capstring.js` | The `npx capstring` executable | + +That is the entire published package: 7 files, zero dependencies, `sideEffects: false` so bundlers can tree-shake it. diff --git a/web/dev/recipes.md b/web/dev/recipes.md new file mode 100644 index 0000000..82a1e49 --- /dev/null +++ b/web/dev/recipes.md @@ -0,0 +1,100 @@ +# Recipes + +## Slugs for URLs and filenames + +```js +import capstring from 'capstring'; + +const slug = capstring('Crème Brûlée: The Sequel!', 'slug'); // 'creme-brulee-the-sequel' +const file = `${slug}.md`; +``` + +`slug` is ASCII only. If you need to keep Unicode letters in a URL path, use `kebab` instead (`crème-brûlée-the-sequel`). + +## Convert identifiers between conventions + +```js +const toSnake = (name) => capstring(name, 'snake'); +const toCamel = (name) => capstring(name, 'camel'); + +toSnake('XMLHttpRequest'); // 'xml_http_request' +toCamel('user_first_name'); // 'userFirstName' +toCamel('user-first-name'); // 'userFirstName' + +// rename object keys +const snakeKeys = (obj) => Object.fromEntries(Object.entries(obj).map(([k, v]) => [toSnake(k), v])); +snakeKeys({ firstName: 'Ada', lastName: 'Lovelace' }); // { first_name: 'Ada', last_name: 'Lovelace' } +``` + +## Constants and env var names + +```js +capstring('max retry count', 'constant'); // 'MAX_RETRY_COUNT' +``` + +## Build a style picker UI + +```js +import { CATEGORIES, capstringAll } from 'capstring'; + +const all = capstringAll(userInput); +for (const [category, styles] of Object.entries(CATEGORIES)) { + renderGroup(category, styles.map((s) => ({ style: s, output: all[s] }))); +} +``` + +## Validate a style from user input + +```js +import { isValidStyle, STYLES } from 'capstring'; + +const style = req.query.style; +if (!isValidStyle(style)) { + return res.status(400).json({ error: `style must be one of ${STYLES.join(', ')}` }); +} +``` + +Or let the library throw for you: + +```js +capstring(text, style, { strict: true }); // RangeError on an unknown style +``` + +## Chain styles + +```js +const pipeline = ['lower', 'title', 'kebab']; +pipeline.reduce((acc, s) => capstring(acc, s), 'HELLO WORLD'); // 'hello-world' +``` + +Watch out for expanding styles like `binary` and `morse` in long chains; each step multiplies the length. + +## Acronyms and hashtags from titles + +```js +capstring('Portable Network Graphics', 'acronym'); // 'PNG' +capstring('throwback thursday', 'hashtag'); // '#ThrowbackThursday' +``` + +## Fun output in a chat bot + +```js +const reply = (text) => capstring(text, pick(['sponge', 'clap', 'flip', 'bubble', 'piglatin'])); +``` + +## Deterministic "random" + +`crazy` looks random but is seeded by the text itself, so the same input always gives the same output. Use it when you need stable results in tests or snapshots; use `random` when you don't. + +## Sentence case for user-generated titles + +```js +capstring('hELLO. how ARE you? "fine."', 'sentence'); // 'Hello. How are you? "Fine."' +``` + +## Node script over a file + +```bash +# one style per line for every line in a file +while read -r line; do npx capstring title "$line"; done < titles.txt +``` diff --git a/web/dev/styles.md b/web/dev/styles.md new file mode 100644 index 0000000..96a71f3 --- /dev/null +++ b/web/dev/styles.md @@ -0,0 +1,79 @@ +# All styles + +Try any of them live in the [playground](/). Every example below is `capstring(input, style)`. + +## Case + +| Style | Input | Output | +|---|---|---| +| `same` | Hello World | Hello World | +| `none` | Hello World | *(empty string)* | +| `proper` | Hello World | Hello World *(alias of same)* | +| `title` | élan vital | Élan Vital | +| `sentence` | hello. world! | Hello. World! | +| `upper` | hello world | HELLO WORLD | +| `lower` | HELLO WORLD | hello world | +| `swap` | Hello World | hELLO wORLD | + +## Code + +| Style | Input | Output | +|---|---|---| +| `camel` | hello world | helloWorld | +| `pascal` | hello world | HelloWorld | +| `snake` | hello world | hello_world | +| `kebab` | Crème Brûlée | crème-brûlée | +| `slug` | Crème Brûlée & Co. | creme-brulee-co | +| `constant` | hello world | HELLO_WORLD | +| `python` | hello world | HELLO_WORLD *(alias of constant)* | +| `dot` | hello world | hello.world | +| `path` | hello world | hello/world | +| `train` | hello world | Hello-World | +| `hashtag` | hello world | #HelloWorld | +| `acronym` | as soon as possible | ASAP | + +All code styles share one tokenizer. Words split on whitespace, `_`, `-`, `.`, `/`, punctuation, emoji, and camelCase boundaries: + +```js +capstring('XMLHttpRequest', 'kebab'); // 'xml-http-request' +capstring('getHTTPResponse2XX', 'snake'); // 'get_http_response2_xx' +capstring('__private_var__', 'camel'); // 'privateVar' +capstring("don't stop", 'snake'); // 'dont_stop' (apostrophes dropped) +capstring('utf8 string', 'kebab'); // 'utf8-string' (letters stay with digits) +``` + +`kebab` keeps Unicode letters; `slug` folds them to ASCII and drops anything else. + +## Fun + +| Style | Input | Output | +|---|---|---| +| `reverse` | hello world | dlrow olleh | +| `sponge` | hello world | HeLlO WoRlD | +| `mock` | hello world | hElLo wOrLd | +| `alternate` | hello world | hElLo WoRlD *(letters only)* | +| `crazy` | hello world | *(deterministic pseudo-random case)* | +| `random` | hello world | *(random case, different every call)* | +| `clap` | hello world | hello 👏 world | +| `piglatin` | hello world | ellohay orldway | + +## Encodings + +| Style | Input | Output | +|---|---|---| +| `leet` | hello world | h3110 w0r1d | +| `rot13` | hello | uryyb | +| `morse` | SOS 1 | ... --- ... / .---- | +| `binary` | hi | 01101000 01101001 | + +## Unicode art + +| Style | Input | Output | +|---|---|---| +| `flip` | hello | ollǝɥ | +| `smallcaps` | hello | ʜᴇʟʟᴏ | +| `bubble` | hello | ⓗⓔⓛⓛⓞ | +| `wide` | hello | hello | +| `strike` | hello | h̶e̶l̶l̶o̶ | + +See [behavior notes](behavior.md) for the exact rules behind `title`, `sentence`, `slug`, and the Unicode handling. diff --git a/web/dev/typescript.md b/web/dev/typescript.md new file mode 100644 index 0000000..c423230 --- /dev/null +++ b/web/dev/typescript.md @@ -0,0 +1,76 @@ +# TypeScript + +Types ship in the package (`index.d.ts`). No `@types` install needed. + +## The `Style` union + +Every style name is a string literal type, so editors autocomplete them and typos fail to compile: + +```ts +import capstring, { type Style, type Category } from 'capstring'; + +const style: Style = 'kebab'; // autocompletes every style +const bad: Style = 'kebap'; // error: not assignable to Style +const group: Category = 'encoding'; // 'case' | 'code' | 'fun' | 'encoding' | 'art' +``` + +## Overloads + +`capstring` and `capstringAll` are overloaded so the return type matches what you pass in: + +```ts +capstring('hi', 'upper'); // string +capstring(value as unknown, 'upper'); // string | false +capstringAll('hi'); // Record +capstringAll(value as unknown); // Record | false +``` + +The style parameter accepts `Style | (string & {})`, which keeps autocomplete while still allowing an arbitrary string (an unknown style returns the input unchanged unless `strict` is on). + +## Narrowing with `isValidStyle` + +```ts +import { isValidStyle, capstring } from 'capstring'; + +function transform(text: string, style: string) { + if (!isValidStyle(style)) throw new Error(`unknown style ${style}`); + return capstring(text, style); // style is Style here +} +``` + +## Options + +```ts +interface CapstringOptions { + strict?: boolean; // throw TypeError / RangeError instead of returning false / input +} +``` + +## Declarations, in full + +```ts +export type Style = 'same' | 'none' | 'proper' | 'title' | 'sentence' | 'upper' | 'lower' | 'swap' + | 'camel' | 'pascal' | 'snake' | 'kebab' | 'slug' | 'constant' | 'python' | 'dot' | 'path' | 'train' + | 'leet' | 'reverse' | 'sponge' | 'mock' | 'alternate' | 'crazy' | 'random' + | 'hashtag' | 'acronym' | 'rot13' | 'flip' + | 'smallcaps' | 'bubble' | 'wide' | 'strike' | 'clap' | 'morse' | 'binary' | 'piglatin'; + +export type Category = 'case' | 'code' | 'fun' | 'encoding' | 'art'; + +export interface CapstringOptions { strict?: boolean } + +export const STYLES: readonly Style[]; +export const CATEGORIES: Readonly>; + +export function capstring(str: string, style?: Style | (string & {}), options?: CapstringOptions): string; +export function capstring(str: unknown, style?: Style | (string & {}), options?: CapstringOptions): string | false; + +export function capstringAll(str: string, options?: CapstringOptions): Record; +export function capstringAll(str: unknown, options?: CapstringOptions): Record | false; + +export function getStyles(): Style[]; +export function isValidStyle(style: unknown): style is Style; +export default capstring; +``` + +A test in the repo fails if this union ever drifts from the runtime `STYLES` array. diff --git a/web/docs/index.html b/web/docs/index.html new file mode 100644 index 0000000..cd4c792 --- /dev/null +++ b/web/docs/index.html @@ -0,0 +1,49 @@ + + + + + + + + capstring API docs + + + + + + + + +
+
+
+ + + + + diff --git a/web/icon.svg b/web/icon.svg new file mode 100644 index 0000000..aa6e7bb --- /dev/null +++ b/web/icon.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/web/index.html b/web/index.html index 4ef5777..8414655 100644 --- a/web/index.html +++ b/web/index.html @@ -4,37 +4,49 @@ + capstring - + - - + + + - - + + - + + + -
-

cApStRiNg

-

CaPiTaLiZe StRiNgS in 37 ways. Type, click a row to copy, press + to chain.

- -
+
+
+

CaPiTaLiZe StRiNgS . Type something, click a row to copy, press + to chain. The page URL carries your phrase, so share it.

+
- - +
+ + + +

- + + +

@@ -52,37 +64,43 @@

cApStRiNg

HTTP API

-

Free, no key, CORS enabled. Text up to 2,000 characters. Responses are JSON unless you add ?format=txt.

+

Free, no key, CORS enabled, cacheable. Base URL https://capstring.netlify.app/api. Full reference with try-it-out at /docs (OpenAPI 3.1, openapi.json).

- - - - - - - + + + + + + + +
EndpointReturns
GET /api/stylesstyle names and categories
GET /api/:style/:text{ input, style, output }
GET /api/all/:textevery style
GET /api/chain/:styles/:textstyles applied in order, e.g. upper+reverse
POST /api/batchbody { "style": "kebab", "texts": ["a", "b"] }
GET /api/badge/:style/:textshields-style SVG badge for your README (?label= to override)
GET /api/lorem/:countlorem ipsum words, 1 to 1000, optional ?style=
GET /api/spell/:textspell-corrected text with a list of corrections, optional ?style= (max 500 chars, 50 corrections)
GET /api/all/:textevery style at once
GET /api/chain/:styles/:textstyles applied in order, e.g. upper+reverse (max 10)
POST /api/batchbody { "style": "kebab", "texts": ["a", "b"] } (max 100)
GET /api/spell/:textspell-corrected text and a list of corrections, optional ?style= (max 500 chars, 50 corrections)
GET /api/count/:textwords, characters, characters without spaces
GET /api/lorem/:countlorem ipsum, 1 to 1000 words, optional ?style=
GET /api/badge/:style/:textshields-style SVG badge for your README, ?label= to override
GET /api/stylesstyle names and categories
+

Output formats

+

JSON by default. Pick another with a file extension (/api/title/hello.txt), a query (?format=txt), or an Accept header. The extension wins when more than one is given. Formats: json, txt, html, xml, yaml, csv, jsonp (?callback=).

curl https://capstring.netlify.app/api/title/hello%20world
-curl "https://capstring.netlify.app/api/all/hello%20world?format=txt"
+curl https://capstring.netlify.app/api/sponge/hello%20world.txt
+curl -H "Accept: text/csv" https://capstring.netlify.app/api/all/hello%20world
+curl https://capstring.netlify.app/api/chain/upper+reverse/hello.yaml
 curl -X POST https://capstring.netlify.app/api/batch \
   -H "Content-Type: application/json" \
   -d '{"style":"slug","texts":["Crème Brûlée","Hello World"]}'
 curl "https://capstring.netlify.app/api/spell/helo%20wrld?style=title"
+curl https://capstring.netlify.app/api/count/hello%20world.txt
+curl https://capstring.netlify.app/api/lorem/10.txt
 curl -o badge.svg https://capstring.netlify.app/api/badge/sponge/hello%20world

capstring badge showing HeLlO WoRlD

-

Errors look like { "error": { "code": "unknown_style", "message": "..." } }. Use ?text= instead of the path when your text contains slashes.

+

Errors come back in the requested format as { "error": { "code": "unknown_style", "message": "..." } } with a matching status. Use ?text= instead of the path when your text contains slashes or ends in an extension. Add ?pretty=1 for indented JSON.

- +
+ diff --git a/web/openapi.json b/web/openapi.json new file mode 100644 index 0000000..93190d1 --- /dev/null +++ b/web/openapi.json @@ -0,0 +1,1304 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "capstring API", + "version": "1.1.0", + "summary": "CaPiTaLiZe StRiNgS over HTTP.", + "description": "Free, no API key, CORS enabled, cacheable.\n\n**Output format** is chosen, in priority order, by a file extension on the last path segment (`/api/title/hello.txt`), the `format` query parameter, the `Accept` header, and finally JSON. Formats: `json`, `txt`, `html`, `xml`, `yaml` (`yml`), `csv`, `jsonp` (`?callback=`). Errors come back in the same format with a matching HTTP status.\n\nText goes in the path. Use `?text=` instead when it contains slashes or ends in a format extension. Add `?pretty=1` for indented JSON.\n\nPowered by the [capstring](https://www.npmjs.com/package/capstring) npm package. Source on [GitHub](https://github.com/brianfunk/capstring).", + "license": { + "name": "MIT", + "identifier": "MIT" + } + }, + "servers": [ + { + "url": "https://capstring.netlify.app/api", + "description": "Production" + }, + { + "url": "http://localhost:8888/api", + "description": "Netlify Dev (npm run dev)" + } + ], + "tags": [ + { + "name": "transform", + "description": "Turn text into one, several, or every style" + }, + { + "name": "text", + "description": "Spell check, counts, and placeholder text" + }, + { + "name": "meta", + "description": "Service and style information" + }, + { + "name": "badge", + "description": "SVG badges for READMEs" + } + ], + "paths": { + "/": { + "get": { + "tags": [ + "meta" + ], + "summary": "Service info", + "operationId": "getInfo", + "parameters": [ + { + "$ref": "#/components/parameters/format" + }, + { + "$ref": "#/components/parameters/pretty" + } + ], + "responses": { + "200": { + "description": "Name, version, formats, and endpoint list", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Info" + }, + "example": { + "name": "capstring", + "version": "1.1.0", + "docs": "https://capstring.netlify.app/#api", + "formats": [ + "json", + "jsonp", + "txt", + "html", + "xml", + "yaml", + "csv" + ], + "endpoints": [ + "GET /api/styles", + "GET /api/:style/:text" + ] + } + }, + "text/plain": { + "schema": { + "type": "string" + } + } + } + } + } + } + }, + "/styles": { + "get": { + "tags": [ + "meta" + ], + "summary": "List every style", + "operationId": "getStyles", + "parameters": [ + { + "$ref": "#/components/parameters/format" + }, + { + "$ref": "#/components/parameters/pretty" + } + ], + "responses": { + "200": { + "description": "Style names in canonical order plus category groupings", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Styles" + }, + "example": { + "count": 37, + "styles": [ + "same", + "none", + "proper", + "title" + ], + "categories": { + "case": [ + "same", + "none", + "proper", + "title", + "sentence", + "upper", + "lower", + "swap" + ] + } + } + }, + "text/plain": { + "schema": { + "type": "string", + "description": "One style name per line" + } + } + } + } + } + } + }, + "/{style}/{text}": { + "get": { + "tags": [ + "transform" + ], + "summary": "Transform text into one style", + "operationId": "transform", + "parameters": [ + { + "$ref": "#/components/parameters/style" + }, + { + "$ref": "#/components/parameters/text" + }, + { + "$ref": "#/components/parameters/format" + }, + { + "$ref": "#/components/parameters/textQuery" + }, + { + "$ref": "#/components/parameters/pretty" + }, + { + "$ref": "#/components/parameters/callback" + } + ], + "responses": { + "200": { + "description": "The transformed text", + "headers": { + "Cache-Control": { + "$ref": "#/components/headers/CacheControl" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Transform" + }, + "example": { + "input": "hello world", + "style": "title", + "output": "Hello World" + } + }, + "text/plain": { + "schema": { + "type": "string" + }, + "example": "Hello World" + }, + "text/html": { + "schema": { + "type": "string" + } + }, + "application/xml": { + "schema": { + "$ref": "#/components/schemas/Transform" + }, + "example": "\n\n hello world\n \n Hello World\n" + }, + "text/yaml": { + "schema": { + "type": "string" + }, + "example": "input: \"hello world\"\nstyle: \"title\"\noutput: \"Hello World\"" + }, + "text/csv": { + "schema": { + "type": "string" + }, + "example": "input,style,output\n\"hello world\",\"title\",\"Hello World\"" + }, + "application/javascript": { + "schema": { + "type": "string" + }, + "example": "/**/ typeof callback === 'function' && callback({\"input\":\"hello world\",\"style\":\"title\",\"output\":\"Hello World\"});" + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "404": { + "$ref": "#/components/responses/UnknownStyle" + }, + "413": { + "$ref": "#/components/responses/TooLong" + } + } + } + }, + "/all/{text}": { + "get": { + "tags": [ + "transform" + ], + "summary": "Transform text into every style", + "operationId": "transformAll", + "parameters": [ + { + "$ref": "#/components/parameters/text" + }, + { + "$ref": "#/components/parameters/format" + }, + { + "$ref": "#/components/parameters/textQuery" + }, + { + "$ref": "#/components/parameters/pretty" + }, + { + "$ref": "#/components/parameters/callback" + } + ], + "responses": { + "200": { + "description": "A map of style name to output, in canonical order. Never cached because `random` is included.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/All" + }, + "example": { + "input": "hello world", + "count": 37, + "results": { + "same": "hello world", + "title": "Hello World", + "kebab": "hello-world" + } + } + }, + "text/plain": { + "schema": { + "type": "string", + "description": "`styleoutput` per line; line breaks inside outputs are escaped" + }, + "example": "same\thello world\nnone\t\ntitle\tHello World" + }, + "text/csv": { + "schema": { + "type": "string", + "description": "One row per style" + }, + "example": "style,output\n\"same\",\"hello world\"\n\"title\",\"Hello World\"" + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "413": { + "$ref": "#/components/responses/TooLong" + } + } + } + }, + "/chain/{styles}/{text}": { + "get": { + "tags": [ + "transform" + ], + "summary": "Apply several styles in order", + "operationId": "chain", + "parameters": [ + { + "name": "styles", + "in": "path", + "required": true, + "description": "Up to 10 style names joined with `+` or `,`, applied left to right.", + "schema": { + "type": "string", + "example": "upper+reverse" + } + }, + { + "$ref": "#/components/parameters/text" + }, + { + "$ref": "#/components/parameters/format" + }, + { + "$ref": "#/components/parameters/textQuery" + }, + { + "$ref": "#/components/parameters/pretty" + }, + { + "$ref": "#/components/parameters/callback" + } + ], + "responses": { + "200": { + "description": "The final output after every step", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Chain" + }, + "example": { + "input": "hello", + "style": "upper+reverse", + "styles": [ + "upper", + "reverse" + ], + "output": "OLLEH" + } + }, + "text/plain": { + "schema": { + "type": "string" + }, + "example": "OLLEH" + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "404": { + "$ref": "#/components/responses/UnknownStyle" + }, + "413": { + "description": "Text over 2,000 characters, or an intermediate result over 20,000 characters (`output_too_long`)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/batch": { + "post": { + "tags": [ + "transform" + ], + "summary": "Transform up to 100 texts with one style", + "operationId": "batch", + "parameters": [ + { + "$ref": "#/components/parameters/format" + }, + { + "$ref": "#/components/parameters/pretty" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BatchRequest" + }, + "example": { + "style": "kebab", + "texts": [ + "Hello World", + "XMLHttpRequest" + ] + } + } + } + }, + "responses": { + "200": { + "description": "One result per input. Items that are not strings or exceed 2,000 characters get an inline `error` instead of failing the whole request.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BatchResponse" + }, + "example": { + "style": "kebab", + "count": 2, + "results": [ + { + "input": "Hello World", + "output": "hello-world" + }, + { + "input": "XMLHttpRequest", + "output": "xml-http-request" + } + ] + } + }, + "text/plain": { + "schema": { + "type": "string", + "description": "One output per line" + } + }, + "text/csv": { + "schema": { + "type": "string", + "description": "`input,output,error` rows" + } + } + } + }, + "400": { + "description": "`invalid_json`, `missing_texts`, or `batch_too_large`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "$ref": "#/components/responses/UnknownStyle" + }, + "415": { + "description": "Body was not sent as `application/json`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/spell/{text}": { + "get": { + "tags": [ + "text" + ], + "summary": "Spell-correct English text", + "description": "Hunspell (en_US) corrections with capitalization, punctuation, and contractions preserved. At most 500 characters and 50 corrections per request; `limited` is `true` when the correction budget ran out.", + "operationId": "spell", + "parameters": [ + { + "name": "text", + "in": "path", + "required": true, + "description": "Text to correct, URL-encoded, up to 500 characters", + "schema": { + "type": "string", + "maxLength": 500, + "example": "helo wrld" + } + }, + { + "$ref": "#/components/parameters/styleQuery" + }, + { + "$ref": "#/components/parameters/format" + }, + { + "$ref": "#/components/parameters/textQuery" + }, + { + "$ref": "#/components/parameters/pretty" + }, + { + "$ref": "#/components/parameters/callback" + } + ], + "responses": { + "200": { + "description": "Corrected text and what changed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Spell" + }, + "example": { + "input": "helo speling", + "style": null, + "output": "hello spelling", + "corrections": [ + { + "from": "helo", + "to": "hello" + }, + { + "from": "speling", + "to": "spelling" + } + ], + "limited": false + } + }, + "text/plain": { + "schema": { + "type": "string" + }, + "example": "hello spelling" + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "404": { + "$ref": "#/components/responses/UnknownStyle" + }, + "413": { + "description": "Text over 500 characters", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/count/{text}": { + "get": { + "tags": [ + "text" + ], + "summary": "Count words and characters", + "operationId": "count", + "parameters": [ + { + "$ref": "#/components/parameters/text" + }, + { + "$ref": "#/components/parameters/format" + }, + { + "$ref": "#/components/parameters/textQuery" + }, + { + "$ref": "#/components/parameters/pretty" + }, + { + "$ref": "#/components/parameters/callback" + } + ], + "responses": { + "200": { + "description": "Counts (characters are Unicode code points, so an emoji counts once)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Count" + }, + "example": { + "input": "hello world", + "words": 2, + "characters": 11, + "charactersNoSpaces": 10 + } + }, + "text/plain": { + "schema": { + "type": "string" + }, + "example": "words: 2, chars: 11, chars (no spaces): 10" + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "413": { + "$ref": "#/components/responses/TooLong" + } + } + } + }, + "/lorem/{count}": { + "get": { + "tags": [ + "text" + ], + "summary": "Generate lorem ipsum", + "operationId": "lorem", + "parameters": [ + { + "name": "count", + "in": "path", + "required": true, + "description": "Number of words, 1 to 1000 (omit the segment entirely for 50)", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 1000, + "example": 10 + } + }, + { + "$ref": "#/components/parameters/styleQuery" + }, + { + "$ref": "#/components/parameters/format" + }, + { + "$ref": "#/components/parameters/pretty" + }, + { + "$ref": "#/components/parameters/callback" + } + ], + "responses": { + "200": { + "description": "Placeholder text, capitalized and ending in a period, optionally run through a style", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Lorem" + }, + "example": { + "count": 3, + "style": null, + "output": "Lorem ipsum dolor." + } + }, + "text/plain": { + "schema": { + "type": "string" + }, + "example": "Lorem ipsum dolor." + } + } + }, + "400": { + "description": "`invalid_count` (not an integer from 1 to 1000)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "$ref": "#/components/responses/UnknownStyle" + } + } + } + }, + "/badge/{style}/{text}": { + "get": { + "tags": [ + "badge" + ], + "summary": "SVG badge with the transformed text", + "description": "A shields.io-style flat badge rendered locally. Embed it in a README: `![sponge](https://capstring.netlify.app/api/badge/sponge/hello%20world)`.", + "operationId": "badge", + "parameters": [ + { + "$ref": "#/components/parameters/style" + }, + { + "$ref": "#/components/parameters/text" + }, + { + "name": "label", + "in": "query", + "required": false, + "description": "Left-hand label (defaults to the style name), up to 100 characters", + "schema": { + "type": "string", + "maxLength": 100, + "example": "capstring" + } + }, + { + "$ref": "#/components/parameters/textQuery" + } + ], + "responses": { + "200": { + "description": "SVG image", + "content": { + "image/svg+xml": { + "schema": { + "type": "string" + } + } + } + }, + "400": { + "description": "`missing_text` or `label_too_long`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "$ref": "#/components/responses/UnknownStyle" + }, + "413": { + "$ref": "#/components/responses/TooLong" + } + } + } + } + }, + "components": { + "parameters": { + "style": { + "name": "style", + "in": "path", + "required": true, + "description": "A style name from `/api/styles`", + "schema": { + "$ref": "#/components/schemas/StyleName" + } + }, + "styleQuery": { + "name": "style", + "in": "query", + "required": false, + "description": "Optional style applied to the result", + "schema": { + "$ref": "#/components/schemas/StyleName" + } + }, + "text": { + "name": "text", + "in": "path", + "required": true, + "description": "Text to transform, URL-encoded, up to 2,000 characters. May contain `/`; extra segments are joined back with slashes.", + "schema": { + "type": "string", + "maxLength": 2000, + "example": "hello world" + } + }, + "textQuery": { + "name": "text", + "in": "query", + "required": false, + "description": "Overrides the path text. Use it when the text contains slashes or ends in a format extension.", + "schema": { + "type": "string", + "maxLength": 2000 + } + }, + "format": { + "name": "format", + "in": "query", + "required": false, + "description": "Output format. A file extension on the path (`.txt`) takes precedence; an `Accept` header is used when neither is given.", + "schema": { + "type": "string", + "enum": [ + "json", + "jsonp", + "txt", + "html", + "xml", + "yaml", + "yml", + "csv" + ] + } + }, + "pretty": { + "name": "pretty", + "in": "query", + "required": false, + "description": "Indent JSON output when present", + "schema": { + "type": "string", + "enum": [ + "1" + ] + } + }, + "callback": { + "name": "callback", + "in": "query", + "required": false, + "description": "JSONP callback name (only with the `jsonp` format); defaults to `callback`", + "schema": { + "type": "string", + "pattern": "^[\\w$.]{1,64}$", + "example": "cb" + } + } + }, + "headers": { + "CacheControl": { + "description": "`public, max-age=86400` for deterministic results, `no-store` when the `random` style is involved", + "schema": { + "type": "string" + } + } + }, + "responses": { + "BadRequest": { + "description": "`missing_text`, `bad_encoding`, `invalid_format`, or `invalid_callback`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "missing_text", + "message": "Provide text in the path (/api/'); expect(await text('/api/styles.yaml')).toContain('styles:\n - "same"'); - expect(await text('/api/styles.csv')).toMatch(/^count,styles,categories\n"37","same\|none\|/); - expect(await text('/api/styles.html')).toContain('count37'); + expect(await text('/api/styles.csv')).toMatch(/^count,styles,categories\n"50","same\|none\|/); + expect(await text('/api/styles.html')).toContain('count50'); }); it('rejects extra segments', async () => { @@ -278,37 +278,6 @@ describe('GET /api/all/:text', () => { }); }); -describe('GET /api/chain/:styles/:text', () => { - it('applies styles in order with + or ,', async () => { - const { body } = await json('/api/chain/upper+reverse/hello'); - expect(body).toEqual({ input: 'hello', style: 'upper+reverse', styles: ['upper', 'reverse'], output: 'OLLEH' }); - expect((await json('/api/chain/lower,title,kebab/HELLO%20WORLD')).body.output).toBe('hello-world'); - expect(await text('/api/chain/upper/hi.txt')).toBe('HI'); - expect(await text('/api/chain/upper+reverse/hello.xml')).toContain('\n \n \n '); - }); - - it('bounds intermediate output so expanding chains cannot exhaust memory', async () => { - const { res, body } = await json(`/api/chain/binary+binary+binary/${'a'.repeat(300)}`); - expect(res.status).toBe(413); - expect(body.error.code).toBe('output_too_long'); - expect(body.error.message).toContain('step "binary"'); - expect((await call('/api/chain/binary+upper/hi')).status).toBe(200); - }); - - it('caches unless random is in the chain', async () => { - expect((await call('/api/chain/upper/hi')).headers.get('Cache-Control')).toBe('public, max-age=86400'); - expect((await call('/api/chain/upper+random/hi')).headers.get('Cache-Control')).toBe('no-store'); - }); - - it('validates styles and length', async () => { - expect((await json('/api/chain/upper+nope/hi')).body.error.code).toBe('unknown_style'); - expect((await json(`/api/chain/${Array(11).fill('upper').join('+')}/hi`)).body.error.code).toBe('chain_too_long'); - expect((await json('/api/chain')).body.error.code).toBe('missing_styles'); - expect((await json('/api/chain/+/hi')).body.error.code).toBe('missing_styles'); - expect((await json('/api/chain/upper')).body.error.code).toBe('missing_text'); - }); -}); - describe('POST /api/batch', () => { it('transforms many texts', async () => { const res = await post('/api/batch', { style: 'kebab', texts: ['Hello World', 'XMLHttpRequest'] }); diff --git a/test/browser-safe.test.js b/test/browser-safe.test.js index 5555557..538ba8d 100644 --- a/test/browser-safe.test.js +++ b/test/browser-safe.test.js @@ -19,7 +19,7 @@ describe('index.js stays browser-safe', () => { expect(read('web/index.html')).toContain(' -``` - -### Deno and Bun - -```ts -import capstring from 'npm:capstring'; // Deno -import capstring from 'capstring'; // Bun, after `bun add capstring` -``` - -## Transform one string - -```js -capstring(str, style = 'same', options = {}) -``` - -Returns the transformed string. Passing a non-string returns `false`; an empty string returns `''`. - -```js -capstring('hello world', 'camel'); // 'helloWorld' -capstring('hello world'); // 'hello world' (default: same) -capstring(''); // '' -capstring(123, 'upper'); // false -capstring('hello', 'not-a-style'); // 'hello' (unknown style returns input) -``` - -## Strict mode - -By default bad input is tolerated. Turn on `strict` to throw instead: - -```js -capstring(123, 'title', { strict: true }); // TypeError: capstring: expected a string, got number -capstring('hi', 'nope', { strict: true }); // RangeError: capstring: unknown style "nope" -``` - -## Every style at once - -```js -import { capstringAll } from 'capstring'; - -const all = capstringAll('hello world'); -all.kebab; // 'hello-world' -all.acronym; // 'HW' -Object.keys(all); // every style, in canonical order -``` - -## Discover styles - -```js -import { STYLES, CATEGORIES, getStyles, isValidStyle } from 'capstring'; - -STYLES; // frozen array of every style name -CATEGORIES.code; // ['camel', 'pascal', 'snake', 'kebab', ...] -getStyles(); // a fresh copy you can mutate -isValidStyle('kebab'); // true -``` - -Next: the full [library reference](library.md) or the [table of all styles](styles.md). diff --git a/web/dev/http-api.md b/web/dev/http-api.md deleted file mode 100644 index 1a0b764..0000000 --- a/web/dev/http-api.md +++ /dev/null @@ -1,114 +0,0 @@ -# HTTP API from code - -Don't want a dependency, or not writing JavaScript? The same transformations are an HTTP call away. - -**Base URL** `https://capstring.netlify.app/api` · no key · CORS enabled · cacheable - -> Interactive reference with try-it-out for every endpoint: [API reference](/docs/) (OpenAPI 3.1, [openapi.json](/openapi.json)). - -## Endpoints - -| Endpoint | Returns | -|---|---| -| `GET /api/:style/:text` | `{ input, style, output }` | -| `GET /api/all/:text` | every style at once | -| `GET /api/chain/:styles/:text` | styles applied in order, `upper+reverse` (max 10) | -| `POST /api/batch` | `{ "style": "kebab", "texts": ["a", "b"] }` (max 100) | -| `GET /api/spell/:text` | spell-corrected text and corrections, optional `?style=` | -| `GET /api/count/:text` | words, characters, characters without spaces | -| `GET /api/lorem/:count` | lorem ipsum, 1 to 1000 words, optional `?style=` | -| `GET /api/badge/:style/:text` | SVG badge for a README, `?label=` to override | -| `GET /api/styles` | style names and categories | - -Text is limited to 2,000 characters (500 for spell). Use `?text=` instead of the path when your text contains slashes or ends in a format extension. - -## Output formats - -JSON by default. Choose another with a file extension, `?format=`, or an `Accept` header; the extension wins. - -| Format | Extension | Accept | Content-Type | -|---|---|---|---| -| JSON | `.json` | `application/json` | `application/json` | -| Plain text | `.txt` | `text/plain` | `text/plain` | -| HTML | `.html` | `text/html` | `text/html` | -| XML | `.xml` | `application/xml` | `application/xml` | -| YAML | `.yaml` / `.yml` | `text/yaml` | `text/yaml` | -| CSV | `.csv` | `text/csv` | `text/csv` | -| JSONP | `.jsonp` + `?callback=` | `application/javascript` | `application/javascript` | - -Errors use the same format with a matching status: `{ "error": { "code": "unknown_style", "message": "..." } }`. - -## curl - -```bash -curl https://capstring.netlify.app/api/title/hello%20world -# {"input":"hello world","style":"title","output":"Hello World"} - -curl https://capstring.netlify.app/api/sponge/hello%20world.txt -# HeLlO WoRlD - -curl -H "Accept: text/csv" https://capstring.netlify.app/api/all/hi - -curl -X POST https://capstring.netlify.app/api/batch \ - -H "Content-Type: application/json" \ - -d '{"style":"slug","texts":["Crème Brûlée","Hello World"]}' -``` - -## JavaScript (browser or Node 18+) - -```js -const res = await fetch('https://capstring.netlify.app/api/kebab/Hello%20World'); -const { output } = await res.json(); // 'hello-world' - -// batch -const batch = await fetch('https://capstring.netlify.app/api/batch', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ style: 'slug', texts: ['Crème Brûlée', 'Hello World'] }) -}).then((r) => r.json()); -batch.results.map((r) => r.output); // ['creme-brulee', 'hello-world'] -``` - -## Python - -```python -import requests -from urllib.parse import quote - -r = requests.get(f"https://capstring.netlify.app/api/title/{quote('hello world')}") -print(r.json()["output"]) # Hello World - -# plain text via Accept header -print(requests.get("https://capstring.netlify.app/api/flip/hello", headers={"Accept": "text/plain"}).text) -``` - -## Shell one-liners - -```bash -# slugify a filename -curl -s "https://capstring.netlify.app/api/slug/$(printf '%s' "My Doc (final).pdf" | jq -sRr @uri).txt" - -# spell check and title case a commit message -curl -s "https://capstring.netlify.app/api/spell/fix%20teh%20bugg?style=sentence&format=txt" -``` - -## README badge - -```markdown -![sponge](https://capstring.netlify.app/api/badge/sponge/hello%20world) -``` - -![sponge](https://capstring.netlify.app/api/badge/sponge/hello%20world?label=capstring) - -## Limits - -| What | Limit | -|---|---| -| Text | 2,000 characters | -| Spell check text | 500 characters, 50 corrections | -| Batch | 100 texts | -| Chain | 10 styles, 20,000-character intermediate results | -| Lorem | 1,000 words | -| Badge label | 100 characters | - -Deterministic responses carry `Cache-Control: public, max-age=86400`; anything involving `random` or a POST body is `no-store`. diff --git a/web/dev/index.html b/web/dev/index.html index 3c55769..8b56fa6 100644 --- a/web/dev/index.html +++ b/web/dev/index.html @@ -5,81 +5,186 @@ - capstring developer docs - + capstring Developer Docs + - - +
-
Loading developer docs…
+ +
+ + +
+
+

Developer Docs

+

capstring is a small, serious, zero-dependency library for turning text into any case, code convention, encoding, or ridiculous fun style. Unicode and emoji safe. It ships a CLI, TypeScript types, and a free HTTP API, all documented on this page.

+ + + + + +
LibraryNode 22+, any modern browser, Deno, BunInstall
CLInpx capstring kebab "hello world"Command line
HTTP APIhttps://capstring.netlify.app/apiHTTP API · API Reference
PlaygroundType, click to copyOpen
+
    +
  • Smart tokenizer: XMLHttpRequest, hello_world, hello-world, and helloWorld all split correctly.
  • +
  • Real slug: diacritics folded, punctuation dropped, ASCII only.
  • +
  • Unicode and emoji safe: flags, ZWJ sequences, keycaps, and combining accents survive every style.
  • +
  • Zero dependencies, 7 files, under 40 kB on npm, and a test fails if that ever grows.
  • +
  • 100% test coverage, every behavior change recorded in the changelog.
  • +
+
+ +
+

Install

+

Requires Node 22.13 or newer. ES modules only: use import, not require. No runtime dependencies are installed.

+

In a browser with no bundler, import the library file straight from a CDN. It is dependency-free and browser-safe.

+
+ +
+

Transform a string

+

capstring(str, style = 'same', options = {}) returns the transformed string. The default export and the named export are the same function.

+ + + + + +
ParameterTypeDefaultDescription
strstringText to transform
styleStyle'same'A style name, see Styles
options.strictbooleanfalseThrow instead of returning false or the input
+

Passing a non-string returns false. An empty string returns ''. An unknown style returns the input unchanged. These are deliberate so the function is safe to call with untrusted values.

+
+ +
+

Strict mode

+

Turn on strict to throw instead: a TypeError when str is not a string, a RangeError when style is not a known style. The CLI and the HTTP API always validate strictly.

+
+ +
+

Every style at once

+

capstringAll(str, options) runs every style and returns an object keyed by style name in canonical order. Same options and same non-string behavior as capstring. The playground is built on this one call.

+
+ +
+

Discover styles

+ + + + + + +
ExportWhat it is
STYLESFrozen array of every style name in canonical order. New styles are appended, so indexes are stable.
CATEGORIESFrozen object grouping every style exactly once: case, code, fun, encoding, art.
getStyles()A fresh, mutable copy of STYLES.
isValidStyle(value)true for a style name. In TypeScript it narrows value to Style.
+
+ +
+

Styles

+

Every style, what it does, and how it handles the edges. Outputs on this page are computed live by the library, so they are exactly what you get. The first example is always hello world.

+
+
+ +
+

Command line

+

The package ships a capstring executable. Run it with npx or install it globally.

+
capstring <style> [text...]     Transform text (reads stdin when no text is given)
+capstring --all [text...]       Print every style
+capstring --list                List style names
+
+  -a, --all        Print every style
+  -l, --list       List style names, one per line
+      --json       JSON output
+  -h, --help       Show this help
+  -v, --version    Show version
+  --               End of options
+

Multiple text arguments are joined with one space. Flags may appear anywhere. With no text argument the CLI reads all of stdin and strips one trailing newline, so it slots into pipelines. Multiline stdin is treated as one string; in the --all table, line breaks inside a result are shown escaped so every style stays on one row.

+ + + + +
Exit codeMeaning
0Success
2Usage error: unknown style, unknown option, or no text given on a terminal
+
+ +
+

TypeScript

+

Types ship in the package. Every style name is a string literal in the Style union, so editors autocomplete them and typos fail to compile. capstring and capstringAll are overloaded so the return type matches the input: a string in gives a string back, unknown in gives string | false. The style parameter accepts Style | (string & {}), which keeps autocomplete while still allowing arbitrary strings.

+

A test in the repository fails if the union ever drifts from the runtime STYLES array.

+
+ +
+

HTTP API

+

The same transformations over HTTP, for any language or no code at all. Base URL https://capstring.netlify.app/api, no key, CORS enabled, cacheable. The interactive API Reference lets you try every endpoint in the browser.

+ + + + + + + + + + +
EndpointReturns
GET /api/:style/:text{ input, style, output }
GET /api/all/:textevery style at once
POST /api/batch{ "style": "kebab", "texts": ["a", "b"] }, up to 100
GET /api/spell/:textspell-corrected text and corrections, optional ?style=
GET /api/count/:textwords, characters, characters without spaces
GET /api/lorem/:countlorem ipsum, 1 to 1000 words, optional ?style=
GET /api/badge/:style/:textSVG badge, ?label= to override
GET /api/stylesstyle names and categories
+
+ +
+

Output formats

+

JSON by default. Choose another with a file extension on the path, a format query parameter, or an Accept header. The extension wins when more than one is given. Errors come back in the same format with a matching status.

+ + + + + + + + + +
FormatExtensionAccept
JSON.jsonapplication/json
Plain text.txttext/plain
HTML.htmltext/html
XML.xmlapplication/xml
YAML.yaml or .ymltext/yaml
CSV.csvtext/csv
JSONP.jsonp with ?callback=application/javascript
+

Use ?text= instead of the path when the text contains slashes or ends in an extension. Add ?pretty=1 for indented JSON.

+
+ +
+

Limits and caching

+ + + + + + + +
WhatLimit
Text2,000 characters
Spell check500 characters, 50 corrections per request
Batch100 texts
Lorem1,000 words
Badge label100 characters
+

Deterministic responses carry Cache-Control: public, max-age=86400. Anything involving the random style or a POST body is no-store.

+
+ +
+

What ships on npm

+ + + + + + +
FilePurpose
index.jsThe whole library, browser-safe
index.d.tsTypeScript declarations
cli.jsCLI logic as a testable main(argv, io)
bin/capstring.jsThe npx capstring executable
+

Plus package.json, README.md, and LICENSE. Zero dependencies, sideEffects: false so bundlers can tree-shake it. Release notes live in the changelog.

+
+
+ + +
+
- - - - - - - - + + + + + + + diff --git a/web/dev/library.md b/web/dev/library.md deleted file mode 100644 index 05745c1..0000000 --- a/web/dev/library.md +++ /dev/null @@ -1,80 +0,0 @@ -# Library reference - -Everything exported by `capstring`. - -## `capstring(str, style?, options?)` - -Default export and named export. - -| Parameter | Type | Default | Description | -|---|---|---|---| -| `str` | `string` | | Text to transform | -| `style` | `Style` | `'same'` | A style name | -| `options.strict` | `boolean` | `false` | Throw instead of returning `false` / the input | - -**Returns** `string`, or `false` when `str` is not a string and `strict` is off. - -```js -capstring('hello world', 'title'); // 'Hello World' -capstring('hello world', 'title', { strict: true }); -``` - -Throws in strict mode: - -- `TypeError` when `str` is not a string -- `RangeError` when `style` is not a known style - -## `capstringAll(str, options?)` - -Runs every style and returns an object keyed by style name in `STYLES` order. Same `options` and same non-string behavior as `capstring`. - -```js -capstringAll('hi'); -// { same: 'hi', none: '', proper: 'hi', title: 'Hi', ..., piglatin: 'ihay' } -``` - -## `STYLES` - -Frozen `readonly string[]` of all style names. Order is stable across releases; new styles are appended. - -## `CATEGORIES` - -Frozen object grouping every style exactly once: - -```js -{ - case: ['same', 'none', 'proper', 'title', 'sentence', 'upper', 'lower', 'swap'], - code: ['camel', 'pascal', 'snake', 'kebab', 'slug', 'constant', 'python', 'dot', 'path', 'train', 'hashtag', 'acronym'], - fun: ['reverse', 'sponge', 'mock', 'alternate', 'crazy', 'random', 'clap', 'piglatin'], - encoding: ['leet', 'rot13', 'morse', 'binary'], - art: ['flip', 'smallcaps', 'bubble', 'wide', 'strike'] -} -``` - -Use it to build grouped UIs, like the [playground](/) does. - -## `getStyles()` - -Returns a fresh, mutable copy of `STYLES`. - -## `isValidStyle(value)` - -`true` when `value` is a style name. In TypeScript this is a type guard: inside the `if`, `value` narrows to `Style`. - -```ts -const input: string = getUserInput(); -if (isValidStyle(input)) { - capstring('hi', input); // input is Style here -} -``` - -## Package layout - -| File | Purpose | -|---|---| -| `index.js` | The whole library, browser-safe (no Node imports) | -| `index.d.ts` | Hand-written TypeScript declarations | -| `cli.js` | CLI logic as a testable `main(argv, io)` | -| `bin/capstring.js` | The `npx capstring` executable | - -That is the entire published package: 7 files, zero dependencies, `sideEffects: false` so bundlers can tree-shake it. diff --git a/web/dev/recipes.md b/web/dev/recipes.md deleted file mode 100644 index 82a1e49..0000000 --- a/web/dev/recipes.md +++ /dev/null @@ -1,100 +0,0 @@ -# Recipes - -## Slugs for URLs and filenames - -```js -import capstring from 'capstring'; - -const slug = capstring('Crème Brûlée: The Sequel!', 'slug'); // 'creme-brulee-the-sequel' -const file = `${slug}.md`; -``` - -`slug` is ASCII only. If you need to keep Unicode letters in a URL path, use `kebab` instead (`crème-brûlée-the-sequel`). - -## Convert identifiers between conventions - -```js -const toSnake = (name) => capstring(name, 'snake'); -const toCamel = (name) => capstring(name, 'camel'); - -toSnake('XMLHttpRequest'); // 'xml_http_request' -toCamel('user_first_name'); // 'userFirstName' -toCamel('user-first-name'); // 'userFirstName' - -// rename object keys -const snakeKeys = (obj) => Object.fromEntries(Object.entries(obj).map(([k, v]) => [toSnake(k), v])); -snakeKeys({ firstName: 'Ada', lastName: 'Lovelace' }); // { first_name: 'Ada', last_name: 'Lovelace' } -``` - -## Constants and env var names - -```js -capstring('max retry count', 'constant'); // 'MAX_RETRY_COUNT' -``` - -## Build a style picker UI - -```js -import { CATEGORIES, capstringAll } from 'capstring'; - -const all = capstringAll(userInput); -for (const [category, styles] of Object.entries(CATEGORIES)) { - renderGroup(category, styles.map((s) => ({ style: s, output: all[s] }))); -} -``` - -## Validate a style from user input - -```js -import { isValidStyle, STYLES } from 'capstring'; - -const style = req.query.style; -if (!isValidStyle(style)) { - return res.status(400).json({ error: `style must be one of ${STYLES.join(', ')}` }); -} -``` - -Or let the library throw for you: - -```js -capstring(text, style, { strict: true }); // RangeError on an unknown style -``` - -## Chain styles - -```js -const pipeline = ['lower', 'title', 'kebab']; -pipeline.reduce((acc, s) => capstring(acc, s), 'HELLO WORLD'); // 'hello-world' -``` - -Watch out for expanding styles like `binary` and `morse` in long chains; each step multiplies the length. - -## Acronyms and hashtags from titles - -```js -capstring('Portable Network Graphics', 'acronym'); // 'PNG' -capstring('throwback thursday', 'hashtag'); // '#ThrowbackThursday' -``` - -## Fun output in a chat bot - -```js -const reply = (text) => capstring(text, pick(['sponge', 'clap', 'flip', 'bubble', 'piglatin'])); -``` - -## Deterministic "random" - -`crazy` looks random but is seeded by the text itself, so the same input always gives the same output. Use it when you need stable results in tests or snapshots; use `random` when you don't. - -## Sentence case for user-generated titles - -```js -capstring('hELLO. how ARE you? "fine."', 'sentence'); // 'Hello. How are you? "Fine."' -``` - -## Node script over a file - -```bash -# one style per line for every line in a file -while read -r line; do npx capstring title "$line"; done < titles.txt -``` diff --git a/web/dev/styles-data.js b/web/dev/styles-data.js new file mode 100644 index 0000000..5e5758d --- /dev/null +++ b/web/dev/styles-data.js @@ -0,0 +1,286 @@ +/** + * Per-style documentation for the developer docs page. Outputs are never written here; + * the page computes them live with the library so every example is correct by construction. + * `examples` lists inputs; the first is always "hello world". + */ +export const CATEGORY_DOCS = { + case: { + title: 'Case', + intro: 'Change letter case without touching word boundaries. Spaces, punctuation, and digits pass through.' + }, + code: { + title: 'Code conventions', + intro: 'Identifier styles for code, URLs, and files. All of them share one tokenizer: words split on whitespace, underscores, hyphens, dots, slashes, punctuation, emoji, and camelCase boundaries, and apostrophes are dropped. See the tokenizer rules under Behavior.' + }, + fun: { + title: 'Fun', + intro: 'Styles that exist because they are fun. Deterministic unless the name says random.' + }, + encoding: { + title: 'Encodings', + intro: 'Byte and cipher representations of the text. All are reversible by the obvious inverse.' + }, + art: { + title: 'Unicode art', + intro: 'Lookalike characters from other Unicode blocks. They paste anywhere plain text goes: chat, bios, commit messages. Characters without a lookalike pass through unchanged.' + } +}; + +export const STYLE_DOCS = { + same: { + summary: 'Returns the input unchanged.', + detail: 'The default style. Useful as a no-op when the style name comes from configuration or user input.', + examples: ['hello world', 'Hello World'], + notes: ['An unknown style name also returns the input unchanged unless strict mode is on.'] + }, + none: { + summary: 'Returns an empty string.', + detail: 'Exists for symmetry with same: a style that produces nothing, handy as a placeholder in style pickers.', + examples: ['hello world'], + notes: ['Through the HTTP API the output field is "" with status 200, not an error.'] + }, + proper: { + summary: 'Returns the input unchanged.', + detail: 'Behaves exactly like same.', + examples: ['hello world'] + }, + title: { + summary: 'Capitalizes the first letter of every word and lowercases the rest.', + detail: 'Every word is capitalized, including small words like "of" and "the"; the rule is deliberately predictable rather than editorial. Hyphens and underscores break words. Apostrophes stay inside words. Works on any script that has case.', + examples: ['hello world', 'HELLO WORLD', "don't stop-me now", 'hello_world', 'élan vital', '3d printing'], + notes: ['Words that start with a digit are left alone (3d stays 3d).'] + }, + sentence: { + summary: 'Lowercases everything, then capitalizes the first letter and the first letter after . ! ?', + detail: 'A sentence boundary is punctuation followed by whitespace. Quotes and brackets, ASCII or typographic, may sit on either side of the boundary. A period with no following space does not count, so decimals and abbreviations survive.', + examples: ['hello world', 'HELLO WORLD', 'hello. world! how? ok', 'hello. "world" (yes)', '3.14 is pi', 'see e.g.this'], + notes: ['Only the first letter after a boundary is touched; nothing else is capitalized.'] + }, + upper: { + summary: 'UPPERCASES every letter.', + detail: 'Uses the Unicode uppercase mapping, so ß becomes SS and accented letters keep their accents.', + examples: ['hello world', 'straße', 'élan'] + }, + lower: { + summary: 'lowercases every letter.', + detail: 'Uses the Unicode lowercase mapping.', + examples: ['hello world', 'HELLO WORLD', 'ÉLAN'] + }, + swap: { + summary: 'Inverts the case of every letter.', + detail: 'Uppercase becomes lowercase and vice versa. Characters without case pass through.', + examples: ['hello world', 'Hello World', 'ÀbÇ 😀 1'] + }, + capitalize: { + summary: 'Uppercases the first character only; everything else is untouched.', + detail: 'Unlike title, it does not lowercase the rest and does not look at other words. Unlike sentence, it never changes anything after the first character.', + examples: ['hello world', 'hELLO wORLD', 'élan vital', '😀 hi'], + notes: ['If the first character has no case (a digit, an emoji) nothing changes.'] + }, + lowerfirst: { + summary: 'Lowercases the first character only; everything else is untouched.', + detail: 'The inverse of capitalize. Handy for turning a PascalCase identifier into camelCase without re-tokenizing.', + examples: ['hello world', 'Hello World', 'HELLO', 'XMLHttpRequest'] + }, + camel: { + summary: 'camelCase: first word lowercase, every following word capitalized, no separators.', + detail: 'The usual JavaScript variable style. Acronyms are normalized (XMLHttpRequest becomes xmlHttpRequest), which keeps round trips consistent.', + examples: ['hello world', 'Hello World Foo', 'XMLHttpRequest hello_world', 'user-first-name', '__private_var__'] + }, + pascal: { + summary: 'PascalCase: every word capitalized, no separators.', + detail: 'Class names in most languages.', + examples: ['hello world', 'XMLHttpRequest', 'user_first_name'] + }, + snake: { + summary: 'snake_case: lowercase words joined by underscores.', + detail: 'Python, Ruby, Rust, SQL columns.', + examples: ['hello world', 'Hello World', 'XMLHttpRequest', 'getHTTPResponse2XX', 'utf8 string'] + }, + kebab: { + summary: 'kebab-case: lowercase words joined by hyphens, Unicode letters preserved.', + detail: 'CSS classes, CLI flags, file names. Unlike slug it keeps accented and non-Latin letters.', + examples: ['hello world', 'helloWorld', 'Crème Brûlée', 'Привет Мир'] + }, + slug: { + summary: 'URL slug: ASCII only, diacritics folded, everything else becomes a hyphen.', + detail: 'Runs the tokenizer, then NFKD-normalizes and strips combining marks, maps ß æ œ ø đ ł þ ð to ASCII, lowercases, and keeps only a-z0-9 joined by single hyphens. Scripts with no ASCII folding (CJK, Cyrillic) produce an empty slug; use kebab to keep them.', + examples: ['hello world', 'Crème Brûlée & Co.', 'XMLHttpRequest v2', "don't", 'straße', '日本語'], + notes: ['Leading and trailing hyphens are trimmed and runs collapse to one.'] + }, + constant: { + summary: 'CONSTANT_CASE: uppercase words joined by underscores.', + detail: 'Also called SCREAMING_SNAKE_CASE. Environment variables and compile-time constants.', + examples: ['hello world', 'helloWorld', 'max retry count'] + }, + python: { + summary: 'CONSTANT_CASE.', + detail: 'Behaves exactly like constant.', + examples: ['hello world'] + }, + dot: { + summary: 'dot.case: lowercase words joined by periods.', + detail: 'Property paths, i18n keys, Java packages.', + examples: ['hello world', 'userFirstName'] + }, + path: { + summary: 'path/case: lowercase words joined by slashes.', + detail: 'File and URL paths.', + examples: ['hello world', 'userFirstName'] + }, + train: { + summary: 'Train-Case: capitalized words joined by hyphens.', + detail: 'HTTP header names.', + examples: ['hello world', 'content type', 'XMLHttpRequest'] + }, + hashtag: { + summary: '#HashTag: PascalCase with a leading #.', + detail: 'Text with no words returns an empty string rather than a lone #.', + examples: ['hello world', 'throwback thursday', '!!!'] + }, + acronym: { + summary: 'First letter of every word, uppercased, joined.', + detail: 'Works on any script with case.', + examples: ['hello world', 'as soon as possible', 'XMLHttpRequest', 'élan vital'] + }, + ada: { + summary: 'Ada_Case: capitalized words joined by underscores.', + detail: 'Ada and some SQL dialects.', + examples: ['hello world', 'XMLHttpRequest'] + }, + cobol: { + summary: 'COBOL-CASE: uppercase words joined by hyphens.', + detail: 'COBOL identifiers and some config keys.', + examples: ['hello world', 'helloWorld'] + }, + initials: { + summary: 'First letter of every word, uppercased, each followed by a period.', + detail: 'Like acronym but punctuated.', + examples: ['hello world', 'as soon as possible', '!!!'] + }, + reverse: { + summary: 'Reverses the text.', + detail: 'Grapheme-aware: accents, flags, and emoji sequences stay attached to their base.', + examples: ['hello world', 'héllo 😀 wörld', '🇺🇸🇫🇷'] + }, + sponge: { + summary: 'AlTeRnAtInG cAsE starting uppercase, by position.', + detail: 'Every character counts, including spaces, so the pattern can look to skip after a space.', + examples: ['hello world', 'a😀b'] + }, + mock: { + summary: 'aLtErNaTiNg CaSe starting lowercase, by position.', + detail: 'The mirror of sponge.', + examples: ['hello world'] + }, + alternate: { + summary: 'Alternating case that counts letters only.', + detail: 'Spaces, digits, and punctuation do not advance the pattern, so every word starts where the last letter left off. Unicode letters count.', + examples: ['hello world', 'àbç déf', 'a 😀 b'] + }, + crazy: { + summary: 'Pseudo-random case, seeded by the text.', + detail: 'Looks random but the same input always gives the same output, so it is safe in snapshots and cacheable through the API.', + examples: ['hello world'] + }, + random: { + summary: 'Random case. Different every call.', + detail: 'The only non-deterministic style. The API marks responses that include it as uncacheable.', + examples: ['hello world'] + }, + clap: { + summary: 'Words 👏 joined 👏 by 👏 clap 👏 emoji.', + detail: 'Leading and trailing whitespace is trimmed; a single word is returned as is.', + examples: ['hello world', 'single', ' Hello big World '] + }, + piglatin: { + summary: 'Pig Latin, English rules.', + detail: 'Vowel-initial words get "way"; otherwise the leading consonant cluster moves to the end with "ay". "qu" moves as a unit even after other consonants, "y" counts as a vowel unless it starts the word, and an initial capital is preserved. Only runs of A-Z are touched.', + examples: ['hello world', 'apple', 'string', 'squeal', 'yes my rhythm', 'Hello, World!'] + }, + spaced: { + summary: 'O n e space between characters, two between words.', + detail: 'Grapheme-aware, so a flag or an accented letter stays together.', + examples: ['hello world', '🇺🇸 ok'] + }, + squish: { + summary: 'Removes all whitespace.', + detail: 'Tabs and newlines included. Case is untouched.', + examples: ['hello world', ' a\tb\nc '] + }, + nato: { + summary: 'NATO phonetic alphabet, words separated by " / ".', + detail: 'Letters and digits only; anything else is dropped. Case-insensitive.', + examples: ['hello world', 'SOS 1', 'x-ray?'] + }, + leet: { + summary: '1337: conventional ASCII substitutions, case preserved.', + detail: 'a→4 b→8 e→3 g→9 i→1 l→1 o→0 s→5 t→7 z→2. Everything else is untouched.', + examples: ['hello world', 'hello WORLD', 'abegilostz'] + }, + rot13: { + summary: 'ROT13 cipher on A-Z and a-z.', + detail: 'Applying it twice gives the original. Non-ASCII letters pass through.', + examples: ['hello world', 'Hello World'] + }, + morse: { + summary: 'International Morse code.', + detail: 'Letters, digits, and common punctuation. Letters are separated by a space and words by " / ". Unmapped characters are dropped.', + examples: ['hello world', 'SOS 1', 'hi!'] + }, + binary: { + summary: 'UTF-8 bytes as space-separated 8-bit groups.', + detail: 'Multi-byte characters produce multiple groups: é is two, 😀 is four.', + examples: ['hello world', 'hi', 'é', '😀'] + }, + hex: { + summary: 'UTF-8 bytes as space-separated lowercase hex pairs.', + detail: 'Same bytes as binary, shorter.', + examples: ['hello world', 'é', '😀'] + }, + base64: { + summary: 'Base64 of the UTF-8 bytes.', + detail: 'Standard alphabet with = padding, the same output as btoa on a UTF-8 encoded string.', + examples: ['hello world', 'é😀'] + }, + flip: { + summary: 'Upside-down text, reversed.', + detail: 'Each character is swapped for its upside-down lookalike and the whole string is reversed so it reads correctly when rotated 180°. Grapheme-aware.', + examples: ['hello world', 'Hello World!', '(a)'] + }, + smallcaps: { + summary: 'sᴍᴀʟʟ ᴄᴀᴘs using Unicode small capital letters.', + detail: 'Maps a-z and A-Z. Digits and other characters pass through.', + examples: ['hello world', 'Hello', 'A1 Ü 😀'] + }, + bubble: { + summary: 'ⓑⓤⓑⓑⓛⓔ letters and digits from the enclosed alphanumerics block.', + detail: 'Maps a-z, A-Z, and 0-9. Keycap emoji and other multi-code-point characters stay whole.', + examples: ['hello world', 'Hi 5', 'az AZ 09'] + }, + wide: { + summary: 'fullwidth characters.', + detail: 'Every printable ASCII character maps to its fullwidth form and spaces become ideographic spaces.', + examples: ['hello world', 'Hi!', '~Ü😀'] + }, + strike: { + summary: 's̶t̶r̶i̶k̶e̶t̶h̶r̶o̶u̶g̶h̶ with a combining long stroke overlay.', + detail: 'A U+0336 is added after every non-whitespace grapheme, so emoji sequences are struck as a unit.', + examples: ['hello world', '👨‍👩‍👧🇺🇸'] + }, + bold: { + summary: '𝗕𝗼𝗹𝗱 sans-serif from the Mathematical Alphanumeric block.', + detail: 'Maps A-Z, a-z, and 0-9. Other characters pass through.', + examples: ['hello world', 'Az 09 é!'] + }, + italic: { + summary: '𝘐𝘵𝘢𝘭𝘪𝘤 sans-serif from the Mathematical Alphanumeric block.', + detail: 'Maps A-Z and a-z; the block has no digits, so digits pass through.', + examples: ['hello world', 'Az 09'] + }, + script: { + summary: '𝓢𝓬𝓻𝓲𝓹𝓽 (bold script) from the Mathematical Alphanumeric block.', + detail: 'Maps A-Z and a-z. The bold variant is used because the regular script block has gaps.', + examples: ['hello world', 'Az 09'] + } +}; diff --git a/web/dev/styles.md b/web/dev/styles.md deleted file mode 100644 index 96a71f3..0000000 --- a/web/dev/styles.md +++ /dev/null @@ -1,79 +0,0 @@ -# All styles - -Try any of them live in the [playground](/). Every example below is `capstring(input, style)`. - -## Case - -| Style | Input | Output | -|---|---|---| -| `same` | Hello World | Hello World | -| `none` | Hello World | *(empty string)* | -| `proper` | Hello World | Hello World *(alias of same)* | -| `title` | élan vital | Élan Vital | -| `sentence` | hello. world! | Hello. World! | -| `upper` | hello world | HELLO WORLD | -| `lower` | HELLO WORLD | hello world | -| `swap` | Hello World | hELLO wORLD | - -## Code - -| Style | Input | Output | -|---|---|---| -| `camel` | hello world | helloWorld | -| `pascal` | hello world | HelloWorld | -| `snake` | hello world | hello_world | -| `kebab` | Crème Brûlée | crème-brûlée | -| `slug` | Crème Brûlée & Co. | creme-brulee-co | -| `constant` | hello world | HELLO_WORLD | -| `python` | hello world | HELLO_WORLD *(alias of constant)* | -| `dot` | hello world | hello.world | -| `path` | hello world | hello/world | -| `train` | hello world | Hello-World | -| `hashtag` | hello world | #HelloWorld | -| `acronym` | as soon as possible | ASAP | - -All code styles share one tokenizer. Words split on whitespace, `_`, `-`, `.`, `/`, punctuation, emoji, and camelCase boundaries: - -```js -capstring('XMLHttpRequest', 'kebab'); // 'xml-http-request' -capstring('getHTTPResponse2XX', 'snake'); // 'get_http_response2_xx' -capstring('__private_var__', 'camel'); // 'privateVar' -capstring("don't stop", 'snake'); // 'dont_stop' (apostrophes dropped) -capstring('utf8 string', 'kebab'); // 'utf8-string' (letters stay with digits) -``` - -`kebab` keeps Unicode letters; `slug` folds them to ASCII and drops anything else. - -## Fun - -| Style | Input | Output | -|---|---|---| -| `reverse` | hello world | dlrow olleh | -| `sponge` | hello world | HeLlO WoRlD | -| `mock` | hello world | hElLo wOrLd | -| `alternate` | hello world | hElLo WoRlD *(letters only)* | -| `crazy` | hello world | *(deterministic pseudo-random case)* | -| `random` | hello world | *(random case, different every call)* | -| `clap` | hello world | hello 👏 world | -| `piglatin` | hello world | ellohay orldway | - -## Encodings - -| Style | Input | Output | -|---|---|---| -| `leet` | hello world | h3110 w0r1d | -| `rot13` | hello | uryyb | -| `morse` | SOS 1 | ... --- ... / .---- | -| `binary` | hi | 01101000 01101001 | - -## Unicode art - -| Style | Input | Output | -|---|---|---| -| `flip` | hello | ollǝɥ | -| `smallcaps` | hello | ʜᴇʟʟᴏ | -| `bubble` | hello | ⓗⓔⓛⓛⓞ | -| `wide` | hello | hello | -| `strike` | hello | h̶e̶l̶l̶o̶ | - -See [behavior notes](behavior.md) for the exact rules behind `title`, `sentence`, `slug`, and the Unicode handling. diff --git a/web/dev/typescript.md b/web/dev/typescript.md deleted file mode 100644 index c423230..0000000 --- a/web/dev/typescript.md +++ /dev/null @@ -1,76 +0,0 @@ -# TypeScript - -Types ship in the package (`index.d.ts`). No `@types` install needed. - -## The `Style` union - -Every style name is a string literal type, so editors autocomplete them and typos fail to compile: - -```ts -import capstring, { type Style, type Category } from 'capstring'; - -const style: Style = 'kebab'; // autocompletes every style -const bad: Style = 'kebap'; // error: not assignable to Style -const group: Category = 'encoding'; // 'case' | 'code' | 'fun' | 'encoding' | 'art' -``` - -## Overloads - -`capstring` and `capstringAll` are overloaded so the return type matches what you pass in: - -```ts -capstring('hi', 'upper'); // string -capstring(value as unknown, 'upper'); // string | false -capstringAll('hi'); // Record -capstringAll(value as unknown); // Record | false -``` - -The style parameter accepts `Style | (string & {})`, which keeps autocomplete while still allowing an arbitrary string (an unknown style returns the input unchanged unless `strict` is on). - -## Narrowing with `isValidStyle` - -```ts -import { isValidStyle, capstring } from 'capstring'; - -function transform(text: string, style: string) { - if (!isValidStyle(style)) throw new Error(`unknown style ${style}`); - return capstring(text, style); // style is Style here -} -``` - -## Options - -```ts -interface CapstringOptions { - strict?: boolean; // throw TypeError / RangeError instead of returning false / input -} -``` - -## Declarations, in full - -```ts -export type Style = 'same' | 'none' | 'proper' | 'title' | 'sentence' | 'upper' | 'lower' | 'swap' - | 'camel' | 'pascal' | 'snake' | 'kebab' | 'slug' | 'constant' | 'python' | 'dot' | 'path' | 'train' - | 'leet' | 'reverse' | 'sponge' | 'mock' | 'alternate' | 'crazy' | 'random' - | 'hashtag' | 'acronym' | 'rot13' | 'flip' - | 'smallcaps' | 'bubble' | 'wide' | 'strike' | 'clap' | 'morse' | 'binary' | 'piglatin'; - -export type Category = 'case' | 'code' | 'fun' | 'encoding' | 'art'; - -export interface CapstringOptions { strict?: boolean } - -export const STYLES: readonly Style[]; -export const CATEGORIES: Readonly>; - -export function capstring(str: string, style?: Style | (string & {}), options?: CapstringOptions): string; -export function capstring(str: unknown, style?: Style | (string & {}), options?: CapstringOptions): string | false; - -export function capstringAll(str: string, options?: CapstringOptions): Record; -export function capstringAll(str: unknown, options?: CapstringOptions): Record | false; - -export function getStyles(): Style[]; -export function isValidStyle(style: unknown): style is Style; -export default capstring; -``` - -A test in the repo fails if this union ever drifts from the runtime `STYLES` array. diff --git a/web/icon.svg b/web/icon.svg index aa6e7bb..e11fdcc 100644 --- a/web/icon.svg +++ b/web/icon.svg @@ -1,11 +1,11 @@ - - - + + + - - + + diff --git a/web/index.html b/web/index.html index 8414655..09dd733 100644 --- a/web/index.html +++ b/web/index.html @@ -12,7 +12,6 @@ - @@ -25,15 +24,12 @@
-

CaPiTaLiZe StRiNgS . Type something, click a row to copy, press + to chain. The page URL carries your phrase, so share it.

+

Every case, code convention, encoding, and ridiculous fun style. Type something and click a row to copy it.

-
- - - -
+ +

@@ -50,16 +46,6 @@

- -
@@ -70,7 +56,6 @@

HTTP API

GET /api/:style/:text{ input, style, output } GET /api/all/:textevery style at once - GET /api/chain/:styles/:textstyles applied in order, e.g. upper+reverse (max 10) POST /api/batchbody { "style": "kebab", "texts": ["a", "b"] } (max 100) GET /api/spell/:textspell-corrected text and a list of corrections, optional ?style= (max 500 chars, 50 corrections) GET /api/count/:textwords, characters, characters without spaces @@ -84,7 +69,7 @@

Output formats

curl https://capstring.netlify.app/api/title/hello%20world
 curl https://capstring.netlify.app/api/sponge/hello%20world.txt
 curl -H "Accept: text/csv" https://capstring.netlify.app/api/all/hello%20world
-curl https://capstring.netlify.app/api/chain/upper+reverse/hello.yaml
+curl https://capstring.netlify.app/api/title/hello%20world.yaml
 curl -X POST https://capstring.netlify.app/api/batch \
   -H "Content-Type: application/json" \
   -d '{"style":"slug","texts":["Crème Brûlée","Hello World"]}'
diff --git a/web/openapi.json b/web/openapi.json
index 93190d1..13712e9 100644
--- a/web/openapi.json
+++ b/web/openapi.json
@@ -115,7 +115,7 @@
                   "$ref": "#/components/schemas/Styles"
                 },
                 "example": {
-                  "count": 37,
+                  "count": 50,
                   "styles": [
                     "same",
                     "none",
@@ -309,85 +309,6 @@
         }
       }
     },
-    "/chain/{styles}/{text}": {
-      "get": {
-        "tags": [
-          "transform"
-        ],
-        "summary": "Apply several styles in order",
-        "operationId": "chain",
-        "parameters": [
-          {
-            "name": "styles",
-            "in": "path",
-            "required": true,
-            "description": "Up to 10 style names joined with `+` or `,`, applied left to right.",
-            "schema": {
-              "type": "string",
-              "example": "upper+reverse"
-            }
-          },
-          {
-            "$ref": "#/components/parameters/text"
-          },
-          {
-            "$ref": "#/components/parameters/format"
-          },
-          {
-            "$ref": "#/components/parameters/textQuery"
-          },
-          {
-            "$ref": "#/components/parameters/pretty"
-          },
-          {
-            "$ref": "#/components/parameters/callback"
-          }
-        ],
-        "responses": {
-          "200": {
-            "description": "The final output after every step",
-            "content": {
-              "application/json": {
-                "schema": {
-                  "$ref": "#/components/schemas/Chain"
-                },
-                "example": {
-                  "input": "hello",
-                  "style": "upper+reverse",
-                  "styles": [
-                    "upper",
-                    "reverse"
-                  ],
-                  "output": "OLLEH"
-                }
-              },
-              "text/plain": {
-                "schema": {
-                  "type": "string"
-                },
-                "example": "OLLEH"
-              }
-            }
-          },
-          "400": {
-            "$ref": "#/components/responses/BadRequest"
-          },
-          "404": {
-            "$ref": "#/components/responses/UnknownStyle"
-          },
-          "413": {
-            "description": "Text over 2,000 characters, or an intermediate result over 20,000 characters (`output_too_long`)",
-            "content": {
-              "application/json": {
-                "schema": {
-                  "$ref": "#/components/schemas/Error"
-                }
-              }
-            }
-          }
-        }
-      }
-    },
     "/batch": {
       "post": {
         "tags": [
@@ -946,7 +867,20 @@
           "clap",
           "morse",
           "binary",
-          "piglatin"
+          "piglatin",
+          "capitalize",
+          "lowerfirst",
+          "ada",
+          "cobol",
+          "initials",
+          "spaced",
+          "squish",
+          "nato",
+          "hex",
+          "base64",
+          "bold",
+          "italic",
+          "script"
         ],
         "example": "title"
       },
@@ -1054,33 +988,6 @@
           "results"
         ]
       },
-      "Chain": {
-        "type": "object",
-        "properties": {
-          "input": {
-            "type": "string"
-          },
-          "style": {
-            "type": "string",
-            "description": "The styles joined with `+`"
-          },
-          "styles": {
-            "type": "array",
-            "items": {
-              "$ref": "#/components/schemas/StyleName"
-            }
-          },
-          "output": {
-            "type": "string"
-          }
-        },
-        "required": [
-          "input",
-          "style",
-          "styles",
-          "output"
-        ]
-      },
       "BatchRequest": {
         "type": "object",
         "properties": {
@@ -1260,10 +1167,7 @@
                 "enum": [
                   "missing_text",
                   "text_too_long",
-                  "output_too_long",
                   "unknown_style",
-                  "missing_styles",
-                  "chain_too_long",
                   "invalid_format",
                   "invalid_callback",
                   "bad_encoding",
diff --git a/web/site.css b/web/site.css
index 4fa8724..011ffa9 100644
--- a/web/site.css
+++ b/web/site.css
@@ -6,8 +6,9 @@
   --muted: #666;
   --card: #fff;
   --border: #e2e2e2;
-  --accent: #2563eb;
-  --accent-soft: #b5d4ff;
+  --accent: #6d28d9;
+  --accent-soft: #ede9fe;
+  --accent-2: #0d9488;
   --mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
   color-scheme: light;
 }
@@ -17,8 +18,9 @@
   --muted: #9a9a9a;
   --card: #1b1e24;
   --border: #2c3038;
-  --accent: #7ab0ff;
-  --accent-soft: #24436e;
+  --accent: #a78bfa;
+  --accent-soft: #2e1065;
+  --accent-2: #2dd4bf;
   color-scheme: dark;
 }
 html, body { background: var(--bg); color: var(--fg); }
@@ -44,7 +46,7 @@ html, body { background: var(--bg); color: var(--fg); }
   border-radius: 8px;
 }
 .site-nav a:hover { color: var(--fg); background: var(--card); }
-.site-nav a[aria-current="page"] { color: var(--fg); background: var(--card); border: 1px solid var(--border); }
+.site-nav a[aria-current="page"] { color: var(--accent); background: var(--accent-soft); }
 .site-links { margin-left: auto; display: flex; gap: 6px; align-items: center; }
 .site-links a, .site-links button { color: var(--muted); display: inline-flex; padding: 6px; border-radius: 8px; background: none; border: 0; cursor: pointer; font: inherit; }
 .site-links a:hover, .site-links button:hover { color: var(--fg); background: var(--card); }
diff --git a/web/style.css b/web/style.css
index 43f2656..442c2bf 100644
--- a/web/style.css
+++ b/web/style.css
@@ -15,11 +15,6 @@ h1 { margin: 0; font-size: 2.4rem; letter-spacing: 0.02em; }
 .wordmark { font-family: var(--mono); }
 .tagline { margin: 4px 0 8px; color: var(--muted); font-size: 1.05rem; }
 
-.input-row { display: flex; gap: 8px; align-items: stretch; }
-.input-row textarea { flex: 1; min-width: 0; }
-#spell { flex: 0 0 auto; align-self: stretch; padding: 0 14px; background: var(--accent-soft); border-color: var(--accent); font-weight: 600; }
-#spell:disabled { opacity: 0.6; cursor: wait; }
-
 textarea {
   width: 100%;
   font: 1.25rem/1.4 var(--mono);
@@ -68,6 +63,7 @@ button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
 
 h2 {
   font-size: 0.85rem;
+  color: var(--accent);
   text-transform: uppercase;
   letter-spacing: 0.12em;
   color: var(--muted);
@@ -75,9 +71,8 @@ h2 {
 }
 
 ul { list-style: none; margin: 0; padding: 0; display: grid; gap: 8px; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); }
-li { display: flex; gap: 6px; align-items: stretch; }
 .row {
-  flex: 1;
+  width: 100%;
   min-width: 0;
   display: grid;
   grid-template-columns: 7.5em 1fr;
@@ -88,33 +83,12 @@ li { display: flex; gap: 6px; align-items: stretch; }
   border-radius: 10px;
   transition: background 0.15s, border-color 0.15s;
 }
-.row .name { font-family: var(--mono); color: var(--accent); font-size: 0.9rem; }
+.row .name { font-family: var(--mono); color: var(--accent-2); font-size: 0.9rem; }
 .row .out { font-family: var(--mono); overflow-wrap: anywhere; white-space: pre-wrap; min-height: 1.4em; }
+.row .out:empty::after { content: '\00a0'; }
 .row.copied { background: var(--accent-soft); border-color: var(--accent); }
 .row.selected { border-color: var(--accent); }
 
-.add {
-  flex: 0 0 auto;
-  width: 2.2em;
-  font-size: 1.1rem;
-  line-height: 1;
-  color: var(--muted);
-}
-.add:hover { color: var(--accent); }
-
-.chain {
-  margin: 0 0 20px;
-  padding: 12px 14px;
-  border: 1px solid var(--accent);
-  border-radius: 10px;
-  background: var(--card);
-}
-.chain-head { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
-.chain-label { font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.12em; color: var(--muted); }
-.chain-list { display: inline-flex; flex-wrap: wrap; gap: 6px; flex: 1; }
-.chip { border-radius: 999px; padding: 2px 10px; font-family: var(--mono); font-size: 0.85rem; }
-.chain-out { display: block; margin-top: 10px; font-family: var(--mono); font-size: 1.1rem; overflow-wrap: anywhere; white-space: pre-wrap; }
-
 #api { margin-top: 48px; padding-top: 16px; border-top: 1px solid var(--border); }
 #api h2 { font-size: 1.3rem; text-transform: none; letter-spacing: 0; color: var(--fg); }
 #api h3 { font-size: 1rem; margin: 24px 0 6px; }
@@ -149,8 +123,6 @@ code { font-family: var(--mono); }
 
 @media (max-width: 600px) {
   h1 { font-size: 1.8rem; }
-  .input-row { flex-direction: column; }
-  #spell { padding: 8px 14px; }
   ul { grid-template-columns: 1fr; }
   .row { grid-template-columns: 6em 1fr; }
 }

From 517ec0fa029d4de239ec71a62d89c5a3adf811c4 Mon Sep 17 00:00:00 2001
From: Brian Funk 
Date: Wed, 7 Oct 2026 21:29:01 -0400
Subject: [PATCH 24/36] chore: declare btoa global for ESLint

---
 eslint.config.js | 1 +
 1 file changed, 1 insertion(+)

diff --git a/eslint.config.js b/eslint.config.js
index 10ca3cb..624be4d 100644
--- a/eslint.config.js
+++ b/eslint.config.js
@@ -11,6 +11,7 @@ export default [
         process: 'readonly',
         Intl: 'readonly',
         TextEncoder: 'readonly',
+        btoa: 'readonly',
         URL: 'readonly',
         URLSearchParams: 'readonly',
         Request: 'readonly',

From d8632f41424fa44c83fc5c6b292fc398dfed7011 Mon Sep 17 00:00:00 2001
From: Brian Funk 
Date: Wed, 7 Oct 2026 21:30:29 -0400
Subject: [PATCH 25/36] style: icon in the accent violet

---
 web/icon.svg | 12 +++---------
 1 file changed, 3 insertions(+), 9 deletions(-)

diff --git a/web/icon.svg b/web/icon.svg
index e11fdcc..53af45a 100644
--- a/web/icon.svg
+++ b/web/icon.svg
@@ -1,11 +1,5 @@
 
-  
-    
-      
-      
-    
-  
-  
-  
-  
+  
+  
+  
 

From c46a79d2f9194ffd5f25eed3665e9b0ce90e3cca Mon Sep 17 00:00:00 2001
From: Brian Funk 
Date: Wed, 7 Oct 2026 21:32:00 -0400
Subject: [PATCH 26/36] style: readable dark theme for the Swagger reference

---
 web/docs/index.html | 55 +++++++++++++++++++++++++++++++++++++--------
 1 file changed, 46 insertions(+), 9 deletions(-)

diff --git a/web/docs/index.html b/web/docs/index.html
index cd4c792..104e958 100644
--- a/web/docs/index.html
+++ b/web/docs/index.html
@@ -12,17 +12,54 @@
   
   
   
 
 

From 7a79f74c34e99db8b209aa5dd115955c407101aa Mon Sep 17 00:00:00 2001
From: Brian Funk 
Date: Wed, 7 Oct 2026 21:38:33 -0400
Subject: [PATCH 27/36] feat: API Reference served at /api, Developer Docs at
 /docs, inline code samples

- GET /api returns the Swagger reference to browsers (Accept: text/html)
  and JSON service info to everything else
- Developer Docs move to /docs with Node / CLI samples inline in each
  section and each style entry; the floating code panel is gone
- Dark theme for Swagger's JSON Schema renderer in the Schemas section
---
 AGENTS.md                          |   2 +-
 CLAUDE.md                          |   4 +-
 README.md                          |  12 +-
 netlify/functions/api.js           |   9 +-
 netlify/functions/swagger-page.js  |  43 ++++++
 test/api.test.js                   |   9 ++
 test/openapi.test.js               |  11 +-
 web/dev/index.html                 | 190 -----------------------
 web/{dev/dev.css => docs/docs.css} |  38 +++--
 web/{dev/dev.js => docs/docs.js}   |   0
 web/docs/index.html                | 236 ++++++++++++++++++++---------
 web/{dev => docs}/styles-data.js   |   0
 web/index.html                     |   2 +-
 web/site.js                        |  10 +-
 web/swagger-theme.css              |  90 +++++++++++
 15 files changed, 354 insertions(+), 302 deletions(-)
 create mode 100644 netlify/functions/swagger-page.js
 delete mode 100644 web/dev/index.html
 rename web/{dev/dev.css => docs/docs.css} (61%)
 rename web/{dev/dev.js => docs/docs.js} (100%)
 rename web/{dev => docs}/styles-data.js (100%)
 create mode 100644 web/swagger-theme.css

diff --git a/AGENTS.md b/AGENTS.md
index 36125d9..7449b5a 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -60,7 +60,7 @@ CATEGORIES;             // frozen { case, code, fun, encoding, art }
 
 ## Website and API
 
-- `web/` - playground, `web/dev/` Developer Docs, `web/docs/` Swagger API Reference; imports `./capstring.js` (copied from `index.js` at Netlify build time)
+- `web/` - playground, `web/docs/` Developer Docs (Swagger API Reference is served at `/api` by the function); imports `./capstring.js` (copied from `index.js` at Netlify build time)
 - `netlify/functions/api.js` - HTTP API at `https://capstring.netlify.app/api` (transform, all, batch, spell, count, lorem, badge, styles; json/txt/html/xml/yaml/csv/jsonp)
 - `npm run dev` runs both locally on http://localhost:8888
 
diff --git a/CLAUDE.md b/CLAUDE.md
index ce0c3c9..dc9a153 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -31,7 +31,7 @@ Single-file library plus a thin CLI:
   - `getStyles()`, `isValidStyle(style)`, `STYLES`, `CATEGORIES`
 - `cli.js` - `main(argv, io)` with injected I/O so it is unit-testable; `bin/capstring.js` is the shim
 - `index.d.ts` - hand-written types; `test/types.test.js` enforces that the `Style` union matches `STYLES`
-- `web/` - static site, no build step. Playground (`index.html`, `app.js`), Swagger API Reference (`docs/`, over `openapi.json`), Developer Docs (`dev/`: single Stripe-style page, per-style docs in `dev/styles-data.js` with outputs computed live), shared header/footer/theme (`site.js`, `site.css`, `theme-init.js`). Imports `./capstring.js`, which Netlify's build copies from `index.js` (gitignored locally)
+- `web/` - static site, no build step. Playground (`index.html`, `app.js`), Developer Docs (`docs/`: single Stripe-style page, per-style docs in `docs/styles-data.js` with outputs computed live). The Swagger API Reference is served by the API function at `/api` for browsers (`netlify/functions/swagger-page.js`, themed by `web/swagger-theme.css`, spec at `web/openapi.json`), shared header/footer/theme (`site.js`, `site.css`, `theme-init.js`). Imports `./capstring.js`, which Netlify's build copies from `index.js` (gitignored locally)
 - `netlify/functions/api.js` - the whole HTTP API, Functions 2.0 handler owning `/api/*`; tested by constructing `Request` objects. `nspell` and `dictionary-en` (spellcheck) are **devDependencies** on purpose: esbuild bundles them into the function and the npm package stays zero-dependency. `dictionary-en` is listed as external + included_files in `netlify.toml` because it reads its `.aff`/`.dic` files by path.
 - `netlify.toml` - publish `web/`, build copies `index.js`, bundles the function. Branch settings (production `master`, branch deploy `dev`) live in the Netlify UI, not in the file
 
@@ -54,7 +54,7 @@ Never remove a style name; `proper` and `python` are kept as aliases on purpose.
 2. Maintain 100% coverage (thresholds enforced): `npm run test:coverage`
 3. Run linter: `npm run lint`
 4. Update CHANGELOG.md for any user-facing changes; behavior changes get their own section
-5. Adding a style: add to `STYLES` **and** one `CATEGORIES` group **and** the `Style` union in `index.d.ts` **and** `web/dev/styles-data.js` **and** the enum in `web/openapi.json`, then regenerate the README table (every table row is `hello world`)
+5. Adding a style: add to `STYLES` **and** one `CATEGORIES` group **and** the `Style` union in `index.d.ts` **and** `web/docs/styles-data.js` **and** the enum in `web/openapi.json`, then regenerate the README table (every table row is `hello world`)
 6. Preserve the ASCII art header
 7. Keep the npm package lean: `test/package.test.js` fails if the tarball gains a file or exceeds the size budget. Website, API, tests, and changelog never ship to npm
 
diff --git a/README.md b/README.md
index a7135d9..39f114f 100644
--- a/README.md
+++ b/README.md
@@ -21,7 +21,7 @@ ridiculous fun style. Unicode and emoji safe. Ships a CLI and TypeScript types.
 - **Zero dependencies** - one 17 kB file, browser-safe
 - **Unicode aware** - `Élan Vital`, `crème-brûlée`, emoji and flags survive every style
 - **Smart tokenizer** - `XMLHttpRequest` becomes `xml-http-request`, `hello_world` becomes `helloWorld`
-- **Website, HTTP API, and docs** - [capstring.netlify.app](https://capstring.netlify.app): playground, [Developer Docs](https://capstring.netlify.app/dev/), [API Reference](https://capstring.netlify.app/docs/)
+- **Website, HTTP API, and docs** - [capstring.netlify.app](https://capstring.netlify.app): playground, [Developer Docs](https://capstring.netlify.app/docs/), [API Reference](https://capstring.netlify.app/api)
 - **CLI** - `npx capstring kebab "Hello World"`
 - **TypeScript types** - autocomplete for every style name
 - **100% test coverage**
@@ -63,7 +63,7 @@ Exit code `2` means a usage error (unknown style or option, missing text).
 
 ## All Styles
 
-Every style applied to `hello world`. Full explanations with more examples are in the [Developer Docs](https://capstring.netlify.app/dev/#styles).
+Every style applied to `hello world`. Full explanations with more examples are in the [Developer Docs](https://capstring.netlify.app/docs/#styles).
 
 ### Case
 
@@ -198,7 +198,7 @@ capstring('Hello', style);      // string
 ## HTTP API
 
 Free, no key, CORS enabled, hosted at `https://capstring.netlify.app/api`. Interactive reference
-with try-it-out: [capstring.netlify.app/docs](https://capstring.netlify.app/docs/).
+with try-it-out: [capstring.netlify.app/api](https://capstring.netlify.app/api).
 
 ```bash
 curl https://capstring.netlify.app/api/title/hello%20world
@@ -234,7 +234,7 @@ spell). Errors come back in the same format as `{ "error": { "code", "message" }
 ## Behavior notes
 
 Exact rules for `title`, `sentence`, `slug`, the tokenizer, and Unicode handling are documented per
-style in the [Developer Docs](https://capstring.netlify.app/dev/#styles). The short version: every
+style in the [Developer Docs](https://capstring.netlify.app/docs/#styles). The short version: every
 style iterates code points, the reordering and art styles iterate grapheme clusters, `slug` is ASCII
 only, and `random` is the only non-deterministic style.
 
@@ -248,8 +248,8 @@ npm run test:coverage  # Test with coverage (100% required)
 npm run dev            # Netlify Dev: site + API at http://localhost:8888
 ```
 
-The website (`web/`: playground, Developer Docs, Swagger API Reference; plain HTML/CSS/JS, no build
-step) and the API (`netlify/functions/api.js`) both import `index.js` directly. The spellcheck
+The website (`web/`: playground and Developer Docs; plain HTML/CSS/JS, no build step; the Swagger API
+Reference is served by the API function at `/api`) and the API (`netlify/functions/api.js`) both import `index.js` directly. The spellcheck
 endpoint's dependencies (`nspell`, `dictionary-en`) are devDependencies bundled into the function,
 so the npm package itself stays zero-dependency.
 
diff --git a/netlify/functions/api.js b/netlify/functions/api.js
index 12dfcf7..b7c8e1f 100644
--- a/netlify/functions/api.js
+++ b/netlify/functions/api.js
@@ -9,7 +9,7 @@
 /**
  * capstring HTTP API - a single Netlify Function serving every /api route.
  *
- *   GET  /api                        service info
+ *   GET  /api                        service info (JSON), or the Swagger reference for browsers
  *   GET  /api/styles                 style names and categories
  *   GET  /api/:style/:text           one style
  *   GET  /api/all/:text              every style
@@ -28,6 +28,7 @@
 
 import capstring, { capstringAll, STYLES, CATEGORIES, isValidStyle } from '../../index.js';
 import pkg from '../../package.json' with { type: 'json' };
+import { SWAGGER_PAGE } from './swagger-page.js';
 
 export const config = { path: ['/api', '/api/*'] };
 
@@ -490,7 +491,11 @@ const route = async (req, format, query, segments) => {
 
   if (head === undefined) {
     requireMethod(req, 'GET');
-    const data = { name: pkg.name, version: pkg.version, docs: `${SITE}/#api`, formats: FORMATS, endpoints: ENDPOINTS };
+    if (format === 'html') {
+      // A browser at /api gets the interactive reference; curl and fetch get the JSON below
+      return new Response(SWAGGER_PAGE, { headers: { ...CORS, 'Content-Type': CONTENT_TYPES.html, 'X-Content-Type-Options': 'nosniff', 'Cache-Control': 'public, max-age=3600', 'Vary': 'Accept' } });
+    }
+    const data = { name: pkg.name, version: pkg.version, docs: `${SITE}/api`, formats: FORMATS, endpoints: ENDPOINTS };
     return send({ data, text: `${pkg.name} ${pkg.version}\n${ENDPOINTS.join('\n')}`, title: 'capstring API' });
   }
 
diff --git a/netlify/functions/swagger-page.js b/netlify/functions/swagger-page.js
new file mode 100644
index 0000000..36de9c8
--- /dev/null
+++ b/netlify/functions/swagger-page.js
@@ -0,0 +1,43 @@
+/**
+ * The interactive API Reference (Swagger UI), served by the API function itself at GET /api when
+ * the client asks for HTML (a browser). Non-browser clients get the JSON service info instead.
+ * Kept as a module so the function bundle has no file-system dependency.
+ */
+export const SWAGGER_PAGE = `
+
+
+  
+  
+  
+  
+  capstring API Reference
+  
+  
+  
+  
+  
+  
+
+
+  
+
+
+ + + + + +`; diff --git a/test/api.test.js b/test/api.test.js index fabb4cf..e6fda44 100644 --- a/test/api.test.js +++ b/test/api.test.js @@ -59,6 +59,15 @@ describe('GET /api', () => { it('honours txt', async () => { expect(await text('/api?format=txt')).toMatch(/^capstring \d+\.\d+\.\d+\nGET \/api\/styles\n/); }); + + it('serves the Swagger reference to browsers and JSON to everyone else', async () => { + const page = await call('/api', { headers: { Accept: 'text/html,application/xhtml+xml,*/*;q=0.8' } }); + expect(page.headers.get('Content-Type')).toBe('text/html; charset=utf-8'); + expect(await page.text()).toContain('SwaggerUIBundle'); + expect((await call('/api/?format=html')).headers.get('Content-Type')).toBe('text/html; charset=utf-8'); + expect((await call('/api', { headers: { Accept: '*/*' } })).headers.get('Content-Type')).toBe('application/json; charset=utf-8'); + expect((await json('/api')).body.docs).toBe('https://capstring.netlify.app/api'); + }); }); describe('GET /api/styles', () => { diff --git a/test/openapi.test.js b/test/openapi.test.js index 9d16cdd..a8d9bf2 100644 --- a/test/openapi.test.js +++ b/test/openapi.test.js @@ -47,15 +47,16 @@ describe('web/openapi.json', () => { }); it('never shows a personal name on the site', () => { - const files = ['../web/openapi.json', '../web/index.html', '../web/404.html', '../web/site.js', '../web/docs/index.html', '../web/dev/index.html', '../web/dev/dev.js', '../web/dev/styles-data.js']; + const files = ['../web/openapi.json', '../web/index.html', '../web/404.html', '../web/site.js', '../web/docs/index.html', '../web/docs/docs.js', '../web/docs/styles-data.js', '../netlify/functions/swagger-page.js']; for (const f of files) expect(readFileSync(new URL(f, import.meta.url), 'utf8')).not.toMatch(/Brian|Funk/); }); - it('the docs page loads the spec from the same place it is published', () => { - const docs = readFileSync(new URL('../web/docs/index.html', import.meta.url), 'utf8'); - expect(docs).toContain("url: '/openapi.json'"); - expect(docs).toContain('cdnjs.cloudflare.com/ajax/libs/swagger-ui/'); + it('the Swagger page served at /api loads the spec from where it is published', () => { + const page = readFileSync(new URL('../netlify/functions/swagger-page.js', import.meta.url), 'utf8'); + expect(page).toContain("url: '/openapi.json'"); + expect(page).toContain('cdnjs.cloudflare.com/ajax/libs/swagger-ui/'); const home = readFileSync(new URL('../web/index.html', import.meta.url), 'utf8'); expect(home).toContain('href="/docs/"'); + expect(home).toContain('href="/api"'); }); }); diff --git a/web/dev/index.html b/web/dev/index.html deleted file mode 100644 index 8b56fa6..0000000 --- a/web/dev/index.html +++ /dev/null @@ -1,190 +0,0 @@ - - - - - - - - capstring Developer Docs - - - - - - - -
- -
- - -
-
-

Developer Docs

-

capstring is a small, serious, zero-dependency library for turning text into any case, code convention, encoding, or ridiculous fun style. Unicode and emoji safe. It ships a CLI, TypeScript types, and a free HTTP API, all documented on this page.

- - - - - -
LibraryNode 22+, any modern browser, Deno, BunInstall
CLInpx capstring kebab "hello world"Command line
HTTP APIhttps://capstring.netlify.app/apiHTTP API · API Reference
PlaygroundType, click to copyOpen
-
    -
  • Smart tokenizer: XMLHttpRequest, hello_world, hello-world, and helloWorld all split correctly.
  • -
  • Real slug: diacritics folded, punctuation dropped, ASCII only.
  • -
  • Unicode and emoji safe: flags, ZWJ sequences, keycaps, and combining accents survive every style.
  • -
  • Zero dependencies, 7 files, under 40 kB on npm, and a test fails if that ever grows.
  • -
  • 100% test coverage, every behavior change recorded in the changelog.
  • -
-
- -
-

Install

-

Requires Node 22.13 or newer. ES modules only: use import, not require. No runtime dependencies are installed.

-

In a browser with no bundler, import the library file straight from a CDN. It is dependency-free and browser-safe.

-
- -
-

Transform a string

-

capstring(str, style = 'same', options = {}) returns the transformed string. The default export and the named export are the same function.

- - - - - -
ParameterTypeDefaultDescription
strstringText to transform
styleStyle'same'A style name, see Styles
options.strictbooleanfalseThrow instead of returning false or the input
-

Passing a non-string returns false. An empty string returns ''. An unknown style returns the input unchanged. These are deliberate so the function is safe to call with untrusted values.

-
- -
-

Strict mode

-

Turn on strict to throw instead: a TypeError when str is not a string, a RangeError when style is not a known style. The CLI and the HTTP API always validate strictly.

-
- -
-

Every style at once

-

capstringAll(str, options) runs every style and returns an object keyed by style name in canonical order. Same options and same non-string behavior as capstring. The playground is built on this one call.

-
- -
-

Discover styles

- - - - - - -
ExportWhat it is
STYLESFrozen array of every style name in canonical order. New styles are appended, so indexes are stable.
CATEGORIESFrozen object grouping every style exactly once: case, code, fun, encoding, art.
getStyles()A fresh, mutable copy of STYLES.
isValidStyle(value)true for a style name. In TypeScript it narrows value to Style.
-
- -
-

Styles

-

Every style, what it does, and how it handles the edges. Outputs on this page are computed live by the library, so they are exactly what you get. The first example is always hello world.

-
-
- -
-

Command line

-

The package ships a capstring executable. Run it with npx or install it globally.

-
capstring <style> [text...]     Transform text (reads stdin when no text is given)
-capstring --all [text...]       Print every style
-capstring --list                List style names
-
-  -a, --all        Print every style
-  -l, --list       List style names, one per line
-      --json       JSON output
-  -h, --help       Show this help
-  -v, --version    Show version
-  --               End of options
-

Multiple text arguments are joined with one space. Flags may appear anywhere. With no text argument the CLI reads all of stdin and strips one trailing newline, so it slots into pipelines. Multiline stdin is treated as one string; in the --all table, line breaks inside a result are shown escaped so every style stays on one row.

- - - - -
Exit codeMeaning
0Success
2Usage error: unknown style, unknown option, or no text given on a terminal
-
- -
-

TypeScript

-

Types ship in the package. Every style name is a string literal in the Style union, so editors autocomplete them and typos fail to compile. capstring and capstringAll are overloaded so the return type matches the input: a string in gives a string back, unknown in gives string | false. The style parameter accepts Style | (string & {}), which keeps autocomplete while still allowing arbitrary strings.

-

A test in the repository fails if the union ever drifts from the runtime STYLES array.

-
- -
-

HTTP API

-

The same transformations over HTTP, for any language or no code at all. Base URL https://capstring.netlify.app/api, no key, CORS enabled, cacheable. The interactive API Reference lets you try every endpoint in the browser.

- - - - - - - - - - -
EndpointReturns
GET /api/:style/:text{ input, style, output }
GET /api/all/:textevery style at once
POST /api/batch{ "style": "kebab", "texts": ["a", "b"] }, up to 100
GET /api/spell/:textspell-corrected text and corrections, optional ?style=
GET /api/count/:textwords, characters, characters without spaces
GET /api/lorem/:countlorem ipsum, 1 to 1000 words, optional ?style=
GET /api/badge/:style/:textSVG badge, ?label= to override
GET /api/stylesstyle names and categories
-
- -
-

Output formats

-

JSON by default. Choose another with a file extension on the path, a format query parameter, or an Accept header. The extension wins when more than one is given. Errors come back in the same format with a matching status.

- - - - - - - - - -
FormatExtensionAccept
JSON.jsonapplication/json
Plain text.txttext/plain
HTML.htmltext/html
XML.xmlapplication/xml
YAML.yaml or .ymltext/yaml
CSV.csvtext/csv
JSONP.jsonp with ?callback=application/javascript
-

Use ?text= instead of the path when the text contains slashes or ends in an extension. Add ?pretty=1 for indented JSON.

-
- -
-

Limits and caching

- - - - - - - -
WhatLimit
Text2,000 characters
Spell check500 characters, 50 corrections per request
Batch100 texts
Lorem1,000 words
Badge label100 characters
-

Deterministic responses carry Cache-Control: public, max-age=86400. Anything involving the random style or a POST body is no-store.

-
- -
-

What ships on npm

- - - - - - -
FilePurpose
index.jsThe whole library, browser-safe
index.d.tsTypeScript declarations
cli.jsCLI logic as a testable main(argv, io)
bin/capstring.jsThe npx capstring executable
-

Plus package.json, README.md, and LICENSE. Zero dependencies, sideEffects: false so bundlers can tree-shake it. Release notes live in the changelog.

-
-
- - -
- -
- - - - - - - - - - diff --git a/web/dev/dev.css b/web/docs/docs.css similarity index 61% rename from web/dev/dev.css rename to web/docs/docs.css index 7febcc5..ad4ecde 100644 --- a/web/dev/dev.css +++ b/web/docs/docs.css @@ -4,9 +4,9 @@ a { color: var(--accent); } .docs { display: grid; - grid-template-columns: 240px minmax(0, 1fr) minmax(360px, 44%); - gap: 0 32px; - max-width: 1500px; + grid-template-columns: 240px minmax(0, 1fr); + gap: 0 40px; + max-width: 1100px; margin: 0 auto; padding: 0 16px; } @@ -27,7 +27,7 @@ a { color: var(--accent); } .toc ol { list-style: none; margin: 0; padding: 0; } .toc li a { display: block; color: var(--muted); text-decoration: none; padding: 4px 10px; border-left: 2px solid transparent; border-radius: 0 6px 6px 0; } .toc li a:hover { color: var(--fg); background: var(--card); } -.toc li a[aria-current="true"] { color: var(--accent); border-left-color: var(--accent); } +.toc li a[aria-current] { color: var(--accent); border-left-color: var(--accent); } .toc li.l2 a { padding-left: 22px; font-size: 13px; } .toc li.l3 a { padding-left: 34px; font-size: 13px; font-family: var(--mono); color: var(--accent-2); opacity: 0.85; } .toc li.hidden { display: none; } @@ -56,24 +56,20 @@ a { color: var(--accent); } .style-entry .notes { margin: 6px 0 0; padding-left: 18px; color: var(--muted); font-size: 0.92rem; } .style-entry .try { font-size: 0.85rem; } -/* ---- right: code panel ---- */ -.code-panel { position: sticky; top: 16px; align-self: start; margin: 20px 0; border-radius: 12px; overflow: hidden; border: 1px solid var(--border); background: #1b1e24; color: #e6e6e6; } -:root[data-theme="light"] .code-panel { background: #fff; color: #1a1a1a; } -.code-tabs { display: flex; gap: 2px; padding: 8px 10px 0; border-bottom: 1px solid var(--border); background: var(--card); } -.code-tabs button { font: inherit; font-size: 13px; padding: 6px 12px; border: 0; border-bottom: 2px solid transparent; background: none; color: var(--muted); cursor: pointer; border-radius: 6px 6px 0 0; } -.code-tabs button[aria-selected="true"] { color: var(--fg); border-bottom-color: var(--accent); } -.code-tabs button:hover { color: var(--fg); } -.code-body { max-height: calc(100vh - 140px); overflow: auto; } -.code-body pre { margin: 0; padding: 16px; font-size: 13px; line-height: 1.55; background: transparent !important; } -.code-body pre code { background: transparent !important; text-shadow: none !important; } -.code-foot { display: flex; justify-content: space-between; align-items: center; padding: 8px 12px; border-top: 1px solid var(--border); background: var(--card); font-size: 13px; color: var(--muted); } -.code-foot button { font: inherit; font-size: 13px; padding: 4px 10px; border: 1px solid var(--border); border-radius: 6px; background: var(--bg); color: var(--fg); cursor: pointer; } -.code-foot button:hover { border-color: var(--accent); } +/* ---- inline samples ---- */ +.sample { margin: 14px 0 6px; border: 1px solid var(--border); border-radius: 10px; overflow: hidden; background: var(--card); } +.sample-tabs { display: flex; align-items: center; gap: 2px; padding: 6px 8px 0; border-bottom: 1px solid var(--border); } +.sample-tabs button { font: inherit; font-size: 13px; padding: 5px 12px; border: 0; border-bottom: 2px solid transparent; background: none; color: var(--muted); cursor: pointer; border-radius: 6px 6px 0 0; } +.sample-tabs button[aria-selected="true"] { color: var(--fg); border-bottom-color: var(--accent); } +.sample-tabs button:hover { color: var(--fg); } +.sample-tabs .copy { margin-left: auto; font-size: 12px; border: 1px solid var(--border); border-radius: 6px; padding: 3px 9px; background: var(--bg); color: var(--fg); } +.sample pre { margin: 0; padding: 14px; font-size: 13px; line-height: 1.55; overflow-x: auto; background: transparent !important; border: 0; border-radius: 0; } +.sample pre code { background: transparent !important; text-shadow: none !important; padding: 0; } +:root[data-theme="light"] .sample pre code, :root[data-theme="light"] .sample pre code .token { color: #1a1a1a; } +:root[data-theme="light"] .sample .token.string { color: #0a7d3b; } +:root[data-theme="light"] .sample .token.keyword, :root[data-theme="light"] .sample .token.function { color: #6d28d9; } +:root[data-theme="light"] .sample .token.comment { color: #6b7280; } -@media (max-width: 1100px) { - .docs { grid-template-columns: 200px minmax(0, 1fr); } - .code-panel { grid-column: 1 / -1; position: static; } -} @media (max-width: 720px) { .docs { grid-template-columns: 1fr; } .toc { position: static; max-height: none; } diff --git a/web/dev/dev.js b/web/docs/docs.js similarity index 100% rename from web/dev/dev.js rename to web/docs/docs.js diff --git a/web/docs/index.html b/web/docs/index.html index 104e958..d60cf1e 100644 --- a/web/docs/index.html +++ b/web/docs/index.html @@ -5,82 +5,180 @@ - capstring API docs - + capstring Developer Docs + - - +
-
+ +
+ + +
+
+

Developer Docs

+

capstring is a small, serious, zero-dependency library for turning text into any case, code convention, encoding, or ridiculous fun style. Unicode and emoji safe. It ships a CLI, TypeScript types, and a free HTTP API, all documented on this page.

+ + + + + +
LibraryNode 22+, any modern browser, Deno, BunInstall
CLInpx capstring kebab "hello world"Command line
HTTP APIhttps://capstring.netlify.app/apiHTTP API · API Reference
PlaygroundType, click to copyOpen
+
    +
  • Smart tokenizer: XMLHttpRequest, hello_world, hello-world, and helloWorld all split correctly.
  • +
  • Real slug: diacritics folded, punctuation dropped, ASCII only.
  • +
  • Unicode and emoji safe: flags, ZWJ sequences, keycaps, and combining accents survive every style.
  • +
  • Zero dependencies, 7 files, under 40 kB on npm, and a test fails if that ever grows.
  • +
  • 100% test coverage, every behavior change recorded in the changelog.
  • +
+
+ +
+

Install

+

Requires Node 22.13 or newer. ES modules only: use import, not require. No runtime dependencies are installed.

+

In a browser with no bundler, import the library file straight from a CDN. It is dependency-free and browser-safe.

+
+ +
+

Transform a string

+

capstring(str, style = 'same', options = {}) returns the transformed string. The default export and the named export are the same function.

+ + + + + +
ParameterTypeDefaultDescription
strstringText to transform
styleStyle'same'A style name, see Styles
options.strictbooleanfalseThrow instead of returning false or the input
+

Passing a non-string returns false. An empty string returns ''. An unknown style returns the input unchanged. These are deliberate so the function is safe to call with untrusted values.

+
+ +
+

Strict mode

+

Turn on strict to throw instead: a TypeError when str is not a string, a RangeError when style is not a known style. The CLI and the HTTP API always validate strictly.

+
+ +
+

Every style at once

+

capstringAll(str, options) runs every style and returns an object keyed by style name in canonical order. Same options and same non-string behavior as capstring. The playground is built on this one call.

+
+ +
+

Discover styles

+ + + + + + +
ExportWhat it is
STYLESFrozen array of every style name in canonical order. New styles are appended, so indexes are stable.
CATEGORIESFrozen object grouping every style exactly once: case, code, fun, encoding, art.
getStyles()A fresh, mutable copy of STYLES.
isValidStyle(value)true for a style name. In TypeScript it narrows value to Style.
+
+ +
+

Styles

+

Every style, what it does, and how it handles the edges. Outputs on this page are computed live by the library, so they are exactly what you get. The first example is always hello world.

+
+
+ +
+

Command line

+

The package ships a capstring executable. Run it with npx or install it globally.

+
capstring <style> [text...]     Transform text (reads stdin when no text is given)
+capstring --all [text...]       Print every style
+capstring --list                List style names
+
+  -a, --all        Print every style
+  -l, --list       List style names, one per line
+      --json       JSON output
+  -h, --help       Show this help
+  -v, --version    Show version
+  --               End of options
+

Multiple text arguments are joined with one space. Flags may appear anywhere. With no text argument the CLI reads all of stdin and strips one trailing newline, so it slots into pipelines. Multiline stdin is treated as one string; in the --all table, line breaks inside a result are shown escaped so every style stays on one row.

+ + + + +
Exit codeMeaning
0Success
2Usage error: unknown style, unknown option, or no text given on a terminal
+
+ +
+

TypeScript

+

Types ship in the package. Every style name is a string literal in the Style union, so editors autocomplete them and typos fail to compile. capstring and capstringAll are overloaded so the return type matches the input: a string in gives a string back, unknown in gives string | false. The style parameter accepts Style | (string & {}), which keeps autocomplete while still allowing arbitrary strings.

+

A test in the repository fails if the union ever drifts from the runtime STYLES array.

+
+ +
+

HTTP API

+

The same transformations over HTTP, for any language or no code at all. Base URL https://capstring.netlify.app/api, no key, CORS enabled, cacheable. The interactive API Reference lets you try every endpoint in the browser.

+ + + + + + + + + + +
EndpointReturns
GET /api/:style/:text{ input, style, output }
GET /api/all/:textevery style at once
POST /api/batch{ "style": "kebab", "texts": ["a", "b"] }, up to 100
GET /api/spell/:textspell-corrected text and corrections, optional ?style=
GET /api/count/:textwords, characters, characters without spaces
GET /api/lorem/:countlorem ipsum, 1 to 1000 words, optional ?style=
GET /api/badge/:style/:textSVG badge, ?label= to override
GET /api/stylesstyle names and categories
+
+ +
+

Output formats

+

JSON by default. Choose another with a file extension on the path, a format query parameter, or an Accept header. The extension wins when more than one is given. Errors come back in the same format with a matching status.

+ + + + + + + + + +
FormatExtensionAccept
JSON.jsonapplication/json
Plain text.txttext/plain
HTML.htmltext/html
XML.xmlapplication/xml
YAML.yaml or .ymltext/yaml
CSV.csvtext/csv
JSONP.jsonp with ?callback=application/javascript
+

Use ?text= instead of the path when the text contains slashes or ends in an extension. Add ?pretty=1 for indented JSON.

+
+ +
+

Limits and caching

+ + + + + + + +
WhatLimit
Text2,000 characters
Spell check500 characters, 50 corrections per request
Batch100 texts
Lorem1,000 words
Badge label100 characters
+

Deterministic responses carry Cache-Control: public, max-age=86400. Anything involving the random style or a POST body is no-store.

+
+ +
+

What ships on npm

+ + + + + + +
FilePurpose
index.jsThe whole library, browser-safe
index.d.tsTypeScript declarations
cli.jsCLI logic as a testable main(argv, io)
bin/capstring.jsThe npx capstring executable
+

Plus package.json, README.md, and LICENSE. Zero dependencies, sideEffects: false so bundlers can tree-shake it. Release notes live in the changelog.

+
+
+ +
+
+ + + + + - - + diff --git a/web/dev/styles-data.js b/web/docs/styles-data.js similarity index 100% rename from web/dev/styles-data.js rename to web/docs/styles-data.js diff --git a/web/index.html b/web/index.html index 09dd733..1e68268 100644 --- a/web/index.html +++ b/web/index.html @@ -50,7 +50,7 @@

HTTP API

-

Free, no key, CORS enabled, cacheable. Base URL https://capstring.netlify.app/api. Full reference with try-it-out at /docs (OpenAPI 3.1, openapi.json).

+

Free, no key, CORS enabled, cacheable. Base URL https://capstring.netlify.app/api. Full reference with try-it-out at /api (OpenAPI 3.1, openapi.json). Library and CLI docs at /docs.

diff --git a/web/site.js b/web/site.js index 61931d3..2a25fb4 100644 --- a/web/site.js +++ b/web/site.js @@ -7,8 +7,8 @@ (() => { const NAV = [ { href: '/', label: 'Playground' }, - { href: '/dev/', label: 'Developer Docs' }, - { href: '/docs/', label: 'API Reference' } + { href: '/docs/', label: 'Developer Docs' }, + { href: '/api', label: 'API Reference' } ]; const ICONS = { github: '', @@ -28,7 +28,7 @@ cApStRiNg`; diff --git a/web/swagger-theme.css b/web/swagger-theme.css new file mode 100644 index 0000000..db7e8a6 --- /dev/null +++ b/web/swagger-theme.css @@ -0,0 +1,90 @@ +/* Swagger UI overrides for the API Reference, served by the API function at /api */ +body { margin: 0; } +.topbar { display: none; } +.swagger-ui { font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; } +.swagger-ui .info .title small pre { background: var(--accent); } +.swagger-ui .btn.authorize, .swagger-ui .btn.execute { background: var(--accent); border-color: var(--accent); color: #fff; } + +/* ---- dark theme: Swagger UI ships light only, so recolor every text surface ---- */ +[data-theme="dark"] .swagger-ui, +[data-theme="dark"] .swagger-ui .info .title, +[data-theme="dark"] .swagger-ui .info p, [data-theme="dark"] .swagger-ui .info li, [data-theme="dark"] .swagger-ui .info table, +[data-theme="dark"] .swagger-ui .opblock-tag, [data-theme="dark"] .swagger-ui .opblock-tag small, +[data-theme="dark"] .swagger-ui .opblock .opblock-summary-path, [data-theme="dark"] .swagger-ui .opblock .opblock-summary-path__deprecated, +[data-theme="dark"] .swagger-ui .opblock .opblock-summary-description, [data-theme="dark"] .swagger-ui .opblock .opblock-summary-operation-id, +[data-theme="dark"] .swagger-ui .opblock-description-wrapper p, [data-theme="dark"] .swagger-ui .opblock-external-docs-wrapper p, [data-theme="dark"] .swagger-ui .opblock-title_normal p, +[data-theme="dark"] .swagger-ui .opblock-section-header h4, [data-theme="dark"] .swagger-ui .opblock-section-header > label, +[data-theme="dark"] .swagger-ui table thead tr th, [data-theme="dark"] .swagger-ui table thead tr td, [data-theme="dark"] .swagger-ui table tbody tr td, +[data-theme="dark"] .swagger-ui .parameter__name, [data-theme="dark"] .swagger-ui .parameter__type, [data-theme="dark"] .swagger-ui .parameter__in, [data-theme="dark"] .swagger-ui .parameter__deprecated, +[data-theme="dark"] .swagger-ui .parameters-col_description p, [data-theme="dark"] .swagger-ui .parameter__extension, +[data-theme="dark"] .swagger-ui .response-col_status, [data-theme="dark"] .swagger-ui .response-col_description, [data-theme="dark"] .swagger-ui .response-col_links, +[data-theme="dark"] .swagger-ui .responses-inner h4, [data-theme="dark"] .swagger-ui .responses-inner h5, [data-theme="dark"] .swagger-ui .response-control-media-type__title, +[data-theme="dark"] .swagger-ui .model-title, [data-theme="dark"] .swagger-ui .model, [data-theme="dark"] .swagger-ui .model-toggle, [data-theme="dark"] .swagger-ui .prop-type, [data-theme="dark"] .swagger-ui .prop-format, +[data-theme="dark"] .swagger-ui section.models h4, [data-theme="dark"] .swagger-ui section.models h5, [data-theme="dark"] .swagger-ui .models-control, +[data-theme="dark"] .swagger-ui .tab li, [data-theme="dark"] .swagger-ui label, [data-theme="dark"] .swagger-ui .servers > label, +[data-theme="dark"] .swagger-ui .markdown p, [data-theme="dark"] .swagger-ui .markdown li, [data-theme="dark"] .swagger-ui .markdown code, +[data-theme="dark"] .swagger-ui .renderedMarkdown p, [data-theme="dark"] .swagger-ui .renderedMarkdown li, [data-theme="dark"] .swagger-ui .renderedMarkdown code, +[data-theme="dark"] .swagger-ui .btn, [data-theme="dark"] .swagger-ui select, [data-theme="dark"] .swagger-ui .loading-container .loading::after, +[data-theme="dark"] .swagger-ui .scheme-container .schemes > label, [data-theme="dark"] .swagger-ui .info .base-url, +[data-theme="dark"] .swagger-ui .filter .operation-filter-input, [data-theme="dark"] .swagger-ui .download-url-wrapper .select-label, +[data-theme="dark"] .swagger-ui .microlight, [data-theme="dark"] .swagger-ui .model .property, [data-theme="dark"] .swagger-ui .model .property.primitive { color: #e6e6e6; } + +[data-theme="dark"] .swagger-ui .info a, [data-theme="dark"] .swagger-ui .markdown a, [data-theme="dark"] .swagger-ui .renderedMarkdown a, [data-theme="dark"] .swagger-ui .info .base-url { color: var(--accent); } +[data-theme="dark"] .swagger-ui .markdown code, [data-theme="dark"] .swagger-ui .renderedMarkdown code, [data-theme="dark"] .swagger-ui .info .title small pre { background: #2a2d35; } +[data-theme="dark"] .swagger-ui .opblock-section-header, [data-theme="dark"] .swagger-ui .scheme-container, [data-theme="dark"] .swagger-ui section.models, [data-theme="dark"] .swagger-ui section.models .model-container, [data-theme="dark"] .swagger-ui .model-box { background: #1b1e24; } +[data-theme="dark"] .swagger-ui .opblock .opblock-section-header { box-shadow: none; border-bottom: 1px solid #2c3038; } +[data-theme="dark"] .swagger-ui input[type=text], [data-theme="dark"] .swagger-ui input[type=search], [data-theme="dark"] .swagger-ui textarea, [data-theme="dark"] .swagger-ui select, +[data-theme="dark"] .swagger-ui .filter .operation-filter-input { background: #1b1e24; color: #e6e6e6; border-color: #2c3038; } +[data-theme="dark"] .swagger-ui .parameter__name.required::after, [data-theme="dark"] .swagger-ui .parameter__name.required span { color: #f87171; } +[data-theme="dark"] .swagger-ui .highlight-code > .microlight, [data-theme="dark"] .swagger-ui .opblock-body pre.microlight, [data-theme="dark"] .swagger-ui .responses-inner pre, [data-theme="dark"] .swagger-ui .model-example pre { background: #0f1115 !important; color: #e6e6e6; } +[data-theme="dark"] .swagger-ui .copy-to-clipboard { background: #2a2d35; } +[data-theme="dark"] .swagger-ui .copy-to-clipboard button { background-color: #e6e6e6; } +[data-theme="dark"] .swagger-ui .opblock-tag { border-bottom-color: #2c3038; } +[data-theme="dark"] .swagger-ui .opblock .opblock-summary-method { color: #fff; } +[data-theme="dark"] .swagger-ui .response-control-media-type--accept-controller select, [data-theme="dark"] .swagger-ui .content-type { color: #e6e6e6; } +[data-theme="dark"] .swagger-ui table.model tr.description td, [data-theme="dark"] .swagger-ui .model .renderedMarkdown p { color: #b4b4b4; } +[data-theme="dark"] .swagger-ui svg.arrow, [data-theme="dark"] .swagger-ui .expand-operation svg, [data-theme="dark"] .swagger-ui .model-toggle::after { fill: #e6e6e6; filter: invert(1); } +[data-theme="dark"] .swagger-ui .model-toggle::after { filter: invert(1); } +[data-theme="dark"] .swagger-ui .opblock.opblock-get .opblock-summary { border-color: #3b82f6; } +[data-theme="dark"] .swagger-ui .opblock.opblock-post .opblock-summary { border-color: #22c55e; } + +/* ---- Schemas section: Swagger 5 renders JSON Schema 2020-12 with its own classes ---- */ +[data-theme="dark"] .swagger-ui .json-schema-2020-12__title, +[data-theme="dark"] .swagger-ui .json-schema-2020-12-keyword__name, +[data-theme="dark"] .swagger-ui .json-schema-2020-12-keyword__name--primary, +[data-theme="dark"] .swagger-ui .json-schema-2020-12-keyword__name--secondary, +[data-theme="dark"] .swagger-ui .json-schema-2020-12-keyword__value, +[data-theme="dark"] .swagger-ui .json-schema-2020-12-keyword__value--primary, +[data-theme="dark"] .swagger-ui .json-schema-2020-12-keyword__value--secondary, +[data-theme="dark"] .swagger-ui .json-schema-2020-12-keyword__value--const, +[data-theme="dark"] .swagger-ui .json-schema-2020-12-keyword__value--warning, +[data-theme="dark"] .swagger-ui .json-schema-2020-12__attribute, +[data-theme="dark"] .swagger-ui .json-schema-2020-12__attribute--primary, +[data-theme="dark"] .swagger-ui .json-schema-2020-12__attribute--muted, +[data-theme="dark"] .swagger-ui .json-schema-2020-12-property, +[data-theme="dark"] .swagger-ui .json-schema-2020-12-property .json-schema-2020-12__title, +[data-theme="dark"] .swagger-ui .json-schema-2020-12-core-keyword__name, +[data-theme="dark"] .swagger-ui .json-schema-2020-12-core-keyword__value, +[data-theme="dark"] .swagger-ui .json-schema-2020-12-keyword--description, +[data-theme="dark"] .swagger-ui .json-schema-2020-12-keyword--description p, +[data-theme="dark"] .swagger-ui .json-schema-2020-12 .json-schema-2020-12-keyword__children, +[data-theme="dark"] .swagger-ui .json-schema-2020-12-accordion, +[data-theme="dark"] .swagger-ui .json-schema-2020-12-accordion__children, +[data-theme="dark"] .swagger-ui .json-schema-2020-12-expand-deep-button, +[data-theme="dark"] .swagger-ui .model-box .model-title__text { color: #e6e6e6; } +[data-theme="dark"] .swagger-ui .json-schema-2020-12__attribute--primary { color: #a78bfa; } +[data-theme="dark"] .swagger-ui .json-schema-2020-12-keyword__value--const, +[data-theme="dark"] .swagger-ui .json-schema-2020-12-keyword__value--secondary { color: #2dd4bf; } +[data-theme="dark"] .swagger-ui .json-schema-2020-12-accordion, +[data-theme="dark"] .swagger-ui .json-schema-2020-12-expand-deep-button, +[data-theme="dark"] .swagger-ui .json-schema-2020-12-accordion__icon { background: transparent; border: 0; } +[data-theme="dark"] .swagger-ui .json-schema-2020-12-expand-deep-button { background: #2a2d35; border-radius: 4px; padding: 2px 8px; } +[data-theme="dark"] .swagger-ui .json-schema-2020-12-accordion__icon--expanded svg, +[data-theme="dark"] .swagger-ui .json-schema-2020-12-accordion__icon--collapsed svg, +[data-theme="dark"] .swagger-ui .json-schema-2020-12-accordion svg { fill: #e6e6e6; } +[data-theme="dark"] .swagger-ui .models .model-container, [data-theme="dark"] .swagger-ui .model-box, +[data-theme="dark"] .swagger-ui .json-schema-2020-12 { background: transparent; } +[data-theme="dark"] .swagger-ui .models .model-container:hover { background: #1b1e24; } +[data-theme="dark"] .swagger-ui section.models .model-container { background: #171a20; border-bottom: 1px solid #2c3038; } +[data-theme="dark"] .swagger-ui section.models h4 span, [data-theme="dark"] .swagger-ui section.models h4 svg { fill: #e6e6e6; color: #e6e6e6; } +[data-theme="dark"] .swagger-ui .json-schema-2020-12-keyword__name--primary::after { color: #f87171; } From 214de0c3803b1849dd0d3aaf1238c84fde467177 Mon Sep 17 00:00:00 2001 From: Brian Funk Date: Wed, 7 Oct 2026 21:40:58 -0400 Subject: [PATCH 28/36] feat: no hard-coded host on the site; inline Node/CLI samples in Developer Docs Page text, Swagger's servers list, and the API's docs link all derive from the origin serving the request, so localhost, deploy previews, Netlify, and a future custom domain all show live URLs. --- eslint.config.js | 1 + netlify/functions/api.js | 4 +- netlify/functions/swagger-page.js | 7 +- test/api.test.js | 2 +- test/openapi.test.js | 2 +- web/docs/docs.js | 251 ++++++++++++------------------ web/site.js | 11 ++ 7 files changed, 120 insertions(+), 158 deletions(-) diff --git a/eslint.config.js b/eslint.config.js index 624be4d..240fe63 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -37,6 +37,7 @@ export default [ fetch: 'readonly', localStorage: 'readonly', addEventListener: 'readonly', + NodeFilter: 'readonly', matchMedia: 'readonly', setTimeout: 'readonly', clearTimeout: 'readonly' diff --git a/netlify/functions/api.js b/netlify/functions/api.js index b7c8e1f..bda3aa2 100644 --- a/netlify/functions/api.js +++ b/netlify/functions/api.js @@ -32,7 +32,6 @@ import { SWAGGER_PAGE } from './swagger-page.js'; export const config = { path: ['/api', '/api/*'] }; -const SITE = 'https://capstring.netlify.app'; const MAX_TEXT = 2000; const MAX_BATCH = 100; const MAX_LOREM = 1000; @@ -487,6 +486,7 @@ const spellCheck = async (text) => { */ const route = async (req, format, query, segments) => { const [head, ...rest] = segments; + const origin = new URL(req.url).origin; const send = (payload, opts = {}) => respond(payload, { format, query, ...opts }); if (head === undefined) { @@ -495,7 +495,7 @@ const route = async (req, format, query, segments) => { // A browser at /api gets the interactive reference; curl and fetch get the JSON below return new Response(SWAGGER_PAGE, { headers: { ...CORS, 'Content-Type': CONTENT_TYPES.html, 'X-Content-Type-Options': 'nosniff', 'Cache-Control': 'public, max-age=3600', 'Vary': 'Accept' } }); } - const data = { name: pkg.name, version: pkg.version, docs: `${SITE}/api`, formats: FORMATS, endpoints: ENDPOINTS }; + const data = { name: pkg.name, version: pkg.version, docs: `${origin}/api`, formats: FORMATS, endpoints: ENDPOINTS }; return send({ data, text: `${pkg.name} ${pkg.version}\n${ENDPOINTS.join('\n')}`, title: 'capstring API' }); } diff --git a/netlify/functions/swagger-page.js b/netlify/functions/swagger-page.js index 36de9c8..5c48379 100644 --- a/netlify/functions/swagger-page.js +++ b/netlify/functions/swagger-page.js @@ -25,9 +25,12 @@ export const SWAGGER_PAGE = ` capstring API Reference - - + + diff --git a/web/404.html b/web/404.html index daec7b1..a9a6b2b 100644 --- a/web/404.html +++ b/web/404.html @@ -7,8 +7,8 @@ 404 · capstring - - + + diff --git a/web/docs/index.html b/web/docs/index.html index 82d9300..29a770e 100644 --- a/web/docs/index.html +++ b/web/docs/index.html @@ -7,8 +7,8 @@ capstring Developer Docs - - + + diff --git a/web/index.html b/web/index.html index 2ff0b5c..9d79733 100644 --- a/web/index.html +++ b/web/index.html @@ -16,8 +16,8 @@ - - + + From b540597e71d787b2e06ed50597f81b443eb77953 Mon Sep 17 00:00:00 2001 From: Brian Funk Date: Wed, 7 Oct 2026 22:13:56 -0400 Subject: [PATCH 36/36] fix: address final Codex review - badge values are bounded before rendering, like the image formats - batch has a total budget of 100,000 characters across all texts - JSONP callbacks cannot start with a JavaScript reserved word - count() measures grapheme clusters, so an emoji or flag counts once - playground: badge URL honours the png format; the curl line quotes the URL --- README.md | 2 +- index.d.ts | 6 +++--- index.js | 11 ++++++----- netlify/functions/api.js | 11 +++++++++-- test/api.test.js | 15 +++++++++++++-- test/index.test.js | 4 +++- web/app.js | 11 ++++++----- web/docs/index.html | 2 +- web/openapi.json | 12 +++++++++++- 9 files changed, 53 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 04f9c51..96f8ba4 100644 --- a/README.md +++ b/README.md @@ -228,7 +228,7 @@ curl -X POST https://capstring.netlify.app/api/batch \ |----------|---------| | `GET /api/:style/:text` | `{ input, style, output }` | | `GET /api/all/:text` | every style | -| `POST /api/batch` | `{ style, texts[] }` (max 1,000 texts) | +| `POST /api/batch` | `{ style, texts[] }` (max 1,000 texts, 100,000 characters in total) | | `GET /api/spell/:text` | spell-corrected text plus a `corrections` list, optional `?style=` | | `GET /api/count/:text` | words, characters, characters without spaces | | `GET /api/lorem/:count` | lorem ipsum words (1 to 1000), optional `?style=` | diff --git a/index.d.ts b/index.d.ts index c30c93d..50ff82b 100644 --- a/index.d.ts +++ b/index.d.ts @@ -52,11 +52,11 @@ export function capstringAll(str: unknown, options?: CapstringOptions): Record { /** * @typedef {Object} TextCount * @property {number} words - Runs of non-whitespace - * @property {number} characters - Unicode code points (an emoji counts once) - * @property {number} charactersNoSpaces - Code points that are not whitespace - * @property {number} spaces - Whitespace code points + * @property {number} characters - Grapheme clusters (an emoji, flag, or accented letter counts once) + * @property {number} charactersNoSpaces - Grapheme clusters that are not whitespace + * @property {number} spaces - Whitespace grapheme clusters (a CRLF pair counts once) */ /** @@ -624,8 +624,9 @@ const capstringAll = (str, options = {}) => { */ const count = (str) => { if (typeof str !== 'string') return false; - const characters = chars(str).length; - const spaces = chars(str).filter((ch) => /\s/u.test(ch)).length; + const clusters = graphemes(str); + const characters = clusters.length; + const spaces = clusters.filter((g) => /^\s+$/u.test(g)).length; return { words: str.trim().split(/\s+/u).filter(Boolean).length, characters, diff --git a/netlify/functions/api.js b/netlify/functions/api.js index b6ad0c2..0306406 100644 --- a/netlify/functions/api.js +++ b/netlify/functions/api.js @@ -34,6 +34,7 @@ export const config = { path: ['/api', '/api/*'] }; const MAX_TEXT = 10000; const MAX_BATCH = 1000; +const MAX_BATCH_CHARS = 100000; // total input across a batch, so 1,000 near-max items cannot be sent at once const MAX_LOREM = 1000; const DEFAULT_LOREM = 50; const MAX_LABEL = 100; @@ -58,6 +59,9 @@ const ENDPOINTS = [ 'GET /api/badge/:style/:text' ]; +/** JavaScript reserved words that cannot start a JSONP callback expression */ +const JS_RESERVED = new Set(['await', 'break', 'case', 'catch', 'class', 'const', 'continue', 'debugger', 'default', 'delete', 'do', 'else', 'enum', 'export', 'extends', 'false', 'finally', 'for', 'function', 'if', 'import', 'in', 'instanceof', 'let', 'new', 'null', 'return', 'static', 'super', 'switch', 'this', 'throw', 'true', 'try', 'typeof', 'var', 'void', 'while', 'with', 'yield', 'implements', 'interface', 'package', 'private', 'protected', 'public']); + const CORS = { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', @@ -359,7 +363,7 @@ const respond = async (payload, { format, query, status = 200, cache = true, hea case 'png': body = await textPng(payload.headline ?? payload.text); break; case 'jsonp': { const callback = query.get('callback') || 'callback'; - if (callback.length > 64 || !/^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*$/.test(callback)) { + if (callback.length > 64 || !/^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*$/.test(callback) || JS_RESERVED.has(callback.split('.')[0])) { throw new ApiError(400, 'invalid_callback', 'callback must be a JavaScript identifier, optionally dotted (e.g. cb or app.handle).'); } body = `/**/ typeof ${callback} === 'function' && ${callback}(${json});`; @@ -618,6 +622,8 @@ const route = async (req, format, query, segments) => { const texts = body?.texts ?? body?.inputs; if (!Array.isArray(texts) || texts.length === 0) throw new ApiError(400, 'missing_texts', '"texts" must be a non-empty array of strings.'); if (texts.length > MAX_BATCH) throw new ApiError(400, 'batch_too_large', `Send at most ${MAX_BATCH} texts per request.`); + const totalChars = texts.reduce((n, t) => n + (typeof t === 'string' ? t.length : 0), 0); + if (totalChars > MAX_BATCH_CHARS) throw new ApiError(413, 'batch_too_large', `A batch may contain at most ${MAX_BATCH_CHARS} characters in total.`); const results = texts.map((input) => { if (typeof input !== 'string') return { input, output: null, error: 'not_a_string' }; if (input.length > MAX_TEXT) return { input: `${input.slice(0, 50)}...`, output: null, error: 'text_too_long' }; @@ -670,7 +676,8 @@ const route = async (req, format, query, segments) => { if (format !== 'json' && format !== 'svg' && format !== 'png') { throw new ApiError(400, 'invalid_format', 'Badges are images: use .svg (default) or .png.'); } - const svg = badgeSvg(label, capstring(text, style)); + const value = Array.from(capstring(text, style)); + const svg = badgeSvg(label, value.length > MAX_IMAGE_CHARS ? `${value.slice(0, MAX_IMAGE_CHARS).join('')}…` : value.join('')); const body = format === 'png' ? await rasterize(svg) : svg; return new Response(body, { headers: { diff --git a/test/api.test.js b/test/api.test.js index 967dcba..858b380 100644 --- a/test/api.test.js +++ b/test/api.test.js @@ -206,7 +206,7 @@ describe('output formats', () => { const { res, body } = await json('/api/upper/hello.jsonp?callback=alert(1)'); expect(res.status).toBe(400); expect(body.error.code).toBe('invalid_callback'); - for (const bad of ['.', '1foo', 'foo..bar', 'foo.', 'a'.repeat(65)]) { + for (const bad of ['.', '1foo', 'foo..bar', 'foo.', 'a'.repeat(65), 'class', 'new', 'delete.x']) { expect((await call(`/api/upper/hello.jsonp?callback=${bad}`)).status, bad).toBe(400); } expect(await text('/api/upper/hello.jsonp?callback=app.handlers.$done')).toContain('app.handlers.$done('); @@ -389,6 +389,10 @@ describe('POST /api/batch', () => { expect((await (await post('/api/batch', { texts: ['a'] })).json()).error.code).toBe('unknown_style'); expect((await (await post('/api/batch', null)).json()).error.code).toBe('unknown_style'); expect((await (await post('/api/batch', { style: 'upper', texts: Array(1001).fill('a') })).json()).error.code).toBe('batch_too_large'); + const big = await post('/api/batch', { style: 'upper', texts: Array(11).fill('a'.repeat(10000)) }); + expect(big.status).toBe(413); + expect((await big.json()).error.code).toBe('batch_too_large'); + expect((await post('/api/batch', { style: 'upper', texts: Array(10).fill('a'.repeat(10000)) })).status).toBe(200); }); it('rejects invalid JSON and wrong content type', async () => { @@ -411,11 +415,12 @@ describe('POST /api/batch', () => { }); describe('GET /api/count/:text', () => { - it('counts words and characters (code points, not UTF-16 units)', async () => { + it('counts words and characters (grapheme clusters, not code units)', async () => { const { res, body } = await json('/api/count/hello%20big%20world'); expect(res.status).toBe(200); expect(body).toEqual({ input: 'hello big world', words: 3, characters: 15, charactersNoSpaces: 13, spaces: 2 }); expect((await json('/api/count/%F0%9F%98%80%20a')).body).toEqual({ input: '😀 a', words: 2, characters: 3, charactersNoSpaces: 2, spaces: 1 }); + expect((await json('/api/count/%F0%9F%91%A8%E2%80%8D%F0%9F%91%A9%E2%80%8D%F0%9F%91%A7')).body.characters).toBe(1); // ZWJ family is one character expect((await json('/api/count/%20%20spaced%20%20')).body.words).toBe(1); }); @@ -569,6 +574,12 @@ describe('GET /api/badge/:style/:text', () => { expect(await text('/api/badge/same?text=readme.txt')).toContain('>readme.txt<'); // ?text= keeps the extension }); + it('bounds the badge value before rendering', async () => { + const svg = await text(`/api/badge/binary?text=${'a'.repeat(500)}`); + expect(Number(/width="(\d+)"/.exec(svg)[1])).toBeLessThan(3000); + expect(svg).toContain('…<'); + }); + it('limits label length', async () => { expect((await call(`/api/badge/upper/hi?label=${'x'.repeat(100)}`)).status).toBe(200); const { res, body } = await json(`/api/badge/upper/hi?label=${'x'.repeat(101)}`); diff --git a/test/index.test.js b/test/index.test.js index cf3c355..2a379e8 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -436,10 +436,12 @@ describe('capstring', () => { }); describe('count', () => { - it('counts words, code points, and spaces', () => { + it('counts words, grapheme clusters, and spaces', () => { expect(count('hello world')).toEqual({ words: 2, characters: 11, charactersNoSpaces: 10, spaces: 1 }); expect(count(' hello world ')).toEqual({ words: 2, characters: 17, charactersNoSpaces: 10, spaces: 7 }); expect(count('😀 a\tb')).toEqual({ words: 3, characters: 5, charactersNoSpaces: 3, spaces: 2 }); + expect(count('👨‍👩‍👧 🇺🇸 1️⃣')).toEqual({ words: 3, characters: 5, charactersNoSpaces: 3, spaces: 2 }); // clusters count once + expect(count('a\r\nb')).toEqual({ words: 2, characters: 3, charactersNoSpaces: 2, spaces: 1 }); // CRLF is one cluster expect(count('')).toEqual({ words: 0, characters: 0, charactersNoSpaces: 0, spaces: 0 }); expect(count(42)).toBe(false); }); diff --git a/web/app.js b/web/app.js index 503af38..1b43205 100644 --- a/web/app.js +++ b/web/app.js @@ -64,7 +64,7 @@ const needsQuery = (text) => /\.(json|jsonp|txt|html|xml|yaml|yml|csv|svg|png)$/ /** The API URL for the selected row (or all styles) in the chosen format */ const apiUrl = (text) => { const format = formatSelect.value; - if (selectedStyle === 'badge') return badgeUrl(text); + if (selectedStyle === 'badge') return badgeUrl(text, format === 'png' ? 'png' : ''); const route = selectedStyle ?? 'all'; const ext = format === 'json' ? '' : `.${format}`; const params = []; @@ -77,7 +77,7 @@ const apiUrl = (text) => { /** Refresh the curl line and its clickable link */ const renderCurl = () => { const url = apiUrl(currentText()); - curl.textContent = `curl ${url}`; + curl.textContent = `curl '${url}'`; // quoted so & and ? survive the shell curlLink.href = url; }; @@ -88,11 +88,12 @@ const countText = (text) => { }; /** Badge URL for the current text, using the selected style (or title); ?text= when the path would misread it */ -const badgeUrl = (text) => { +const badgeUrl = (text, ext = '') => { const style = STYLES.includes(selectedStyle) ? selectedStyle : 'title'; + const dot = ext ? `.${ext}` : ''; return needsQuery(text) - ? `${API}/badge/${style}?text=${shellSafeEncode(text)}&label=capstring` - : `${API}/badge/${style}/${shellSafeEncode(text)}?label=capstring`; + ? `${API}/badge/${style}${dot}?text=${shellSafeEncode(text)}&label=capstring` + : `${API}/badge/${style}/${shellSafeEncode(text)}${dot}?label=capstring`; }; /** Ask the API to spell-correct the text; stale responses are dropped */ diff --git a/web/docs/index.html b/web/docs/index.html index 29a770e..d8927a2 100644 --- a/web/docs/index.html +++ b/web/docs/index.html @@ -160,7 +160,7 @@

Limits and caching

- +
EndpointReturns
WhatLimit
Text10,000 characters (GET URLs are capped at about 8 KB by the host; use POST /api/batch for longer text)
Spell check500 characters, 50 corrections per request
Batch1,000 texts
Batch1,000 texts, 100,000 characters in total
Lorem1,000 words
Badge label100 characters
diff --git a/web/openapi.json b/web/openapi.json index af49526..8366a66 100644 --- a/web/openapi.json +++ b/web/openapi.json @@ -412,6 +412,16 @@ } } } + }, + "413": { + "description": "The batch's texts total more than 100,000 characters (`batch_too_large`)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } } } } @@ -530,7 +540,7 @@ ], "responses": { "200": { - "description": "Counts (characters are Unicode code points, so an emoji counts once)", + "description": "Counts (characters are grapheme clusters, so an emoji, a flag, or an accented letter counts once)", "content": { "application/json": { "schema": {