diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a278bc3..0d7adaf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,14 +4,13 @@ on: push: branches: [main, master, dev] pull_request: - branches: [main, master, dev] jobs: test: runs-on: ubuntu-latest strategy: matrix: - node-version: [18, 20, 22] + node-version: [22, 24] steps: - uses: actions/checkout@v4 @@ -28,17 +27,21 @@ jobs: - name: Run linter run: npm run lint - - name: Run tests - if: matrix.node-version == 18 - run: npm test - - name: Run tests with coverage - 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 + if: matrix.node-version == 22 with: token: ${{ secrets.CODECOV_TOKEN }} fail_ci_if_error: false diff --git a/.gitignore b/.gitignore index aa6f0f0..6629f3a 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,7 @@ jspm_packages # Optional REPL history .node_repl_history + +# Netlify +.netlify +web/capstring.js 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..7449b5a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,57 +2,72 @@ ## 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, many 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('hello world', 'slug'); // 'hello-world' +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 style names +CATEGORIES; // frozen { case, code, fun, encoding, art } ``` -## All 24 Styles +## All 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, capitalize, lowerfirst | +| code | camel, pascal, snake, kebab, slug, constant, python, dot, path, train, hashtag, acronym, ada, cobol, initials | +| fun | reverse, sponge, mock, alternate, crazy, random, clap, piglatin, spaced, squish, nato | +| encoding | leet, rot13, morse, binary, hex, base64 | +| art | flip, smallcaps, bubble, wide, strike, bold, italic, script | + +## 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`. + +## Website and API + +- `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 ## 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..5ac1c1c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,49 @@ 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 + +- **Website**: [capstring.netlify.app](https://capstring.netlify.app) - playground with every style live, click to copy, shareable URLs, light/dark theme, output format picker, live badge +- **Developer Docs** at `/docs/`: one Stripe-style page with every style explained and example outputs computed live, Node and CLI samples inline +- **API Reference** at `/api` (for browsers): Swagger UI over a hand-written OpenAPI 3.1 document (`/openapi.json`) +- **HTTP API** at `/api`: `/:style/:text`, `/all`, `/styles`, `POST /batch`, `/spell`, `/count`, `/lorem`, `/badge`, as a single Netlify Function. Output formats json (default), txt, html, xml, yaml, csv, jsonp, svg, png, chosen by extension, `?format=`, or `Accept` header. Limits: 10,000 characters, 1,000 batch texts +- `count(str)` in the library (words, characters, charactersNoSpaces, spaces) and `--count` in the CLI +- **21 new styles**: `smallcaps`, `bubble`, `wide`, `strike`, `clap`, `morse`, `binary`, `piglatin`, `capitalize`, `lowerfirst`, `ada`, `cobol`, `initials`, `spaced`, `squish`, `nato`, `hex`, `base64`, `bold`, `italic`, `script` +- **CLI**: `npx capstring '); + expect(await text('/api/styles.xml')).toContain('\n same'); // category arrays use , never + expect(await text('/api/styles.yaml')).toContain('styles:\n - "same"'); + 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 () => { + const { res, body } = await json('/api/styles/extra'); + expect(res.status).toBe(404); + expect(body.error.code).toBe('not_found'); + }); +}); + +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('images keep backslashes and collapse real line breaks', async () => { + expect(await text('/api/same/C%3A%5Cnew.svg')).toContain('>C:\\new'); + expect(await text('/api/same/a%0Ab%E2%80%A8c.svg')).toContain('>a b c'); + }); + + it('images cut long output at 200 characters so the canvas stays bounded', async () => { + const svg = await text(`/api/binary/${'a'.repeat(100)}.svg`); // 100 bytes -> 899 chars of binary + expect(svg).toMatch(/width="\d+"/); + expect(Number(/width="(\d+)"/.exec(svg)[1])).toBeLessThan(3000); + expect(svg).toContain('…'); + const all = await text('/api/all/hello%20world.svg'); // the whole table would be thousands of chars + expect(Number(/width="(\d+)"/.exec(all)[1])).toBeLessThan(3000); + expect((await call(`/api/binary/${'a'.repeat(100)}.png`)).status).toBe(200); + }); + + it('svg draws fullwidth text as spaced ASCII so it renders without CJK fonts', async () => { + const svg = await text('/api/wide/hi.svg'); + expect(svg).toContain('letter-spacing="0.6em"'); + expect(svg).toContain('>hi'); + }); + + it('svg renders the result as an image', async () => { + const res = await call('/api/sponge/hello%20world.svg'); + expect(res.headers.get('Content-Type')).toBe('image/svg+xml; charset=utf-8'); + const svg = await res.text(); + expect(svg).toMatch(/^HeLlO WoRlD'); + expect(await text('/api/same/a%3Cb.svg')).toContain('a<b'); + expect(await text('/api/same/a%0Ab.svg')).toContain('>a b'); + expect((await call('/api/upper/hi', { headers: { Accept: 'image/svg+xml' } })).headers.get('Content-Type')).toBe('image/svg+xml; charset=utf-8'); + expect(await text('/api/none/hi.svg')).toContain('> '); + expect(await text('/api/count/hi.svg')).toContain('words: 1'); + expect(await text('/api/nope/hi.svg')).toContain('unknown_style'); + }); + + it('png renders the result as an image', async () => { + const res = await call('/api/bold/hello%20world.png'); + expect(res.status).toBe(200); + expect(res.headers.get('Content-Type')).toBe('image/png'); + const bytes = new Uint8Array(await res.arrayBuffer()); + expect([...bytes.slice(0, 8)]).toEqual([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); // PNG signature + expect(bytes.length).toBeGreaterThan(500); + expect((await call('/api/upper/hi', { headers: { Accept: 'image/png' } })).headers.get('Content-Type')).toBe('image/png'); + expect((await call('/api/nope/hi.png')).status).toBe(404); + }); + + 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('image/png'); + }); + + it('ignores prototype keys in the Accept header', async () => { + for (const evil of ['constructor', '__proto__', 'toString']) { + const res = await call('/api/upper/hi', { headers: { Accept: evil } }); + expect(res.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('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('rejects unsafe or malformed 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'); + 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('); + const err = await call('/api/nope/hello.jsonp'); + expect(err.headers.get('Content-Type')).toBe('application/json; charset=utf-8'); + }); + + 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('?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('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 () => { + expect((await json('/api/upper/a/b')).body.input).toBe('a/b'); + expect((await json('/api/upper/ignored?text=a%2Fb')).body.input).toBe('a/b'); + }); + + it('decodes unicode', async () => { + expect((await json('/api/slug/Cr%C3%A8me%20Br%C3%BBl%C3%A9e')).body.output).toBe('creme-brulee'); + }); + + it('marks random as uncacheable', async () => { + expect((await call('/api/random/hi')).headers.get('Cache-Control')).toBe('no-store'); + expect((await call('/api/upper/hi')).headers.get('Cache-Control')).toBe('public, max-age=86400'); + }); + + it('404s unknown style with the style list', async () => { + const { res, body } = await json('/api/nope/hello'); + expect(res.status).toBe(404); + expect(body.error.code).toBe('unknown_style'); + expect(body.error.styles).toEqual([...STYLES]); + expect(res.headers.get('Cache-Control')).toBe('no-store'); + }); + + it('400s missing text', async () => { + for (const path of ['/api/title', '/api/title/', '/api/title/?text=']) { + const { res, body } = await json(path); + 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 () => { + const { res, body } = await json(`/api/upper/${'a'.repeat(10001)}`); + expect(res.status).toBe(413); + expect(body.error.code).toBe('text_too_long'); + expect((await call(`/api/upper/${'a'.repeat(10000)}`)).status).toBe(200); + }); + + it('400s malformed percent-encoding', async () => { + const { res, body } = await json('/api/upper/%E0%A4%A'); + expect(res.status).toBe(400); + expect(body.error.code).toBe('bad_encoding'); + }); + + it('405s non-GET methods with Allow', async () => { + const res = await post('/api/upper/hi', {}); + expect(res.status).toBe(405); + expect(res.headers.get('Allow')).toBe('GET, OPTIONS'); + expect((await res.json()).error.code).toBe('method_not_allowed'); + }); +}); + +describe('GET /api/all/:text', () => { + it('returns every style', async () => { + const { res, body } = await json('/api/all/hello%20world'); + expect(res.status).toBe(200); + expect(body.input).toBe('hello world'); + expect(body.count).toBe(STYLES.length); + expect(Object.keys(body.results)).toEqual([...STYLES]); + expect(body.results.kebab).toBe('hello-world'); + expect(res.headers.get('Cache-Control')).toBe('no-store'); + }); + + 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 text('/api/all/a%E2%80%A8b.txt')).split('\n')[0]).toBe('same\ta\\u2028b'); + }); + + 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 () => { + expect((await call('/api/all')).status).toBe(400); + }); +}); + +describe('POST /api/batch', () => { + it('transforms many texts', async () => { + const res = await post('/api/batch', { style: 'kebab', texts: ['Hello World', 'XMLHttpRequest'] }); + expect(res.status).toBe(200); + expect(res.headers.get('Cache-Control')).toBe('no-store'); + expect(await res.json()).toEqual({ + style: 'kebab', + count: 2, + results: [ + { input: 'Hello World', output: 'hello-world' }, + { input: 'XMLHttpRequest', output: 'xml-http-request' } + ] + }); + }); + + 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'); + 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(10001)] }); + const body = await res.json(); + expect(body.results[1]).toEqual({ input: 42, output: null, error: 'not_a_string' }); + expect(body.results[2]).toEqual({ input: `${'a'.repeat(50)}...`, output: null, error: 'text_too_long' }); + }); + + it('validates body', async () => { + expect((await (await post('/api/batch', { style: 'upper' })).json()).error.code).toBe('missing_texts'); + expect((await (await post('/api/batch', { style: 'upper', texts: [] })).json()).error.code).toBe('missing_texts'); + expect((await (await post('/api/batch', { style: 'upper', texts: 'no' })).json()).error.code).toBe('missing_texts'); + expect((await (await post('/api/batch', { style: 'nope', texts: ['a'] })).json()).error.code).toBe('unknown_style'); + 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 () => { + const bad = await post('/api/batch', '{not json'); + expect(bad.status).toBe(400); + expect((await bad.json()).error.code).toBe('invalid_json'); + 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 () => { + const res = await call('/api/batch'); + expect(res.status).toBe(405); + expect(res.headers.get('Allow')).toBe('POST, OPTIONS'); + }); + + it('404s extra segments', async () => { + expect((await post('/api/batch/extra', {})).status).toBe(404); + }); +}); + +describe('GET /api/count/:text', () => { + 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); + }); + + it('supports txt, html, csv', async () => { + expect(await text('/api/count/hello%20world.txt')).toBe('words: 2, chars: 11, chars (no spaces): 10, spaces: 1'); + expect(await text('/api/count/hi.html')).toContain('words1'); + expect(await text('/api/count/hi.csv')).toBe('input,words,characters,charactersNoSpaces,spaces\n"hi","1","2","2","0"'); + }); + + it('validates text', async () => { + expect((await json('/api/count')).body.error.code).toBe('missing_text'); + expect((await call(`/api/count/${'a'.repeat(10001)}`)).status).toBe(413); + }); +}); + +describe('GET /api/lorem/:count', () => { + 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.endsWith('.')).toBe(true); + expect(res.headers.get('Cache-Control')).toBe('public, max-age=86400'); + }); + + 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'); + }); + + it('validates count, style and path', async () => { + for (const c of ['0', '1001', '2.5', 'abc', '-1', '0x10', '1e2', '%205%20', '+5']) { + expect((await json(`/api/lorem/${c}`)).body.error.code).toBe('invalid_count'); + } + expect((await json('/api/lorem/3?style=nope')).body.error.code).toBe('unknown_style'); + expect((await json('/api/lorem/3/extra')).body.error.code).toBe('not_found'); + }); +}); + +describe('GET /api/spell/:text', () => { + it('corrects misspellings and lists corrections', async () => { + const { res, body } = await json('/api/spell/helo%20speling'); + expect(res.status).toBe(200); + expect(body.input).toBe('helo speling'); + expect(body.output).toBe('hello spelling'); + expect(body.corrections).toEqual([{ from: 'helo', to: 'hello' }, { from: 'speling', to: 'spelling' }]); + expect(body.limited).toBe(false); + expect(body.style).toBeNull(); + expect(res.headers.get('Cache-Control')).toBe('public, max-age=86400'); + }); + + it('preserves capitalization, punctuation, and correct words', async () => { + const { body } = await json('/api/spell/Helo,%20world!%20Recieve%20ok%20NASA.'); + expect(body.output).toBe('Hello, world! Receive ok NASA.'); + expect(body.corrections).toEqual([{ from: 'Helo', to: 'Hello' }, { from: 'Recieve', to: 'Receive' }]); + }); + + it('leaves words with accents or digits alone instead of correcting fragments', async () => { + const { body } = await json('/api/spell/na%C3%AFve%20M%C3%BCnchen%20utf8%20caf%C3%A9%20helo'); + expect(body.output).toBe('naïve München utf8 café hello'); + expect(body.corrections).toEqual([{ from: 'helo', to: 'hello' }]); + }); + + it('keeps contractions as single tokens', async () => { + const { body } = await json("/api/spell/isn't%20it%20don't"); + expect(body.output).toBe("isn't it don't"); + expect(body.corrections).toEqual([]); + }); + + it('leaves unsuggestable words alone', async () => { + const { body } = await json('/api/spell/xqzjvwpk%20ok'); + expect(body.output).toBe('xqzjvwpk ok'); + expect(body.corrections).toEqual([]); + }); + + 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 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'); + }); + + it('counts unsuggestable words against the limit too', async () => { + 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([]); + expect(body.output.endsWith(' helo')).toBe(true); + }); + + it('stops suggesting after 50 misspellings and says so', async () => { + const words = Array.from({ length: 60 }, (_, i) => `helo${String.fromCharCode(97 + (i % 26))}`); + const { body } = await json(`/api/spell/${encodeURIComponent(words.join(' '))}`); + expect(body.corrections.length).toBeLessThanOrEqual(50); + expect(body.limited).toBe(true); + expect(body.output.split(' ')).toHaveLength(60); + }); + + it('validates text and enforces the tighter spell limit', async () => { + expect((await json('/api/spell')).body.error.code).toBe('missing_text'); + const { res, body } = await json(`/api/spell/${'a'.repeat(501)}`); + expect(res.status).toBe(413); + expect(body.error.code).toBe('text_too_long'); + expect((await call(`/api/spell/${'a'.repeat(500)}`)).status).toBe(200); + }); +}); + +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('serves png badges and rejects text formats', async () => { + const png = await call('/api/badge/title/hi.png'); + expect(png.status).toBe(200); + expect(png.headers.get('Content-Type')).toBe('image/png'); + expect((await call('/api/badge/title/hi.svg')).headers.get('Content-Type')).toBe('image/svg+xml; charset=utf-8'); + const res = await call('/api/badge/title/hi.txt'); + expect(res.status).toBe(400); + expect(await res.text()).toContain('invalid_format'); // rendered in the requested (txt) format + 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)}`); + 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 new file mode 100644 index 0000000..538ba8d --- /dev/null +++ b/test/browser-safe.test.js @@ -0,0 +1,34 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; + +const read = (path) => readFileSync(new URL(`../${path}`, import.meta.url), 'utf8'); + +describe('index.js stays browser-safe', () => { + const src = read('index.js'); + + it('has no Node-only imports or globals', () => { + expect(src).not.toMatch(/from\s+['"]node:/); + expect(src).not.toMatch(/\bprocess\./); + expect(src).not.toMatch(/\brequire\(/); + expect(src).not.toMatch(/import\.meta/); + }); + + it('is the only import of the web app', () => { + 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 style.

