@brianfunk/capita 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2016 Brian Funk
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,244 @@
1
+ [![cAPIta](https://img.shields.io/badge/cAPIta-CaPiTaLiZaTiOn%20API-b5d4ff.svg)](https://github.com/brianfunk/cAPIta)
2
+ [![npm version](https://img.shields.io/npm/v/@brianfunk/capita.svg)](https://www.npmjs.com/package/@brianfunk/capita)
3
+ [![npm downloads](https://img.shields.io/npm/dm/@brianfunk/capita.svg)](https://www.npmjs.com/package/@brianfunk/capita)
4
+ [![Netlify](https://img.shields.io/badge/live-capita-api.netlify.app-b5d4ff.svg)](https://capita-api.netlify.app)
5
+ [![CI](https://github.com/brianfunk/cAPIta/actions/workflows/ci.yml/badge.svg)](https://github.com/brianfunk/cAPIta/actions/workflows/ci.yml)
6
+ [![Open Source Love](https://badges.frapsoft.com/os/v1/open-source.svg?v=103)](https://github.com/ellerbrock/open-source-badge/)
7
+ [![Semver](https://img.shields.io/badge/SemVer-2.0-blue.svg)](http://semver.org/spec/v2.0.0.html)
8
+ [![License](https://img.shields.io/github/license/mashape/apistatus.svg)](https://opensource.org/licenses/MIT)
9
+ [![LinkedIn](https://img.shields.io/badge/Linked-In-blue.svg)](https://www.linkedin.com/in/brianrandyfunk)
10
+
11
+ # cAPIta
12
+
13
+ > cAPIta - Capitalization API
14
+
15
+ A blazing-fast REST API for text transformation. Convert any string to 29 different styles including camelCase, snake_case, kebab-case, and more! Powered by [capstring](https://github.com/brianfunk/capstring).
16
+
17
+ ## Try It Live
18
+
19
+ **https://capita-api.netlify.app** has a playground that transforms as you type. Or just hit a URL:
20
+
21
+ - https://capita-api.netlify.app/sponge/hello%20world.txt → `HeLlO WoRlD`
22
+ - https://capita-api.netlify.app/kebab/Hello%20World.json → `{"input":"Hello World","output":"hello-world","cap":"kebab"}`
23
+ - https://capita-api.netlify.app/badge/upper/hello → an SVG badge for your README
24
+ - https://capita-api.netlify.app/spell/helo%20wrld.txt → `hello world`
25
+
26
+ ## Why cAPIta?
27
+
28
+ - **29 capitalization styles** - More than any other text transform API!
29
+ - **Multiple output formats** - JSON, JSONP, HTML, TXT, XML, YAML, CSV
30
+ - **Built-in spell check** - Powered by nspell (no API key required!)
31
+ - **Batch processing** - Transform up to 100 strings at once
32
+ - **Chain transformations** - Apply multiple styles in sequence
33
+ - **Word/char count** - Built-in text analytics
34
+ - **Lorem ipsum generator** - Generate placeholder text
35
+ - **XSS-safe HTML output** - Security built in
36
+ - **Content negotiation** - Automatic format based on Accept header
37
+
38
+ ## Quick Start
39
+
40
+ ```bash
41
+ # Run it (no install needed)
42
+ npx @brianfunk/capita
43
+
44
+ # Or clone and start
45
+ npm install
46
+ npm start
47
+
48
+ # Try it out!
49
+ curl http://localhost:4321/title/hello%20world.json
50
+ # {"input":"hello world","output":"Hello World","cap":"title"}
51
+
52
+ # See all available styles
53
+ curl http://localhost:4321/styles
54
+ ```
55
+
56
+ ## API Endpoints
57
+
58
+ ### Transform Text
59
+
60
+ ```
61
+ GET /:style/:string
62
+ GET /:style/:string.:ext
63
+ ```
64
+
65
+ ### Chain Multiple Transformations
66
+
67
+ ```
68
+ GET /chain/:styles/:string
69
+ ```
70
+
71
+ Chain styles with `+`:
72
+
73
+ ```bash
74
+ curl http://localhost:4321/chain/upper+reverse/hello.json
75
+ # {"input":"hello","output":"OLLEH","cap":"upper+reverse"}
76
+
77
+ curl http://localhost:4321/chain/lower+title+kebab/HELLO%20WORLD.txt
78
+ # hello-world
79
+ ```
80
+
81
+ ### Batch Processing
82
+
83
+ ```
84
+ POST /batch
85
+ ```
86
+
87
+ Transform multiple strings at once:
88
+
89
+ ```bash
90
+ curl -X POST http://localhost:4321/batch \
91
+ -H "Content-Type: application/json" \
92
+ -d '{"inputs": ["hello", "world"], "style": "upper"}'
93
+ # {"style":"upper","results":[{"input":"hello","output":"HELLO"},{"input":"world","output":"WORLD"}],"count":2}
94
+ ```
95
+
96
+ ### Word/Character Count
97
+
98
+ ```
99
+ GET /count/:string
100
+ GET /count/:string.:ext
101
+ ```
102
+
103
+ ```bash
104
+ curl http://localhost:4321/count/hello%20world.json
105
+ # {"input":"hello world","words":2,"characters":11,"charactersNoSpaces":10}
106
+ ```
107
+
108
+ ### Lorem Ipsum Generator
109
+
110
+ ```
111
+ GET /lorem/:count?
112
+ GET /lorem/:count?.:ext
113
+ ```
114
+
115
+ ```bash
116
+ curl http://localhost:4321/lorem/10.json
117
+ # {"lorem":"Lorem ipsum dolor sit amet consectetur adipiscing elit sed do.","wordCount":10}
118
+ ```
119
+
120
+ ### All 29 Capitalization Styles
121
+
122
+ #### Case Styles
123
+ | Style | Input | Output |
124
+ |-------|-------|--------|
125
+ | `upper` | hello world | HELLO WORLD |
126
+ | `lower` | HELLO WORLD | hello world |
127
+ | `title` | hello world | Hello World |
128
+ | `sentence` | hello world | Hello world |
129
+ | `swap` | Hello World | hELLO wORLD |
130
+
131
+ #### Code Styles
132
+ | Style | Input | Output |
133
+ |-------|-------|--------|
134
+ | `camel` | hello world | helloWorld |
135
+ | `pascal` | hello world | HelloWorld |
136
+ | `snake` | hello world | hello_world |
137
+ | `kebab` | hello world | hello-world |
138
+ | `constant` | hello world | HELLO_WORLD |
139
+ | `dot` | hello world | hello.world |
140
+ | `path` | hello world | hello/world |
141
+ | `train` | hello world | Hello-World |
142
+
143
+ #### Fun Styles
144
+ | Style | Input | Output |
145
+ |-------|-------|--------|
146
+ | `leet` | elite | 3£1t3 |
147
+ | `reverse` | hello | olleh |
148
+ | `sponge` | hello | HeLlO |
149
+ | `hashtag` | hello world | #HelloWorld |
150
+ | `acronym` | as soon as possible | ASAP |
151
+ | `rot13` | hello | uryyb |
152
+ | `flip` | hello | ollǝɥ |
153
+
154
+ ### Output Formats
155
+
156
+ | Extension | Content-Type | Example |
157
+ |-----------|--------------|---------|
158
+ | `.json` | application/json | `{"input":"hello","output":"HELLO","cap":"upper"}` |
159
+ | `.jsonp` | application/javascript | Callback wrapper |
160
+ | `.html` | text/html | `<em>HELLO</em>` |
161
+ | `.txt` | text/plain | `HELLO` |
162
+ | `.xml` | application/xml | `<result><output>HELLO</output></result>` |
163
+ | `.yaml` | text/yaml | `output: "HELLO"` |
164
+ | `.csv` | text/csv | `input,output,cap` |
165
+
166
+ ### Spell Check
167
+
168
+ ```
169
+ GET /spell/:string
170
+ GET /spell/:string.:ext
171
+ ```
172
+
173
+ Uses [nspell](https://github.com/wooorm/nspell) with the English Hunspell dictionary. No API key required!
174
+
175
+ ```bash
176
+ curl http://localhost:4321/spell/helo%20wrld.json
177
+ # {"input":"helo wrld","output":"hello world","cap":"spell"}
178
+ ```
179
+
180
+ ### Generate Badges
181
+
182
+ ```
183
+ GET /badge/:style/:string
184
+ ```
185
+
186
+ Returns a shields-style SVG badge, rendered locally with no external calls. Perfect for README files!
187
+
188
+ ### List Available Styles
189
+
190
+ ```
191
+ GET /styles
192
+ ```
193
+
194
+ Returns all available styles and their count.
195
+
196
+ ## Configuration
197
+
198
+ | Variable | Description | Default |
199
+ |----------|-------------|---------|
200
+ | `PORT` | Server port | `4321` |
201
+
202
+ ## Security
203
+
204
+ - **XSS Prevention** - HTML output is escaped
205
+ - **Input Limits** - Max 10,000 characters per request
206
+ - **Batch Limits** - Max 100 items per batch request
207
+ - **Chain Limits** - Max 10 transformations per chain
208
+
209
+ ## Development
210
+
211
+ ```bash
212
+ npm install # Install dependencies
213
+ npm start # Start server
214
+ npm run dev # Run through Netlify Dev (static playground + function)
215
+ npm run demo # Run interactive demo
216
+ npm test # Run tests
217
+ npm run lint # Run linter
218
+ npm run test:coverage # Test with coverage (84%+)
219
+ ```
220
+
221
+ ## Deployment
222
+
223
+ The site runs on Netlify: `public/` is served statically (the playground) and every other path is redirected to `netlify/functions/api.mjs`, which wraps the Express app. See `netlify.toml`.
224
+
225
+ ## Contributing
226
+
227
+ Please read [CONTRIBUTING.md](CONTRIBUTING.md) for details on the process for submitting pull requests.
228
+
229
+ ## Versioning
230
+
231
+ This project uses [Semantic Versioning 2.0](http://semver.org/spec/v2.0.0.html).
232
+
233
+ ## Related Projects
234
+
235
+ - **[capstring](https://github.com/brianfunk/capstring)** - The core library that powers cAPIta
236
+
237
+ ## Requirements
238
+
239
+ - Node.js 18+
240
+ - ES modules (`import`/`export`)
241
+
242
+ ## License
243
+
244
+ MIT
package/app.js ADDED
@@ -0,0 +1,445 @@
1
+ /*
2
+ _ ____ ___ _
3
+ ___ / \ | _ \_ _| |_ __ _
4
+ / __| / _ \ | |_) | || __/ _` |
5
+ | (__ / ___ \| __/| || || (_| |
6
+ \___/_/ \_\_| |___|\__\__,_|
7
+
8
+ */
9
+
10
+ /**
11
+ * cAPIta - Capitalization API
12
+ * Express app factory powered by capstring. See index.js for the server entry.
13
+ * @module cAPIta
14
+ */
15
+
16
+ import { fileURLToPath } from 'node:url';
17
+ import path from 'node:path';
18
+ import express from 'express';
19
+ import capstring, { STYLES, isValidStyle } from 'capstring';
20
+ import nspell from 'nspell';
21
+ import dictionary from 'dictionary-en';
22
+
23
+ /** Directory holding the static playground */
24
+ const PUBLIC_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), 'public');
25
+
26
+ /** Maximum input length to prevent abuse */
27
+ const MAX_INPUT_LENGTH = 10000;
28
+
29
+ /** Supported output formats */
30
+ const EXTS = Object.freeze(['json', 'jsonp', 'html', 'txt', 'xml', 'yaml', 'yml', 'csv']);
31
+
32
+ /** Lorem Ipsum base text */
33
+ const LOREM_WORDS = 'lorem ipsum dolor sit amet consectetur adipiscing elit sed do eiusmod tempor incididunt ut labore et dolore magna aliqua ut enim ad minim veniam quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur excepteur sint occaecat cupidatat non proident sunt in culpa qui officia deserunt mollit anim id est laborum'.split(' ');
34
+
35
+ /** @type {import('nspell').default|null} */
36
+ let spellChecker = null;
37
+
38
+ /**
39
+ * Initialize the spell checker with the English dictionary
40
+ */
41
+ const initSpellChecker = () => {
42
+ if (spellChecker) return;
43
+ spellChecker = nspell(dictionary);
44
+ };
45
+
46
+ /**
47
+ * Escape HTML special characters to prevent XSS
48
+ * @param {string} str - The string to escape
49
+ * @returns {string} The escaped string
50
+ */
51
+ const escapeHtml = (str) => {
52
+ const htmlEscapes = {
53
+ '&': '&amp;',
54
+ '<': '&lt;',
55
+ '>': '&gt;',
56
+ '"': '&quot;',
57
+ "'": '&#39;'
58
+ };
59
+ return str.replace(/[&<>"']/g, (char) => htmlEscapes[char]);
60
+ };
61
+
62
+ /**
63
+ * Render a shields.io-style flat SVG badge locally (no network call)
64
+ * @param {string} label - Left-hand label text
65
+ * @param {string} value - Right-hand value text
66
+ * @returns {string} SVG markup
67
+ */
68
+ const badgeSvg = (label, value) => {
69
+ const charWidth = 6.5;
70
+ const padding = 10;
71
+ const labelWidth = Math.round(label.length * charWidth + padding);
72
+ const valueWidth = Math.round(value.length * charWidth + padding);
73
+ const width = labelWidth + valueWidth;
74
+ const safeLabel = escapeHtml(label);
75
+ const safeValue = escapeHtml(value);
76
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="20" role="img" aria-label="${safeLabel}: ${safeValue}">
77
+ <title>${safeLabel}: ${safeValue}</title>
78
+ <linearGradient id="s" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient>
79
+ <clipPath id="r"><rect width="${width}" height="20" rx="3" fill="#fff"/></clipPath>
80
+ <g clip-path="url(#r)">
81
+ <rect width="${labelWidth}" height="20" fill="#555"/>
82
+ <rect x="${labelWidth}" width="${valueWidth}" height="20" fill="#b5d4ff"/>
83
+ <rect width="${width}" height="20" fill="url(#s)"/>
84
+ </g>
85
+ <g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11">
86
+ <text x="${labelWidth / 2}" y="15" fill="#010101" fill-opacity=".3">${safeLabel}</text>
87
+ <text x="${labelWidth / 2}" y="14">${safeLabel}</text>
88
+ <text x="${labelWidth + valueWidth / 2}" y="15" fill="#010101" fill-opacity=".3">${safeValue}</text>
89
+ <text x="${labelWidth + valueWidth / 2}" y="14" fill="#333">${safeValue}</text>
90
+ </g>
91
+ </svg>`;
92
+ };
93
+
94
+ /**
95
+ * Spell check and correct text using nspell
96
+ * @param {string} text - Text to spell check
97
+ * @returns {string} Corrected text
98
+ */
99
+ const spellCheckText = (text) => {
100
+ initSpellChecker();
101
+
102
+ // Split into words while preserving spacing and punctuation
103
+ const words = text.split(/(\s+)/);
104
+
105
+ const corrected = words.map((word) => {
106
+ // Skip whitespace
107
+ if (/^\s*$/.test(word)) return word;
108
+
109
+ // Extract punctuation from word
110
+ const match = word.match(/^([^a-zA-Z]*)([a-zA-Z]+)([^a-zA-Z]*)$/);
111
+ if (!match) return word;
112
+
113
+ const [, prefix, core, suffix] = match;
114
+
115
+ // Check if word is spelled correctly
116
+ if (spellChecker.correct(core)) {
117
+ return word;
118
+ }
119
+
120
+ // Get suggestions
121
+ const suggestions = spellChecker.suggest(core);
122
+ if (suggestions.length > 0) {
123
+ return prefix + suggestions[0] + suffix;
124
+ }
125
+
126
+ return word;
127
+ });
128
+
129
+ return corrected.join('');
130
+ };
131
+
132
+ /**
133
+ * Format and send response based on extension/content type
134
+ * @param {Object} req - Express request
135
+ * @param {Object} res - Express response
136
+ * @param {string} input - Original input
137
+ * @param {string} output - Transformed output
138
+ * @param {string} cap - Capitalization type
139
+ * @param {string|false} ext - File extension or false for content negotiation
140
+ */
141
+ const outResponse = (req, res, input, output, cap, ext) => {
142
+ const safeOutput = escapeHtml(output);
143
+ const outHtml = `<head><title></title></head><body><p><strong><em>${safeOutput}</em></strong></p></body>`;
144
+ const outJson = { input, output, cap };
145
+
146
+ if (ext) {
147
+ if (!EXTS.includes(ext)) {
148
+ res.status(400).send('error - invalid ext');
149
+ return;
150
+ }
151
+
152
+ switch (ext) {
153
+ case 'txt':
154
+ res.type('text');
155
+ res.send(output);
156
+ break;
157
+ case 'html':
158
+ res.type('html');
159
+ res.send(outHtml);
160
+ break;
161
+ case 'json':
162
+ res.json(outJson);
163
+ break;
164
+ case 'jsonp':
165
+ res.jsonp(outJson);
166
+ break;
167
+ case 'xml':
168
+ res.type('application/xml');
169
+ res.send(`<?xml version="1.0" encoding="UTF-8"?>\n<result>\n <input>${escapeHtml(input)}</input>\n <output>${safeOutput}</output>\n <cap>${cap}</cap>\n</result>`);
170
+ break;
171
+ case 'yaml':
172
+ case 'yml': {
173
+ res.type('text/yaml');
174
+ // YAML escape: handle quotes, newlines, special chars
175
+ const yamlEscape = (s) => s.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n').replace(/\r/g, '\\r');
176
+ res.send(`input: "${yamlEscape(input)}"\noutput: "${yamlEscape(output)}"\ncap: "${cap}"`);
177
+ break;
178
+ }
179
+ case 'csv': {
180
+ res.type('text/csv');
181
+ // CSV escape: double quotes and handle newlines (wrap in quotes)
182
+ const csvEscape = (s) => s.replace(/"/g, '""').replace(/[\r\n]/g, ' ');
183
+ res.send(`input,output,cap\n"${csvEscape(input)}","${csvEscape(output)}","${cap}"`);
184
+ break;
185
+ }
186
+ }
187
+ } else {
188
+ res.format({
189
+ text: () => res.send(output),
190
+ html: () => res.send(outHtml),
191
+ json: () => res.json(outJson),
192
+ jsonp: () => res.jsonp(outJson)
193
+ });
194
+ }
195
+ };
196
+
197
+ /**
198
+ * Create and configure the Express app
199
+ * @returns {express.Application} Configured Express app
200
+ */
201
+ export const createApp = () => {
202
+ const app = express();
203
+
204
+ app.use(express.json());
205
+
206
+ // Playground (public/index.html) and any other static assets
207
+ app.use(express.static(PUBLIC_DIR));
208
+
209
+ // List available styles
210
+ app.get('/styles', (_req, res) => {
211
+ res.json({
212
+ styles: STYLES,
213
+ count: STYLES.length
214
+ });
215
+ });
216
+
217
+ // Badge route
218
+ app.get('/badge/:cap/:string', (req, res) => {
219
+ const { cap, string } = req.params;
220
+
221
+ if (!isValidStyle(cap)) {
222
+ res.status(400).send('error - invalid case');
223
+ return;
224
+ }
225
+
226
+ if (string.length > MAX_INPUT_LENGTH) {
227
+ res.status(400).send('error - input too long');
228
+ return;
229
+ }
230
+
231
+ const cs = capstring(string, cap);
232
+ res.type('image/svg+xml');
233
+ res.send(badgeSvg(`cAPIta ${capstring(cap, cap)}`, cs));
234
+ });
235
+
236
+ // Word and character count
237
+ app.get('/count/:string.:ext?', (req, res) => {
238
+ const { string, ext } = req.params;
239
+
240
+ if (string.length > MAX_INPUT_LENGTH) {
241
+ res.status(400).send('error - input too long');
242
+ return;
243
+ }
244
+
245
+ const words = string.trim().split(/\s+/).filter(w => w.length > 0);
246
+ const chars = string.length;
247
+ const charsNoSpaces = string.replace(/\s/g, '').length;
248
+
249
+ const result = {
250
+ input: string,
251
+ words: words.length,
252
+ characters: chars,
253
+ charactersNoSpaces: charsNoSpaces
254
+ };
255
+
256
+ if (ext === 'json' || !ext) {
257
+ res.json(result);
258
+ } else if (ext === 'txt') {
259
+ res.type('text');
260
+ res.send(`words: ${words.length}, chars: ${chars}, chars (no spaces): ${charsNoSpaces}`);
261
+ } else if (ext === 'html') {
262
+ const safeInput = escapeHtml(string);
263
+ res.type('html');
264
+ res.send(`<head><title>Count</title></head><body><p>Input: <em>${safeInput}</em></p><p>Words: ${words.length}</p><p>Characters: ${chars}</p><p>Characters (no spaces): ${charsNoSpaces}</p></body>`);
265
+ } else {
266
+ res.status(400).send('error - invalid ext');
267
+ }
268
+ });
269
+
270
+ // Lorem ipsum generator
271
+ app.get('/lorem/:count?.:ext?', (req, res) => {
272
+ const parsedCount = parseInt(req.params.count, 10);
273
+ const count = isNaN(parsedCount) ? 50 : parsedCount;
274
+ const { ext } = req.params;
275
+
276
+ if (count < 1 || count > 1000) {
277
+ res.status(400).send('error - count must be 1-1000');
278
+ return;
279
+ }
280
+
281
+ // Generate lorem ipsum words
282
+ const words = [];
283
+ for (let i = 0; i < count; i++) {
284
+ words.push(LOREM_WORDS[i % LOREM_WORDS.length]);
285
+ }
286
+ // Capitalize first word
287
+ words[0] = words[0].charAt(0).toUpperCase() + words[0].slice(1);
288
+
289
+ const lorem = words.join(' ') + '.';
290
+
291
+ if (ext === 'json' || !ext) {
292
+ res.json({ lorem, wordCount: count });
293
+ } else if (ext === 'txt') {
294
+ res.type('text');
295
+ res.send(lorem);
296
+ } else if (ext === 'html') {
297
+ res.type('html');
298
+ res.send(`<head><title>Lorem Ipsum</title></head><body><p>${lorem}</p></body>`);
299
+ } else {
300
+ res.status(400).send('error - invalid ext');
301
+ }
302
+ });
303
+
304
+ // Batch/bulk transformation (POST)
305
+ app.post('/batch', (req, res) => {
306
+ const { inputs, style } = req.body;
307
+
308
+ if (!Array.isArray(inputs)) {
309
+ res.status(400).json({ error: 'inputs must be an array' });
310
+ return;
311
+ }
312
+
313
+ if (inputs.length > 100) {
314
+ res.status(400).json({ error: 'maximum 100 inputs per batch' });
315
+ return;
316
+ }
317
+
318
+ if (!isValidStyle(style)) {
319
+ res.status(400).json({ error: 'invalid style', validStyles: [...STYLES] });
320
+ return;
321
+ }
322
+
323
+ const results = inputs.map(input => {
324
+ if (typeof input !== 'string') {
325
+ return { input, output: null, error: 'input must be string' };
326
+ }
327
+ if (input.length > MAX_INPUT_LENGTH) {
328
+ return { input: input.slice(0, 50) + '...', output: null, error: 'input too long' };
329
+ }
330
+ const output = capstring(input, style);
331
+ return { input, output };
332
+ });
333
+
334
+ res.json({ style, results, count: results.length });
335
+ });
336
+
337
+ // Chain multiple transformations
338
+ app.get('/chain/:styles/:string.:ext?', (req, res) => {
339
+ const { styles, string, ext } = req.params;
340
+
341
+ if (string.length > MAX_INPUT_LENGTH) {
342
+ res.status(400).send('error - input too long');
343
+ return;
344
+ }
345
+
346
+ const styleList = styles.split('+');
347
+ if (styleList.length > 10) {
348
+ res.status(400).send('error - maximum 10 chained styles');
349
+ return;
350
+ }
351
+
352
+ for (const s of styleList) {
353
+ if (!isValidStyle(s)) {
354
+ res.status(400).send(`error - invalid style: ${s}`);
355
+ return;
356
+ }
357
+ }
358
+
359
+ let result = string;
360
+ for (const s of styleList) {
361
+ result = capstring(result, s);
362
+ if (result === false) {
363
+ res.status(400).send('error - transformation failed');
364
+ return;
365
+ }
366
+ }
367
+
368
+ outResponse(req, res, string, result, styleList.join('+'), ext || false);
369
+ });
370
+
371
+ // Spell check with extension
372
+ app.get('/spell/:string.:ext', (req, res) => {
373
+ const { string, ext } = req.params;
374
+
375
+ try {
376
+ const cs = spellCheckText(string);
377
+ outResponse(req, res, string, cs, 'spell', ext);
378
+ } catch {
379
+ res.status(500).send('error - spell check failed');
380
+ }
381
+ });
382
+
383
+ // Spell check without extension
384
+ app.get('/spell/:string', (req, res) => {
385
+ const { string } = req.params;
386
+
387
+ try {
388
+ const cs = spellCheckText(string);
389
+ outResponse(req, res, string, cs, 'spell', false);
390
+ } catch {
391
+ res.status(500).send('error - spell check failed');
392
+ }
393
+ });
394
+
395
+ // Capitalize with extension
396
+ app.get('/:cap/:string.:ext', (req, res) => {
397
+ const { cap, string, ext } = req.params;
398
+
399
+ if (!isValidStyle(cap)) {
400
+ res.status(400).send('error - invalid case');
401
+ return;
402
+ }
403
+
404
+ if (string.length > MAX_INPUT_LENGTH) {
405
+ res.status(400).send('error - input too long');
406
+ return;
407
+ }
408
+
409
+ const cs = capstring(string, cap);
410
+ if (!cs && cap !== 'none') {
411
+ res.status(400).send('error - invalid string');
412
+ return;
413
+ }
414
+
415
+ outResponse(req, res, string, cs, cap, ext);
416
+ });
417
+
418
+ // Capitalize without extension
419
+ app.get('/:cap/:string', (req, res) => {
420
+ const { cap, string } = req.params;
421
+
422
+ if (!isValidStyle(cap)) {
423
+ res.status(400).send('error - invalid case');
424
+ return;
425
+ }
426
+
427
+ if (string.length > MAX_INPUT_LENGTH) {
428
+ res.status(400).send('error - input too long');
429
+ return;
430
+ }
431
+
432
+ const cs = capstring(string, cap);
433
+ if (!cs && cap !== 'none') {
434
+ res.status(400).send('error - invalid string');
435
+ return;
436
+ }
437
+
438
+ outResponse(req, res, string, cs, cap, false);
439
+ });
440
+
441
+ return app;
442
+ };
443
+
444
+ export { STYLES as CASES };
445
+ export default createApp;
package/index.js ADDED
@@ -0,0 +1,28 @@
1
+ #!/usr/bin/env node
2
+ /*
3
+ _ ____ ___ _
4
+ ___ / \ | _ \_ _| |_ __ _
5
+ / __| / _ \ | |_) | || __/ _` |
6
+ | (__ / ___ \| __/| || || (_| |
7
+ \___/_/ \_\_| |___|\__\__,_|
8
+
9
+ */
10
+
11
+ /**
12
+ * cAPIta server entry
13
+ * Starts the Express app from app.js on PORT (default 4321).
14
+ * @module cAPIta/server
15
+ */
16
+
17
+ import 'dotenv/config';
18
+ import { createApp } from './app.js';
19
+
20
+ const PORT = process.env.PORT || 4321;
21
+
22
+ const app = createApp();
23
+ const server = app.listen(PORT, () => {
24
+ console.log(`cAPIta listening on port ${PORT}`);
25
+ });
26
+
27
+ export { app, server };
28
+ export { createApp, CASES } from './app.js';
package/package.json ADDED
@@ -0,0 +1,76 @@
1
+ {
2
+ "name": "@brianfunk/capita",
3
+ "version": "1.1.0",
4
+ "description": "cAPIta - Capitalization API powered by capstring. 29 styles, one URL.",
5
+ "type": "module",
6
+ "main": "index.js",
7
+ "bin": {
8
+ "capita": "./index.js"
9
+ },
10
+ "files": [
11
+ "index.js",
12
+ "app.js",
13
+ "public"
14
+ ],
15
+ "exports": {
16
+ ".": {
17
+ "import": "./index.js"
18
+ }
19
+ },
20
+ "scripts": {
21
+ "start": "node index.js",
22
+ "demo": "node demo.js",
23
+ "test": "vitest run",
24
+ "test:watch": "vitest",
25
+ "test:coverage": "vitest run --coverage",
26
+ "lint": "eslint . --format stylish",
27
+ "lint:report": "eslint . --format html -o coverage/lint-report.html",
28
+ "dev": "netlify dev"
29
+ },
30
+ "repository": {
31
+ "type": "git",
32
+ "url": "git+https://github.com/brianfunk/cAPIta.git"
33
+ },
34
+ "keywords": [
35
+ "capita",
36
+ "api",
37
+ "cap",
38
+ "capitalization",
39
+ "capitalize",
40
+ "string",
41
+ "capstring",
42
+ "rest",
43
+ "kebab-case",
44
+ "camelCase",
45
+ "snake_case",
46
+ "title-case",
47
+ "text-transform",
48
+ "spell-check"
49
+ ],
50
+ "author": "Brian Funk",
51
+ "license": "MIT",
52
+ "bugs": {
53
+ "url": "https://github.com/brianfunk/cAPIta/issues"
54
+ },
55
+ "homepage": "https://capita-api.netlify.app",
56
+ "publishConfig": {
57
+ "access": "public"
58
+ },
59
+ "engines": {
60
+ "node": ">=18.0.0"
61
+ },
62
+ "dependencies": {
63
+ "capstring": "^1.0.0",
64
+ "dictionary-en": "^4.0.0",
65
+ "dotenv": "^16.4.0",
66
+ "express": "^4.21.0",
67
+ "nspell": "^2.1.0",
68
+ "serverless-http": "^4.0.0"
69
+ },
70
+ "devDependencies": {
71
+ "@vitest/coverage-v8": "^4.0.18",
72
+ "eslint": "^9.17.0",
73
+ "supertest": "^7.0.0",
74
+ "vitest": "^4.0.18"
75
+ }
76
+ }
@@ -0,0 +1,315 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>cAPIta</title>
7
+ <meta name="description" content="cAPIta: a tiny REST API that capitalizes strings 29 ways. Type a phrase, get every style, copy the URL.">
8
+ <link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><rect width='100' height='100' rx='18' fill='%23b5d4ff'/><text x='50' y='68' font-size='52' text-anchor='middle' font-family='monospace' font-weight='bold' fill='%231a2a44'>Aa</text></svg>">
9
+ <style>
10
+ :root {
11
+ --bg: #fbfcff;
12
+ --fg: #1a2233;
13
+ --muted: #5f6b80;
14
+ --card: #ffffff;
15
+ --line: #dde4f0;
16
+ --accent: #b5d4ff;
17
+ --accent-ink: #1a2a44;
18
+ --mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
19
+ }
20
+ @media (prefers-color-scheme: dark) {
21
+ :root {
22
+ --bg: #0f1420;
23
+ --fg: #e6ebf5;
24
+ --muted: #8e9ab3;
25
+ --card: #171e2e;
26
+ --line: #2a3448;
27
+ --accent: #b5d4ff;
28
+ --accent-ink: #0f1420;
29
+ }
30
+ }
31
+ * { box-sizing: border-box; }
32
+ body {
33
+ margin: 0;
34
+ background: var(--bg);
35
+ color: var(--fg);
36
+ font: 16px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif;
37
+ }
38
+ main { max-width: 960px; margin: 0 auto; padding: 32px 16px 64px; }
39
+ pre.logo {
40
+ font-family: var(--mono);
41
+ font-size: 13px;
42
+ line-height: 1.15;
43
+ margin: 0 0 4px;
44
+ color: var(--fg);
45
+ overflow-x: auto;
46
+ }
47
+ p.tag { margin: 0 0 24px; color: var(--muted); }
48
+ p.tag a { color: inherit; }
49
+ .input {
50
+ display: flex;
51
+ gap: 8px;
52
+ align-items: center;
53
+ background: var(--card);
54
+ border: 1px solid var(--line);
55
+ border-radius: 12px;
56
+ padding: 6px 6px 6px 16px;
57
+ }
58
+ .input input {
59
+ flex: 1;
60
+ min-width: 0;
61
+ border: 0;
62
+ background: transparent;
63
+ color: var(--fg);
64
+ font: inherit;
65
+ font-size: 20px;
66
+ outline: none;
67
+ }
68
+ .input button {
69
+ border: 0;
70
+ border-radius: 8px;
71
+ background: var(--accent);
72
+ color: var(--accent-ink);
73
+ font: inherit;
74
+ font-weight: 600;
75
+ padding: 8px 14px;
76
+ cursor: pointer;
77
+ }
78
+ .curl {
79
+ margin: 12px 0 28px;
80
+ font-family: var(--mono);
81
+ font-size: 13px;
82
+ color: var(--muted);
83
+ word-break: break-all;
84
+ }
85
+ .curl a { color: var(--fg); text-decoration: none; border-bottom: 1px dotted var(--muted); }
86
+ .curl a:hover { border-bottom-style: solid; }
87
+ h2 {
88
+ font-size: 13px;
89
+ letter-spacing: .08em;
90
+ text-transform: uppercase;
91
+ color: var(--muted);
92
+ margin: 28px 0 10px;
93
+ }
94
+ .grid {
95
+ display: grid;
96
+ grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
97
+ gap: 10px;
98
+ }
99
+ .card {
100
+ background: var(--card);
101
+ border: 1px solid var(--line);
102
+ border-radius: 10px;
103
+ padding: 10px 12px;
104
+ cursor: pointer;
105
+ transition: border-color .15s, transform .15s;
106
+ min-width: 0;
107
+ }
108
+ .card:hover { border-color: var(--accent); }
109
+ .card:active { transform: scale(.98); }
110
+ .card.selected { border-color: var(--accent); box-shadow: 0 0 0 2px var(--accent) inset; }
111
+ .card .style {
112
+ font-family: var(--mono);
113
+ font-size: 12px;
114
+ color: var(--muted);
115
+ display: flex;
116
+ justify-content: space-between;
117
+ }
118
+ .card .style .hint { opacity: 0; transition: opacity .15s; }
119
+ .card:hover .style .hint { opacity: 1; }
120
+ .card .out {
121
+ font-size: 17px;
122
+ margin-top: 2px;
123
+ overflow-wrap: anywhere;
124
+ min-height: 1.5em;
125
+ }
126
+ .card .out:empty::after { content: "\00a0"; }
127
+ footer {
128
+ margin-top: 48px;
129
+ padding-top: 20px;
130
+ border-top: 1px solid var(--line);
131
+ color: var(--muted);
132
+ font-size: 14px;
133
+ }
134
+ footer code { font-family: var(--mono); font-size: 13px; color: var(--fg); }
135
+ footer ul { padding-left: 18px; margin: 8px 0; }
136
+ footer li { margin: 2px 0; }
137
+ footer a { color: inherit; }
138
+ .toast {
139
+ position: fixed;
140
+ left: 50%;
141
+ bottom: 24px;
142
+ transform: translateX(-50%) translateY(20px);
143
+ background: var(--fg);
144
+ color: var(--bg);
145
+ padding: 8px 14px;
146
+ border-radius: 8px;
147
+ font-size: 14px;
148
+ opacity: 0;
149
+ pointer-events: none;
150
+ transition: opacity .2s, transform .2s;
151
+ }
152
+ .toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
153
+ </style>
154
+ </head>
155
+ <body>
156
+ <main>
157
+ <pre class="logo" aria-label="cAPIta"> _ ____ ___ _
158
+ ___ / \ | _ \_ _| |_ __ _
159
+ / __| / _ \ | |_) | || __/ _` |
160
+ | (__ / ___ \| __/| || || (_| |
161
+ \___/_/ \_\_| |___|\__\__,_|</pre>
162
+ <p class="tag">A tiny REST API that capitalizes strings 29 ways. Type something, click a card to copy, or just hit the URL. Powered by <a href="https://github.com/brianfunk/capstring">capstring</a>.</p>
163
+
164
+ <div class="input">
165
+ <input id="text" type="text" value="hello world" placeholder="type a phrase" autocomplete="off" autofocus>
166
+ <button id="spell" type="button" title="Run the input through the spell checker">spell check</button>
167
+ </div>
168
+ <div class="curl" id="curl"></div>
169
+
170
+ <div id="groups"></div>
171
+
172
+ <footer>
173
+ <strong>Endpoints</strong>
174
+ <ul>
175
+ <li><code>GET /:style/:text</code> or <code>/:style/:text.json|txt|html|xml|yaml|csv|jsonp</code></li>
176
+ <li><code>GET /chain/:style+:style/:text</code> apply several styles in order</li>
177
+ <li><code>POST /batch</code> <code>{"inputs":[...],"style":"kebab"}</code> up to 100 strings</li>
178
+ <li><code>GET /spell/:text</code> spell-corrected text, no API key</li>
179
+ <li><code>GET /badge/:style/:text</code> SVG badge for your README</li>
180
+ <li><code>GET /count/:text</code> words and characters, <code>GET /lorem/:n</code> placeholder text</li>
181
+ <li><code>GET /styles</code> every style name</li>
182
+ </ul>
183
+ Self-host with <code>npx @brianfunk/capita</code>. Source on <a href="https://github.com/brianfunk/cAPIta">GitHub</a>. MIT.
184
+ </footer>
185
+ </main>
186
+ <div class="toast" id="toast">copied</div>
187
+
188
+ <script>
189
+ (() => {
190
+ const GROUPS = {
191
+ 'Case': ['upper', 'lower', 'title', 'sentence', 'proper', 'swap', 'same'],
192
+ 'Code': ['camel', 'pascal', 'snake', 'kebab', 'slug', 'constant', 'python', 'dot', 'path', 'train'],
193
+ 'Fun': ['leet', 'reverse', 'sponge', 'mock', 'alternate', 'crazy', 'random', 'hashtag', 'acronym', 'rot13', 'flip', 'none']
194
+ };
195
+
196
+ const input = document.getElementById('text');
197
+ const groupsEl = document.getElementById('groups');
198
+ const curlEl = document.getElementById('curl');
199
+ const toast = document.getElementById('toast');
200
+ const cards = {};
201
+ let selected = 'title';
202
+ let timer = null;
203
+ let seq = 0;
204
+
205
+ const base = location.origin;
206
+
207
+ const buildCards = (styles) => {
208
+ const known = new Set(Object.values(GROUPS).flat());
209
+ const extra = styles.filter((s) => !known.has(s));
210
+ const groups = { ...GROUPS };
211
+ if (extra.length) groups['More'] = extra;
212
+
213
+ for (const [name, list] of Object.entries(groups)) {
214
+ const present = list.filter((s) => styles.includes(s));
215
+ if (!present.length) continue;
216
+ const h = document.createElement('h2');
217
+ h.textContent = `${name} (${present.length})`;
218
+ groupsEl.appendChild(h);
219
+ const grid = document.createElement('div');
220
+ grid.className = 'grid';
221
+ for (const style of present) {
222
+ const card = document.createElement('div');
223
+ card.className = 'card' + (style === selected ? ' selected' : '');
224
+ card.dataset.style = style;
225
+ card.innerHTML = `<div class="style"><span>${style}</span><span class="hint">click to copy</span></div><div class="out"></div>`;
226
+ card.addEventListener('click', () => select(style, true));
227
+ grid.appendChild(card);
228
+ cards[style] = card;
229
+ }
230
+ groupsEl.appendChild(grid);
231
+ }
232
+ };
233
+
234
+ const showToast = (msg) => {
235
+ toast.textContent = msg;
236
+ toast.classList.add('show');
237
+ setTimeout(() => toast.classList.remove('show'), 1200);
238
+ };
239
+
240
+ const encode = (s) => encodeURIComponent(s);
241
+
242
+ const updateCurl = () => {
243
+ const text = input.value;
244
+ const url = `${base}/${selected}/${encode(text)}.json`;
245
+ curlEl.innerHTML = `curl <a href="${url}" target="_blank" rel="noopener">${url}</a>`;
246
+ };
247
+
248
+ const select = async (style, copy) => {
249
+ selected = style;
250
+ for (const [s, c] of Object.entries(cards)) c.classList.toggle('selected', s === style);
251
+ updateCurl();
252
+ if (copy) {
253
+ const out = cards[style].querySelector('.out').textContent;
254
+ try {
255
+ await navigator.clipboard.writeText(out);
256
+ showToast(`copied "${out}"`);
257
+ } catch {
258
+ showToast('copy failed');
259
+ }
260
+ }
261
+ };
262
+
263
+ const transform = async () => {
264
+ const text = input.value;
265
+ const mine = ++seq;
266
+ updateCurl();
267
+ if (!text) {
268
+ for (const c of Object.values(cards)) c.querySelector('.out').textContent = '';
269
+ return;
270
+ }
271
+ const styles = Object.keys(cards);
272
+ await Promise.all(styles.map(async (style) => {
273
+ try {
274
+ const r = await fetch(`${base}/${style}/${encode(text)}.json`);
275
+ const data = await r.json();
276
+ if (mine !== seq) return;
277
+ cards[style].querySelector('.out').textContent = data.output ?? '';
278
+ } catch {
279
+ if (mine !== seq) return;
280
+ cards[style].querySelector('.out').textContent = '';
281
+ }
282
+ }));
283
+ };
284
+
285
+ input.addEventListener('input', () => {
286
+ clearTimeout(timer);
287
+ timer = setTimeout(transform, 180);
288
+ });
289
+
290
+ document.getElementById('spell').addEventListener('click', async () => {
291
+ const text = input.value.trim();
292
+ if (!text) return;
293
+ try {
294
+ const r = await fetch(`${base}/spell/${encode(text)}.json`);
295
+ const data = await r.json();
296
+ if (data.output && data.output !== text) {
297
+ input.value = data.output;
298
+ showToast(`"${text}" → "${data.output}"`);
299
+ transform();
300
+ } else {
301
+ showToast('looks right already');
302
+ }
303
+ } catch {
304
+ showToast('spell check failed');
305
+ }
306
+ });
307
+
308
+ fetch(`${base}/styles`)
309
+ .then((r) => r.json())
310
+ .then((d) => { buildCards(d.styles); transform(); })
311
+ .catch(() => { buildCards(Object.values(GROUPS).flat()); transform(); });
312
+ })();
313
+ </script>
314
+ </body>
315
+ </html>