@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 +21 -0
- package/README.md +244 -0
- package/app.js +445 -0
- package/index.js +28 -0
- package/package.json +76 -0
- package/public/index.html +315 -0
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
|
+
[](https://github.com/brianfunk/cAPIta)
|
|
2
|
+
[](https://www.npmjs.com/package/@brianfunk/capita)
|
|
3
|
+
[](https://www.npmjs.com/package/@brianfunk/capita)
|
|
4
|
+
[](https://capita-api.netlify.app)
|
|
5
|
+
[](https://github.com/brianfunk/cAPIta/actions/workflows/ci.yml)
|
|
6
|
+
[](https://github.com/ellerbrock/open-source-badge/)
|
|
7
|
+
[](http://semver.org/spec/v2.0.0.html)
|
|
8
|
+
[](https://opensource.org/licenses/MIT)
|
|
9
|
+
[](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
|
+
'&': '&',
|
|
54
|
+
'<': '<',
|
|
55
|
+
'>': '>',
|
|
56
|
+
'"': '"',
|
|
57
|
+
"'": '''
|
|
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>
|