+
+
+

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

+
+
+ + + diff --git a/web/app.js b/web/app.js new file mode 100644 index 0000000..1b43205 --- /dev/null +++ b/web/app.js @@ -0,0 +1,199 @@ +import { capstringAll, count, STYLES, CATEGORIES } from './capstring.js'; + +const API = `${location.origin}/api`; +const LABELS = { case: 'Case', code: 'Code', fun: 'Fun', encoding: 'Encodings', art: 'Unicode Art', tools: 'Tools', api: 'API only' }; +/** Rows that are not styles: count comes from the library, spell from the HTTP API (it needs a dictionary) */ +const TOOLS = ['count']; +const API_ONLY = ['spell', 'badge']; +const DEFAULT_TEXT = 'hello world'; + +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 outputs = new Map(); +let selectedStyle = null; +let toastTimer; +let spellRequest = 0; + +/** Build one section per category with a copy row per style, plus the API extras */ +const buildRows = () => { + const groups = { ...CATEGORIES, tools: TOOLS, api: API_ONLY }; + for (const [category, styles] of Object.entries(groups)) { + const section = document.createElement('section'); + const h2 = document.createElement('h2'); + h2.textContent = LABELS[category] ?? category; + const ul = document.createElement('ul'); + for (const style of styles) { + const li = document.createElement('li'); + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'row'; + btn.dataset.style = style; + btn.setAttribute('aria-label', `Copy ${style} result`); + const name = document.createElement('code'); + name.className = 'name'; + name.textContent = style; + const out = document.createElement(style === 'badge' ? 'img' : 'span'); + out.className = 'out'; + if (style === 'badge') { + out.alt = 'SVG badge for the current text'; + out.height = 20; + } + btn.append(name, out); + li.append(btn); + ul.append(li); + outputs.set(style, out); + } + section.append(h2, ul); + results.append(section); + } +}; + +/** 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()}`); + +/** Text that would be misread in the path (a trailing format extension or a slash) goes in ?text= instead */ +const needsQuery = (text) => /\.(json|jsonp|txt|html|xml|yaml|yml|csv|svg|png)$/i.test(text) || text.includes('/'); + +/** 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, format === 'png' ? 'png' : ''); + const route = selectedStyle ?? 'all'; + const ext = format === 'json' ? '' : `.${format}`; + const params = []; + if (needsQuery(text)) params.push(`text=${shellSafeEncode(text)}`); + if (format === 'jsonp') params.push('callback=cb'); + const query = params.length ? `?${params.join('&')}` : ''; + return needsQuery(text) ? `${API}/${route}${ext}${query}` : `${API}/${route}/${shellSafeEncode(text)}${ext}${query}`; +}; + +/** Refresh the curl line and its clickable link */ +const renderCurl = () => { + const url = apiUrl(currentText()); + curl.textContent = `curl '${url}'`; // quoted so & and ? survive the shell + curlLink.href = url; +}; + +/** Word and character counts from the library's count() */ +const countText = (text) => { + const c = count(text); + return `${c.words} words, ${c.characters} chars, ${c.charactersNoSpaces} without spaces, ${c.spaces} spaces`; +}; + +/** Badge URL for the current text, using the selected style (or title); ?text= when the path would misread it */ +const badgeUrl = (text, ext = '') => { + const style = STYLES.includes(selectedStyle) ? selectedStyle : 'title'; + const dot = ext ? `.${ext}` : ''; + return needsQuery(text) + ? `${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 */ +const renderSpell = async (text) => { + const el = outputs.get('spell'); + const mine = ++spellRequest; + try { + const res = await fetch(`${API}/spell?text=${encodeURIComponent(text.slice(0, 500))}`); + const data = await res.json(); + if (mine !== spellRequest) return; + el.textContent = res.ok ? data.output : `(${data.error?.message ?? 'unavailable'})`; + } catch { + if (mine === spellRequest) el.textContent = '(spell check unavailable)'; + } +}; + +/** Recompute every output and the curl line */ +const render = () => { + const text = currentText(); + const all = capstringAll(text); + for (const style of STYLES) outputs.get(style).textContent = all[style]; + outputs.get('count').textContent = countText(text); + outputs.get('badge').src = badgeUrl(text); + renderSpell(text); + renderCurl(); +}; + +/** Show a transient toast message */ +const showToast = (message) => { + toast.textContent = message; + toast.classList.add('show'); + clearTimeout(toastTimer); + toastTimer = setTimeout(() => toast.classList.remove('show'), 2000); +}; + +/** Copy text to the clipboard, reporting success or failure in the toast */ +const copy = async (text, label) => { + try { + await navigator.clipboard.writeText(text); + showToast(`Copied ${label}`); + return true; + } catch { + showToast('Copy failed. Select the text and copy it manually.'); + return false; + } +}; + +/** Keep the URL shareable: ?t=&style=\n Hello World\n` + }, + limits: { + node: `const res = await fetch('${API}/upper/' + 'a'.repeat(10001));\nres.status; // 413\n(await res.json()).error.code; // 'text_too_long'`, + cli: `$ curl -i ${API}/upper/hello%20world | grep -i cache-control\ncache-control: public, max-age=86400\n\n$ curl -i ${API}/random/hello%20world | grep -i cache-control\ncache-control: no-store` + }, + package: { + node: '// What you get after npm install capstring\n// node_modules/capstring/\n// index.js the library\n// index.d.ts types\n// cli.js CLI logic\n// bin/capstring.js\n// package.json, README.md, LICENSE', + cli: '$ npm pack capstring --dry-run\nnpm notice package size: ~15 kB\nnpm notice unpacked size: ~36 kB\nnpm notice total files: 7' + } +}; + +// ---------- helpers ---------- + +const el = (tag, attrs = {}, children = []) => { + const node = document.createElement(tag); + for (const [k, v] of Object.entries(attrs)) { + if (k === 'text') node.textContent = v; + else node.setAttribute(k, v); + } + for (const child of children) node.append(child); + return node; +}; + +let lang = 'node'; +try { lang = localStorage.getItem('capstring-docs-lang') || 'node'; } catch { /* ignore */ } +if (!LANGS.some((l) => l.id === lang)) lang = 'node'; +const sampleBlocks = []; + +/** Build an inline sample block with Node / CLI tabs; all blocks switch together */ +const sampleBlock = (samples) => { + const tabs = el('div', { class: 'sample-tabs', role: 'tablist' }); + const code = el('code'); + const pre = el('pre', {}, [code]); + const block = el('div', { class: 'sample' }, [tabs, pre]); + const render = () => { + for (const b of tabs.querySelectorAll('[role="tab"]')) b.setAttribute('aria-selected', String(b.dataset.lang === lang)); + const entry = samples[lang] ?? samples.node; + const text = typeof entry === 'string' ? entry : entry.code; + const prism = typeof entry === 'string' ? LANGS.find((l) => l.id === lang).prism : entry.lang; + code.className = `language-${prism}`; + code.textContent = text; + if (window.Prism) window.Prism.highlightElement(code); + }; + for (const l of LANGS) { + const b = el('button', { type: 'button', role: 'tab', 'data-lang': l.id, text: l.label }); + b.addEventListener('click', () => { + lang = l.id; + try { localStorage.setItem('capstring-docs-lang', lang); } catch { /* ignore */ } + for (const r of sampleBlocks) r(); + }); + tabs.append(b); + } + const copy = el('button', { type: 'button', class: 'copy', text: 'Copy' }); + copy.addEventListener('click', async () => { + try { + await navigator.clipboard.writeText(code.textContent); + copy.textContent = 'Copied'; + } catch { + copy.textContent = 'Copy failed'; + } + setTimeout(() => { copy.textContent = 'Copy'; }, 1200); + }); + tabs.append(copy); + sampleBlocks.push(render); + render(); + return block; +}; + +// ---------- inline samples for the fixed sections ---------- + +for (const section of document.querySelectorAll('.prose section[data-sample]')) { + const samples = SAMPLES[section.dataset.sample]; + if (samples) section.append(sampleBlock(samples)); +} + +// ---------- style docs ---------- + +const styleDocs = document.getElementById('style-docs'); +for (const [category, styles] of Object.entries(CATEGORIES)) { + const doc = CATEGORY_DOCS[category]; + const section = el('section', { id: `cat-${category}`, class: 'category' }, [ + el('h2', { text: doc.title }), + el('p', { class: 'category-intro', text: doc.intro }) + ]); + for (const style of styles) { + const d = STYLE_DOCS[style]; + const table = el('table', {}, [el('tr', {}, [el('th', { text: 'Input' }), el('th', { text: 'Output' })])]); + for (const input of d.examples) { + table.append(el('tr', {}, [el('td', { text: input }), el('td', { text: capstring(input, style) })])); + } + const entry = el('article', { id: `style-${style}`, class: 'style-entry' }, [ + el('h3', {}, [el('code', { text: style })]), + el('p', { class: 'summary', text: d.summary }), + el('p', { class: 'detail', text: d.detail }), + table + ]); + if (d.notes) entry.append(el('ul', { class: 'notes' }, d.notes.map((n) => el('li', { text: n })))); + entry.append(sampleBlock(styleSamples(style))); + entry.append(el('p', { class: 'try' }, [el('a', { href: `/?t=hello+world&style=${style}`, text: `Try ${style} in the playground →` })])); + section.append(entry); + } + styleDocs.append(section); +} + +// ---------- table of contents ---------- + +const tocList = document.getElementById('toc'); +const tocItems = []; +const addToc = (id, label, level) => { + const li = el('li', { class: `l${level}` }, [el('a', { href: `#${id}`, text: label })]); + tocList.append(li); + tocItems.push({ id, li, text: label.toLowerCase() }); +}; +for (const section of document.querySelectorAll('.prose > section')) { + addToc(section.id, section.querySelector('h1, h2').textContent, 1); + // The Styles section nests one section per category; list each category once with its styles under it + for (const category of section.querySelectorAll(':scope > #style-docs > section.category')) { + addToc(category.id, category.querySelector('h2').textContent, 2); + for (const entry of category.querySelectorAll('.style-entry')) addToc(entry.id, entry.id.replace('style-', ''), 3); + } +} + +document.getElementById('toc-filter').addEventListener('input', (e) => { + const needle = e.target.value.trim().toLowerCase(); + for (const item of tocItems) item.li.classList.toggle('hidden', needle !== '' && !item.text.includes(needle)); +}); + +// ---------- scroll spy for the sidebar ---------- + +const targets = tocItems.map((i) => document.getElementById(i.id)); +const spy = () => { + let best = targets[0]; + for (const t of targets) if (t.getBoundingClientRect().top <= 120) best = t; + for (const item of tocItems) item.li.querySelector('a').toggleAttribute('aria-current', item.id === best.id); +}; +addEventListener('scroll', spy, { passive: true }); +addEventListener('hashchange', () => setTimeout(spy, 50)); +spy(); diff --git a/web/docs/index.html b/web/docs/index.html new file mode 100644 index 0000000..d8927a2 --- /dev/null +++ b/web/docs/index.html @@ -0,0 +1,194 @@ + + + + + + + + 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. Two ways to get it:

+
# no install, runs the latest version each time
+npx capstring kebab "hello world"
+
+# or install once and use it anywhere
+npm install -g capstring
+capstring kebab "hello world"
+
capstring <style> [text...]     Transform text (reads stdin when no text is given)
+capstring --all [text...]       Print every style
+capstring --count [text...]     Count words, characters, and spaces
+capstring --list                List style names
+
+  -a, --all        Print every style
+  -c, --count      Count words, characters, and spaces
+  -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 1,000
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
SVG image.svgimage/svg+xml
PNG image.pngimage/png
+

Use ?text= instead of the path when the text contains slashes or ends in an extension. Add ?pretty=1 for indented JSON. Image formats draw a single line and cut output beyond 200 characters with an ellipsis.

+
+ +
+

Limits and caching

+ + + + + + + +
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, 100,000 characters in total
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/docs/styles-data.js b/web/docs/styles-data.js new file mode 100644 index 0000000..5e5758d --- /dev/null +++ b/web/docs/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/icon.svg b/web/icon.svg new file mode 100644 index 0000000..53af45a --- /dev/null +++ b/web/icon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..9d79733 --- /dev/null +++ b/web/index.html @@ -0,0 +1,94 @@ + + + + + + + + capstring + + + + + + + + + + + + + + + + +
+
+

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

+
+ +
+ + + +

+ + + + +

+ +
+ +
+

HTTP API

+

Free, no key, CORS enabled, cacheable. Base URL https://capstring.netlify.app/api. Text up to 10,000 characters (about 8 KB per GET URL; use POST /api/batch beyond that). Full reference with try-it-out at /api (OpenAPI 3.1, openapi.json). Library and CLI docs at /docs.

+ + + + + + + + + + + + +
EndpointReturns
GET /api/:style/:text{ input, style, output }
GET /api/all/:textevery style at once
POST /api/batchbody { "style": "kebab", "texts": ["a", "b"] } (max 1,000)
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=), svg and png (the result as an image).

+
curl https://capstring.netlify.app/api/title/hello%20world
+curl https://capstring.netlify.app/api/sponge/hello%20world.txt
+curl -o hello.png https://capstring.netlify.app/api/flip/hello%20world.png
+curl -H "Accept: text/csv" https://capstring.netlify.app/api/all/hello%20world
+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"]}'
+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 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..8366a66 --- /dev/null +++ b/web/openapi.json @@ -0,0 +1,1237 @@ +{ + "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=`), `svg` and `png` (the result rendered as an image, handy in a README). 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": 50, + "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\"});" + }, + "image/svg+xml": { + "schema": { + "type": "string" + }, + "example": "Hello World" + }, + "image/png": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "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" + } + } + } + }, + "/batch": { + "post": { + "tags": [ + "transform" + ], + "summary": "Transform up to 1,000 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 10,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" + } + } + } + }, + "413": { + "description": "The batch's texts total more than 100,000 characters (`batch_too_large`)", + "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 grapheme clusters, so an emoji, a flag, or an accented letter counts once)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Count" + }, + "example": { + "input": "hello world", + "words": 2, + "characters": 11, + "charactersNoSpaces": 10, + "spaces": 1 + } + }, + "text/plain": { + "schema": { + "type": "string" + }, + "example": "words: 2, chars: 11, chars (no spaces): 10, spaces: 1" + } + } + }, + "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 10,000 characters. May contain `/`; extra segments are joined back with slashes. The host caps GET URLs at about 8 KB, so send longer text through POST /batch.", + "schema": { + "type": "string", + "maxLength": 10000, + "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": 10000 + } + }, + "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. `svg` and `png` render the result as an image.", + "schema": { + "type": "string", + "enum": [ + "json", + "jsonp", + "txt", + "html", + "xml", + "yaml", + "yml", + "csv", + "svg", + "png" + ] + } + }, + "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/