book-cover-generator 1.7.0 → 1.8.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/README.md +43 -3
- package/bin/book-cover.js +52 -24
- package/package.json +13 -4
- package/src/element.d.ts +21 -0
- package/src/element.js +134 -0
- package/src/index.js +1 -1
- package/src/link.js +194 -1
- package/src/mcp.js +378 -0
package/README.md
CHANGED
|
@@ -5,9 +5,10 @@
|
|
|
5
5
|
[](https://covers.otsirh.dev)
|
|
6
6
|

|
|
7
7
|

|
|
8
|
+
[](https://scorecard.dev/viewer/?uri=github.com/otsirh/book-cover-generator)
|
|
8
9
|
[](LICENSE)
|
|
9
10
|
|
|
10
|
-
Generate book covers from a **title** and an **author**. 39 styles, 28 palettes or your own colours, a seeded randomizer, any size, and export to **SVG, PNG, JPEG or WebP**. The library has no dependencies and ships TypeScript types. It's written as plain ES modules, so it runs in the browser and
|
|
11
|
+
Generate book covers from a **title** and an **author**. 39 styles, 28 palettes or your own colours, a seeded randomizer, any size, and export to **SVG, PNG, JPEG or WebP**. The library has no dependencies and ships TypeScript types. It's written as plain ES modules, so it runs in the browser, Node, Deno and Bun.
|
|
11
12
|
|
|
12
13
|
**[▶ Open the generator](https://covers.otsirh.dev)**
|
|
13
14
|
|
|
@@ -82,6 +83,42 @@ The query takes the options' own names (`title`, `author`, `subtitle`, `series`,
|
|
|
82
83
|
|
|
83
84
|
A title breaks its line at `%0A`. Anyone may embed the answers (CORS and `Cross-Origin-Resource-Policy` allow it), and the headers `X-Cover-Style`, `X-Cover-Palette` and `X-Cover-Version` say what was drawn. The same URL always draws the same cover in a version of the library, so answers are cached for a day. Options the library rejects get a `400` with the reason as plain text.
|
|
84
85
|
|
|
86
|
+
## On any page: `<book-cover>`
|
|
87
|
+
|
|
88
|
+
One script, then a cover wherever you write the element:
|
|
89
|
+
|
|
90
|
+
```html
|
|
91
|
+
<script type="module" src="https://covers.otsirh.dev/src/element.js"></script>
|
|
92
|
+
|
|
93
|
+
<book-cover cover-title="Dune" author="Frank Herbert" cover-style="retro" seed="1"></book-cover>
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Its attributes are the app's link parameters, except the title and the style, which are `cover-title` and `cover-style` (`title` and `style` are HTML's own): `cover-title`, `author`, `subtitle`, `series`, `credit`, `badge`, `cover-style`, `genre`, `palette`, `seed`, `format`, `font`, `case`, `variant`, `texture` (present: on) and `lang` (the language of the text, such as `zh-Hant`). Styles, palettes and fonts may be named as the app's menus name them. The cover is 240 px wide unless you style the element's `width`; it draws again when an attribute changes, and fires `cover` (with the cover) or `cover-error` (with what's wrong). It is drawn in the page, measuring the text with the fonts installed there, and shown as an image, so a page with Trusted Types can show it (its CSP must allow `data:` images). An image can't use the page's web fonts: if the page loads one that a cover's font stack names (such as EB Garamond or Oswald), the text is measured in it but drawn in the system's font, and may fit less well. From npm: `import 'book-cover-generator/element'`.
|
|
97
|
+
|
|
98
|
+
## From an AI assistant (MCP)
|
|
99
|
+
|
|
100
|
+
Book Cover Generator is a [Model Context Protocol](https://modelcontextprotocol.io) server, so an assistant such as Claude can make covers for you: "a cover for my horror novel _The Hollow Night_, in red". It answers with a link to the image and one that opens the cover in the app to change and download.
|
|
101
|
+
|
|
102
|
+
Add it to Claude Code:
|
|
103
|
+
|
|
104
|
+
```sh
|
|
105
|
+
claude mcp add book-covers -- npx -y book-cover-generator --mcp
|
|
106
|
+
# or, with nothing installed, the hosted server:
|
|
107
|
+
claude mcp add --transport http book-covers https://covers.otsirh.dev/mcp
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Or to any client that takes a JSON configuration (Claude Desktop, Cursor and others):
|
|
111
|
+
|
|
112
|
+
```json
|
|
113
|
+
{
|
|
114
|
+
"mcpServers": {
|
|
115
|
+
"book-covers": { "command": "npx", "args": ["-y", "book-cover-generator", "--mcp"] }
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
The tools: `create_cover` (a title, and any of author, subtitle, style, genre, palette or your own colours, seed, format, font, pins), `create_mockup` (the same, as a 3D book, on an e-reader or on a social card), `list_styles` and `list_palettes`. Run locally, `create_cover` can also save the SVG to a file (`output`).
|
|
121
|
+
|
|
85
122
|
## Library usage
|
|
86
123
|
|
|
87
124
|
### Browser (no install)
|
|
@@ -101,7 +138,7 @@ A title breaks its line at `%0A`. Anyone may embed the answers (CORS and `Cross-
|
|
|
101
138
|
|
|
102
139
|
`@1` gives you the latest 1.x release. Pin an exact version (such as `@1.1.0`) if you need the artwork to never change.
|
|
103
140
|
|
|
104
|
-
### Node
|
|
141
|
+
### Node, Deno, Bun and bundlers
|
|
105
142
|
|
|
106
143
|
```sh
|
|
107
144
|
npm install book-cover-generator
|
|
@@ -122,6 +159,8 @@ const { svg, style, palette, seed } = createCover({
|
|
|
122
159
|
writeFileSync('cover.svg', svg);
|
|
123
160
|
```
|
|
124
161
|
|
|
162
|
+
In Deno, import `npm:book-cover-generator`; in Bun, `bun add book-cover-generator`. CI checks that Node, Deno and Bun draw the same covers byte for byte.
|
|
163
|
+
|
|
125
164
|
### CLI
|
|
126
165
|
|
|
127
166
|
```sh
|
|
@@ -301,9 +340,10 @@ npm run dev # http://127.0.0.1:4173
|
|
|
301
340
|
|
|
302
341
|
| script | what it does |
|
|
303
342
|
| ----------------------- | ----------------------------------------------------------------- |
|
|
304
|
-
| `npm run check` | lint, format
|
|
343
|
+
| `npm run check` | lint, format, type and package checks, unit tests and fuzzing |
|
|
305
344
|
| `npm test` | unit tests (`node --test`) |
|
|
306
345
|
| `npm run test:coverage` | unit tests with coverage thresholds |
|
|
346
|
+
| `npm run test:fuzz` | property-based fuzzing (fast-check); `FUZZ_RUNS=20` for more |
|
|
307
347
|
| `npm run test:e2e` | browser tests in Chromium, Firefox, WebKit and mobile Chrome |
|
|
308
348
|
| `npm run format` | format everything with Prettier |
|
|
309
349
|
| `npm run assets` | regenerate the app icons, the social preview and the README image |
|
package/bin/book-cover.js
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
3
|
-
import { dirname, join } from 'node:path';
|
|
3
|
+
import { dirname, isAbsolute, join, resolve } from 'node:path';
|
|
4
|
+
import { createInterface } from 'node:readline';
|
|
4
5
|
import { parseArgs } from 'node:util';
|
|
5
|
-
import { createCover, listStyles, palettes,
|
|
6
|
+
import { createCover, listStyles, palettes, parseBookList, optionsForBook, genres } from '../src/index.js';
|
|
6
7
|
import { hex, slug } from '../src/shared.js';
|
|
8
|
+
import { px, formatSize } from '../src/link.js';
|
|
9
|
+
import { handlePayload } from '../src/mcp.js';
|
|
7
10
|
|
|
8
11
|
const HELP = `Usage: book-cover "<title>" ["<author>"] [options]
|
|
9
12
|
|
|
@@ -27,7 +30,9 @@ Options:
|
|
|
27
30
|
--badge <text> a seal in a corner clear of the text ("Bestseller")
|
|
28
31
|
--texture add a fine film-grain texture
|
|
29
32
|
--seed <value> seed for the artwork (default: title + author)
|
|
30
|
-
--format <shape> paperback (2:3, the default), ebook (5:8) or
|
|
33
|
+
--format <shape> paperback (2:3, the default), ebook (5:8), square, or a
|
|
34
|
+
shape such as 1600x2560; with --width or --height, that
|
|
35
|
+
shape at that width or height
|
|
31
36
|
--width <px> width (default 600)
|
|
32
37
|
--height <px> height (default: from the format)
|
|
33
38
|
--size <WxH> width and height at once, e.g. 1600x2560
|
|
@@ -45,6 +50,8 @@ Options:
|
|
|
45
50
|
--json print the choices (and the SVG, without --out) as JSON,
|
|
46
51
|
one line per cover
|
|
47
52
|
--list list styles and palettes
|
|
53
|
+
--mcp serve the Model Context Protocol on stdin and stdout, so an
|
|
54
|
+
AI assistant can make covers (see the README)
|
|
48
55
|
--help show this help
|
|
49
56
|
|
|
50
57
|
Examples:
|
|
@@ -86,6 +93,7 @@ try {
|
|
|
86
93
|
json: { type: 'boolean' },
|
|
87
94
|
list: { type: 'boolean' },
|
|
88
95
|
help: { type: 'boolean' },
|
|
96
|
+
mcp: { type: 'boolean' },
|
|
89
97
|
},
|
|
90
98
|
});
|
|
91
99
|
} catch (err) {
|
|
@@ -100,6 +108,10 @@ const { values, positionals } = args;
|
|
|
100
108
|
* set, not exited with, so output piped elsewhere is written in full first.)
|
|
101
109
|
*/
|
|
102
110
|
function preflight() {
|
|
111
|
+
if (values.mcp) {
|
|
112
|
+
serveMcp();
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
103
115
|
if (values.help) {
|
|
104
116
|
console.log(HELP);
|
|
105
117
|
return false;
|
|
@@ -137,17 +149,6 @@ function preflight() {
|
|
|
137
149
|
return true;
|
|
138
150
|
}
|
|
139
151
|
|
|
140
|
-
/**
|
|
141
|
-
* A --width/--height value as a number, reporting what was typed if it isn't one.
|
|
142
|
-
* @param {string | undefined} value @param {string} name
|
|
143
|
-
*/
|
|
144
|
-
function size(value, name) {
|
|
145
|
-
if (value === undefined) return undefined;
|
|
146
|
-
const number = Number(value);
|
|
147
|
-
if (!value.trim() || !Number.isFinite(number)) throw new RangeError(`--${name} must be a number, got "${value}"`);
|
|
148
|
-
return number;
|
|
149
|
-
}
|
|
150
|
-
|
|
151
152
|
/**
|
|
152
153
|
* Width and height from --format, --width, --height and --size.
|
|
153
154
|
* @returns {{ width?: number, height?: number }}
|
|
@@ -161,17 +162,12 @@ function dimensions() {
|
|
|
161
162
|
if (!m) throw new RangeError(`--size must look like 1600x2560, got "${values.size}"`);
|
|
162
163
|
return { width: Number(m[1]), height: Number(m[2]) };
|
|
163
164
|
}
|
|
164
|
-
const width =
|
|
165
|
-
const height =
|
|
165
|
+
const width = values.width === undefined ? undefined : px('--width', values.width);
|
|
166
|
+
const height = values.height === undefined ? undefined : px('--height', values.height);
|
|
166
167
|
if (values.format === undefined) return { width, height };
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
}
|
|
170
|
-
if (height !== undefined) throw new Error('--format sets the height from the width: use --size for both');
|
|
171
|
-
// The shape of the app's format, at the width asked for.
|
|
172
|
-
const [w, h] = formats[/** @type {keyof typeof formats} */ (values.format)];
|
|
173
|
-
const W = width ?? 600;
|
|
174
|
-
return { width: W, height: Math.round((W * h) / w) };
|
|
168
|
+
// A preset or a WxH shape, at the width or height asked for (as the HTTP API reads it).
|
|
169
|
+
const [w, h] = formatSize(values.format, width, height);
|
|
170
|
+
return { width: w, height: h };
|
|
175
171
|
}
|
|
176
172
|
|
|
177
173
|
/** The palette: an id, or your own colours from --bg, --ink and --accent. */
|
|
@@ -338,4 +334,36 @@ function run() {
|
|
|
338
334
|
}
|
|
339
335
|
}
|
|
340
336
|
|
|
337
|
+
/**
|
|
338
|
+
* Serve MCP over stdio: a JSON-RPC message per line in, an answer per line out (nothing else on
|
|
339
|
+
* stdout). A cover can be saved to a file, at an absolute path (the host picks the folder it runs in).
|
|
340
|
+
*/
|
|
341
|
+
function serveMcp() {
|
|
342
|
+
const lines = createInterface({ input: process.stdin });
|
|
343
|
+
/** @param {string} path @param {string} data */
|
|
344
|
+
const writeFile = (path, data) => {
|
|
345
|
+
// (A host starts the server in a folder of its own choosing: a relative path would land there.)
|
|
346
|
+
if (!isAbsolute(path)) throw new Error(`output must be an absolute path, got "${path}"`);
|
|
347
|
+
const file = resolve(path);
|
|
348
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
349
|
+
writeFileSync(file, data);
|
|
350
|
+
return file;
|
|
351
|
+
};
|
|
352
|
+
/** @param {unknown} message */
|
|
353
|
+
const answer = (message) => {
|
|
354
|
+
if (message) process.stdout.write(`${JSON.stringify(message)}\n`);
|
|
355
|
+
};
|
|
356
|
+
lines.on('line', (line) => {
|
|
357
|
+
if (!line.trim()) return;
|
|
358
|
+
let message;
|
|
359
|
+
try {
|
|
360
|
+
message = JSON.parse(line);
|
|
361
|
+
} catch {
|
|
362
|
+
answer({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error' } });
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
answer(handlePayload(message, { writeFile }));
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
|
|
341
369
|
if (preflight()) run();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "book-cover-generator",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.8.0",
|
|
4
4
|
"description": "Generate SVG book covers from a title and an author. 39 styles, 28 palettes, seeded randomizer, zero dependencies.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"book",
|
|
@@ -23,7 +23,9 @@
|
|
|
23
23
|
"license": "MIT",
|
|
24
24
|
"author": "otsirh (https://github.com/otsirh)",
|
|
25
25
|
"type": "module",
|
|
26
|
-
"sideEffects":
|
|
26
|
+
"sideEffects": [
|
|
27
|
+
"./src/element.js"
|
|
28
|
+
],
|
|
27
29
|
"exports": {
|
|
28
30
|
".": {
|
|
29
31
|
"types": "./src/index.d.ts",
|
|
@@ -33,6 +35,10 @@
|
|
|
33
35
|
"types": "./src/browser.d.ts",
|
|
34
36
|
"default": "./src/browser.js"
|
|
35
37
|
},
|
|
38
|
+
"./element": {
|
|
39
|
+
"types": "./src/element.d.ts",
|
|
40
|
+
"default": "./src/element.js"
|
|
41
|
+
},
|
|
36
42
|
"./package.json": "./package.json"
|
|
37
43
|
},
|
|
38
44
|
"main": "./src/index.js",
|
|
@@ -51,14 +57,15 @@
|
|
|
51
57
|
"dev": "node scripts/serve.js 4173",
|
|
52
58
|
"build": "node scripts/build.js",
|
|
53
59
|
"lint": "eslint .",
|
|
54
|
-
"lint:package": "publint --strict && attw --pack . --profile esm-only",
|
|
60
|
+
"lint:package": "publint --strict && attw --pack . --profile esm-only && node scripts/check-bundle.js",
|
|
55
61
|
"format": "prettier --write .",
|
|
56
62
|
"format:check": "prettier --check .",
|
|
57
63
|
"typecheck": "tsc -p tsconfig.json",
|
|
58
64
|
"test": "node --test",
|
|
59
65
|
"test:coverage": "node --test --experimental-test-coverage --test-coverage-include=src/**/*.js --test-coverage-exclude=src/browser.js --test-coverage-lines=99 --test-coverage-branches=95 --test-coverage-functions=98",
|
|
66
|
+
"test:fuzz": "node --test fuzz/*.fuzz.js",
|
|
60
67
|
"test:e2e": "playwright test",
|
|
61
|
-
"check": "npm run lint && npm run format:check && npm run typecheck && npm run lint:package && npm test",
|
|
68
|
+
"check": "npm run lint && npm run format:check && npm run typecheck && npm run lint:package && npm test && npm run test:fuzz",
|
|
62
69
|
"assets": "node scripts/generate-assets.js",
|
|
63
70
|
"metrics": "node scripts/generate-metrics.js",
|
|
64
71
|
"deploy": "npm run check && npm run test:e2e && wrangler deploy"
|
|
@@ -69,7 +76,9 @@
|
|
|
69
76
|
"@eslint/js": "10.0.1",
|
|
70
77
|
"@playwright/test": "1.65.0",
|
|
71
78
|
"@types/node": "26.6.6",
|
|
79
|
+
"esbuild": "0.28.2",
|
|
72
80
|
"eslint": "10.14.0",
|
|
81
|
+
"fast-check": "4.10.2",
|
|
73
82
|
"globals": "17.14.0",
|
|
74
83
|
"prettier": "3.10.0",
|
|
75
84
|
"publint": "0.3.25",
|
package/src/element.d.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { CoverOptions, createCover } from './index.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `<book-cover>`: a cover on any page, from its attributes (cover-title, author, subtitle, series,
|
|
5
|
+
* credit, badge, cover-style, genre, palette, seed, format, font, case, texture, variant, lang). It
|
|
6
|
+
* fires `cover` (detail: the cover) when it draws, and `cover-error` (detail: the error) when the
|
|
7
|
+
* attributes can't make one.
|
|
8
|
+
*/
|
|
9
|
+
export declare class BookCover extends HTMLElement {
|
|
10
|
+
static observedAttributes: string[];
|
|
11
|
+
/** The cover drawn last, or null (before the first, or after an error). */
|
|
12
|
+
cover: ReturnType<typeof createCover> | null;
|
|
13
|
+
/** The cover's options, read from the attributes as the HTTP API reads a link. */
|
|
14
|
+
options(): CoverOptions;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
declare global {
|
|
18
|
+
interface HTMLElementTagNameMap {
|
|
19
|
+
'book-cover': BookCover;
|
|
20
|
+
}
|
|
21
|
+
}
|
package/src/element.js
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// <book-cover>: a cover on any page, from its attributes.
|
|
2
|
+
//
|
|
3
|
+
// <script type="module" src="https://covers.otsirh.dev/src/element.js"></script>
|
|
4
|
+
// <book-cover cover-title="Dune" author="Frank Herbert" cover-style="retro"></book-cover>
|
|
5
|
+
//
|
|
6
|
+
// Attributes: cover-title and cover-style (`title` and `style` are HTML's own: a tooltip, the
|
|
7
|
+
// element's CSS), author, subtitle, series, credit, badge, genre, palette, seed, format (paperback,
|
|
8
|
+
// ebook, square or 1600x2560), font, case, texture (present: on), variant (as in the app's links:
|
|
9
|
+
// scene.forest) and lang (the language of the text, as on any element). The cover
|
|
10
|
+
// is drawn as an image (so pages with Trusted Types can show it: the page's CSP must allow data:
|
|
11
|
+
// images), sized by the element's width (240px unless styled).
|
|
12
|
+
|
|
13
|
+
import { createCover } from './index.js';
|
|
14
|
+
import { svgToDataUrl } from './browser.js';
|
|
15
|
+
import { LINK, optionsFromLink } from './link.js';
|
|
16
|
+
|
|
17
|
+
/** The attributes that draw the cover, and the link parameter each is. */
|
|
18
|
+
const ATTRIBUTES = /** @type {const} */ ({
|
|
19
|
+
'cover-title': 'title',
|
|
20
|
+
author: 'author',
|
|
21
|
+
subtitle: 'subtitle',
|
|
22
|
+
series: 'series',
|
|
23
|
+
credit: 'credit',
|
|
24
|
+
badge: 'badge',
|
|
25
|
+
'cover-style': 'style',
|
|
26
|
+
genre: 'genre',
|
|
27
|
+
palette: 'palette',
|
|
28
|
+
seed: 'seed',
|
|
29
|
+
format: 'format',
|
|
30
|
+
font: 'font',
|
|
31
|
+
case: 'case',
|
|
32
|
+
variant: 'variant',
|
|
33
|
+
lang: 'lang',
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const CSS =
|
|
37
|
+
':host{display:inline-block;width:240px;vertical-align:top}:host([hidden]){display:none}' +
|
|
38
|
+
'img{display:block;width:100%;height:auto}';
|
|
39
|
+
/** The element's styles as a constructed sheet: a page whose CSP allows no inline styles still applies it. */
|
|
40
|
+
const sheet =
|
|
41
|
+
typeof CSSStyleSheet !== 'undefined' && 'replaceSync' in CSSStyleSheet.prototype
|
|
42
|
+
? (() => {
|
|
43
|
+
const made = new CSSStyleSheet();
|
|
44
|
+
made.replaceSync(CSS);
|
|
45
|
+
return made;
|
|
46
|
+
})()
|
|
47
|
+
: null;
|
|
48
|
+
|
|
49
|
+
// (In Node there is no HTMLElement: the module still loads, and defines nothing.)
|
|
50
|
+
const Base = /** @type {typeof HTMLElement} */ (typeof HTMLElement === 'undefined' ? class {} : HTMLElement);
|
|
51
|
+
|
|
52
|
+
export class BookCover extends Base {
|
|
53
|
+
static observedAttributes = [...Object.keys(ATTRIBUTES), 'texture'];
|
|
54
|
+
|
|
55
|
+
/** The cover drawn last (null before the first, or after an error). @type {ReturnType<typeof createCover> | null} */
|
|
56
|
+
cover = null;
|
|
57
|
+
/** @type {HTMLImageElement} */
|
|
58
|
+
#img;
|
|
59
|
+
#queued = false;
|
|
60
|
+
/** Drawn once connected: until then, attributes set (or present when upgraded) wait for it. */
|
|
61
|
+
#ready = false;
|
|
62
|
+
/** The options last drawn, to skip drawing them again. */
|
|
63
|
+
#drawn = '';
|
|
64
|
+
|
|
65
|
+
constructor() {
|
|
66
|
+
super();
|
|
67
|
+
const root = this.attachShadow({ mode: 'open' });
|
|
68
|
+
if (sheet && 'adoptedStyleSheets' in root) root.adoptedStyleSheets = [sheet];
|
|
69
|
+
else {
|
|
70
|
+
const style = document.createElement('style');
|
|
71
|
+
style.textContent = CSS;
|
|
72
|
+
root.append(style);
|
|
73
|
+
}
|
|
74
|
+
this.#img = document.createElement('img');
|
|
75
|
+
this.#img.decoding = 'async';
|
|
76
|
+
this.#img.setAttribute('part', 'image');
|
|
77
|
+
root.append(this.#img);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
connectedCallback() {
|
|
81
|
+
this.#ready = true;
|
|
82
|
+
this.#render();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
attributeChangedCallback() {
|
|
86
|
+
// (Several attributes set at once draw once; an element upgraded on the page draws once, when
|
|
87
|
+
// it connects.)
|
|
88
|
+
if (!this.#ready || this.#queued || !this.isConnected) return;
|
|
89
|
+
this.#queued = true;
|
|
90
|
+
queueMicrotask(() => {
|
|
91
|
+
this.#queued = false;
|
|
92
|
+
this.#render();
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** The cover's options, read from the attributes as the HTTP API reads a link. */
|
|
97
|
+
options() {
|
|
98
|
+
const q = new URLSearchParams();
|
|
99
|
+
for (const [attribute, key] of Object.entries(ATTRIBUTES)) {
|
|
100
|
+
const value = this.getAttribute(attribute);
|
|
101
|
+
if (value != null) q.set(key, value);
|
|
102
|
+
}
|
|
103
|
+
// (Present is on, as a boolean attribute; 0 and false, as links write them, are off.)
|
|
104
|
+
const texture = this.getAttribute('texture');
|
|
105
|
+
if (texture !== null) q.set('texture', LINK.texture(texture) === false ? '0' : '1');
|
|
106
|
+
return optionsFromLink(q);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
#render() {
|
|
110
|
+
try {
|
|
111
|
+
const options = this.options();
|
|
112
|
+
// (Moved or reconnected with the same attributes: the cover stands.)
|
|
113
|
+
const key = JSON.stringify(options);
|
|
114
|
+
if (this.cover && key === this.#drawn) return;
|
|
115
|
+
this.#drawn = key;
|
|
116
|
+
this.cover = createCover(options);
|
|
117
|
+
this.#img.src = svgToDataUrl(this.cover.svg);
|
|
118
|
+
this.#img.alt = this.cover.author
|
|
119
|
+
? `Cover: ${this.cover.title.replace(/\n/g, ' ')} by ${this.cover.author}`
|
|
120
|
+
: `Cover: ${this.cover.title.replace(/\n/g, ' ')}`;
|
|
121
|
+
this.dispatchEvent(new CustomEvent('cover', { detail: this.cover }));
|
|
122
|
+
} catch (err) {
|
|
123
|
+
this.cover = null;
|
|
124
|
+
this.#drawn = '';
|
|
125
|
+
this.#img.removeAttribute('src');
|
|
126
|
+
this.#img.alt = '';
|
|
127
|
+
this.dispatchEvent(new CustomEvent('cover-error', { detail: err }));
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (typeof customElements !== 'undefined' && !customElements.get('book-cover')) {
|
|
133
|
+
customElements.define('book-cover', BookCover);
|
|
134
|
+
}
|
package/src/index.js
CHANGED
|
@@ -31,7 +31,7 @@ export { createMockup, mockupKinds } from './mockups.js';
|
|
|
31
31
|
export { parseBookList } from './books.js';
|
|
32
32
|
|
|
33
33
|
/** This version of the library (artwork for a seed can change between minor versions). */
|
|
34
|
-
export const version = '1.
|
|
34
|
+
export const version = '1.8.0';
|
|
35
35
|
|
|
36
36
|
/** The sizes a cover can have: each side `min` to `max` px, and neither more than `ratio` times the other. */
|
|
37
37
|
export const sizeLimits = Object.freeze({ min: 50, max: 10000, ratio: 3 });
|
package/src/link.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// design the app stored) and gives the field's value, or undefined for one it can't read: the app
|
|
4
4
|
// drops it, the API says so. (Not part of the library's API.)
|
|
5
5
|
|
|
6
|
-
import { styleIdFor, palettes, formats, sizeLimits } from './index.js';
|
|
6
|
+
import { styleIdFor, palettes, formats, sizeLimits, version } from './index.js';
|
|
7
7
|
import { LINE_BREAK, clipGraphemes } from './text.js';
|
|
8
8
|
import {
|
|
9
9
|
idFor,
|
|
@@ -14,6 +14,8 @@ import {
|
|
|
14
14
|
CUSTOM_PALETTE,
|
|
15
15
|
CUSTOM_SIZE,
|
|
16
16
|
VARIANT,
|
|
17
|
+
customColors,
|
|
18
|
+
hex,
|
|
17
19
|
} from './shared.js';
|
|
18
20
|
|
|
19
21
|
/** Line breaks as spaces (a single-line field: the cover spaces them). @param {unknown} v */
|
|
@@ -78,9 +80,200 @@ export const LINK = {
|
|
|
78
80
|
},
|
|
79
81
|
};
|
|
80
82
|
|
|
83
|
+
/**
|
|
84
|
+
* @typedef {object} Field
|
|
85
|
+
* @property {(v: unknown) => unknown} read the value from a link or a stored design; undefined drops it
|
|
86
|
+
* @property {(v: any) => string} [write] the value as a link writes it (default: as it is)
|
|
87
|
+
* @property {unknown} empty the value of a design that leaves it out (see complete())
|
|
88
|
+
* @property {boolean} [optional] links may leave it out and still be whole designs
|
|
89
|
+
* @property {boolean} [omitEmpty] links leave it out when it is empty
|
|
90
|
+
*/
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Every field of a design, in the order links name them. A field added later is `optional`, so
|
|
94
|
+
* links made before it existed still open as whole designs (with its `empty` value).
|
|
95
|
+
* @type {Record<string, Field>}
|
|
96
|
+
*/
|
|
97
|
+
export const LINK_FIELDS = {
|
|
98
|
+
// (Each read by LINK, as the HTTP API reads links too: one link draws one design.)
|
|
99
|
+
// (Line breaks kept since 1.5.0: the cover breaks the title there.)
|
|
100
|
+
title: { read: LINK.title, empty: '' },
|
|
101
|
+
author: { read: LINK.author, empty: '' },
|
|
102
|
+
// (Optional since 1.2.0: older links left it out when empty.)
|
|
103
|
+
subtitle: { read: LINK.subtitle, empty: '', optional: true },
|
|
104
|
+
// (Since 1.5.0.)
|
|
105
|
+
series: { read: LINK.series, empty: '', optional: true, omitEmpty: true },
|
|
106
|
+
credit: { read: LINK.credit, empty: '', optional: true, omitEmpty: true },
|
|
107
|
+
// (Since 1.6.0.)
|
|
108
|
+
badge: { read: LINK.badge, empty: '', optional: true, omitEmpty: true },
|
|
109
|
+
style: { read: LINK.style, empty: '' },
|
|
110
|
+
// (Since 1.5.0.) Checked against the style when used (the app's pinsFor()).
|
|
111
|
+
variant: { read: LINK.variant, empty: '', optional: true, omitEmpty: true },
|
|
112
|
+
// (Since 1.5.0.)
|
|
113
|
+
font: { read: LINK.font, empty: '', optional: true, omitEmpty: true },
|
|
114
|
+
case: { read: LINK.case, empty: '', optional: true, omitEmpty: true },
|
|
115
|
+
palette: { read: LINK.palette, empty: 'auto' },
|
|
116
|
+
seed: { read: LINK.seed, empty: '' },
|
|
117
|
+
format: { read: LINK.format, empty: 'paperback' },
|
|
118
|
+
// On or off; any other value (texture=yes) is dropped like other unknown values.
|
|
119
|
+
texture: { read: LINK.texture, write: (v) => (v ? '1' : '0'), empty: false, optional: true },
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* A design's link (the part of an app link after #, the HTTP API's query): every field, even when
|
|
124
|
+
* empty or off, so a link is the whole design, never mistaken for a hand-edited partial one (which
|
|
125
|
+
* changes only the keys it names); last, the version that drew it (artwork can change between
|
|
126
|
+
* minor versions).
|
|
127
|
+
* @param {Record<string, unknown>} design
|
|
128
|
+
*/
|
|
129
|
+
export function writeLink(design) {
|
|
130
|
+
const q = new URLSearchParams();
|
|
131
|
+
for (const [key, field] of Object.entries(LINK_FIELDS)) {
|
|
132
|
+
if (field.omitEmpty && design[key] === field.empty) continue;
|
|
133
|
+
q.set(key, field.write?.(design[key]) ?? String(design[key]));
|
|
134
|
+
}
|
|
135
|
+
q.set('v', version);
|
|
136
|
+
return q;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Your own colours (background, text, one to three accents) as a link's palette: each as six hex
|
|
141
|
+
* digits (#abc is #aabbcc; an alpha is dropped).
|
|
142
|
+
* @param {readonly string[]} colors
|
|
143
|
+
*/
|
|
144
|
+
export const customPalette = (colors) => `custom-${colors.map((c) => sixDigits(c).slice(1)).join('-')}`;
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* A colour as #rrggbb, lower-case: #abc as #aabbcc, and an alpha (#abcd, #aabbccdd) dropped. Any
|
|
148
|
+
* other value is left as it is, for the reader to refuse.
|
|
149
|
+
* @param {string} c
|
|
150
|
+
*/
|
|
151
|
+
export function sixDigits(c) {
|
|
152
|
+
const h = c.trim().replace(/^#/, '').toLowerCase();
|
|
153
|
+
if (!/^[0-9a-f]+$/.test(h)) return c;
|
|
154
|
+
if (h.length === 3 || h.length === 4) return `#${[...h.slice(0, 3)].map((x) => x + x).join('')}`;
|
|
155
|
+
if (h.length === 6 || h.length === 8) return `#${h.slice(0, 6)}`;
|
|
156
|
+
return c;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** A design's pins as a link holds them (sorted by name, so the same pins make the same link). @param {Record<string, string>} pins */
|
|
160
|
+
export const variantOf = (pins) =>
|
|
161
|
+
Object.keys(pins)
|
|
162
|
+
.sort()
|
|
163
|
+
.map((name) => `${name}.${pins[name]}`)
|
|
164
|
+
.join('-');
|
|
165
|
+
|
|
81
166
|
/**
|
|
82
167
|
* The value of each key in a query, the last when it is given more than once (as an app link's
|
|
83
168
|
* reader, `Object.fromEntries`, takes it).
|
|
84
169
|
* @param {URLSearchParams} q
|
|
85
170
|
*/
|
|
86
171
|
export const lastValues = (q) => /** @type {Record<string, string>} */ (Object.fromEntries(q));
|
|
172
|
+
|
|
173
|
+
/** A size in px, as the CLI reads one (the library checks its range), else an error naming the parameter. @param {string} key @param {string} raw */
|
|
174
|
+
export function px(key, raw) {
|
|
175
|
+
const value = Number(raw);
|
|
176
|
+
if (!raw.trim() || !Number.isFinite(value)) throw new RangeError(`${key} must be a number, got "${raw}"`);
|
|
177
|
+
return value;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* A cover's size from a format (a preset, or any WxH shape) and a width or height that scales it,
|
|
182
|
+
* as the HTTP API and the CLI read them; the format's own size with neither. The library checks
|
|
183
|
+
* the size it comes to.
|
|
184
|
+
* @param {string} format @param {number | undefined} width @param {number | undefined} height
|
|
185
|
+
* @returns {[number, number]}
|
|
186
|
+
*/
|
|
187
|
+
export function formatSize(format, width, height) {
|
|
188
|
+
const read = format.trim();
|
|
189
|
+
const size = Object.hasOwn(formats, read)
|
|
190
|
+
? formats[/** @type {keyof typeof formats} */ (read)]
|
|
191
|
+
: CUSTOM_SIZE.exec(read)?.slice(1).map(Number);
|
|
192
|
+
if (!size) {
|
|
193
|
+
throw new RangeError(
|
|
194
|
+
`Unknown format "${format}". Use one of ${Object.keys(formats).join(', ')}, or a size such as 1600x2560`,
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
if (width !== undefined && height !== undefined) {
|
|
198
|
+
throw new RangeError('A format sets the shape: give it a width or a height, not both');
|
|
199
|
+
}
|
|
200
|
+
const [w, h] = size;
|
|
201
|
+
if (width !== undefined) return [width, Math.round((width * h) / w)];
|
|
202
|
+
if (height !== undefined) return [Math.round((height * w) / h), height];
|
|
203
|
+
return [w, h];
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Cover options from a link's query (the HTTP API's). The app's link parameters are read as the
|
|
208
|
+
* app reads them, the last of a repeated one too, except that a value the app would drop is an
|
|
209
|
+
* error here. Also the library's option names (`titleCase`, `genre`, `lang`, `width`, `height`)
|
|
210
|
+
* and your own colours as `bg`, `ink` and `accent` (hex, the # optional, accents separated by
|
|
211
|
+
* commas).
|
|
212
|
+
* @param {URLSearchParams} q
|
|
213
|
+
* @returns {import('./index.js').CoverOptions}
|
|
214
|
+
*/
|
|
215
|
+
export function optionsFromLink(q) {
|
|
216
|
+
const v = lastValues(q);
|
|
217
|
+
/** A parameter's value, if it has one. @param {string} key */
|
|
218
|
+
const given = (key) => (v[key] != null && v[key] !== '' ? v[key] : undefined);
|
|
219
|
+
/** @type {Record<string, unknown>} */
|
|
220
|
+
const o = {};
|
|
221
|
+
for (const key of /** @type {const} */ (['title', 'author', 'subtitle', 'series', 'credit', 'badge', 'seed'])) {
|
|
222
|
+
const value = given(key) === undefined ? undefined : LINK[key](v[key]);
|
|
223
|
+
if (value) o[key] = value;
|
|
224
|
+
}
|
|
225
|
+
// Styles, fonts and palettes the reader doesn't know go to the library as typed: it picks for
|
|
226
|
+
// 'random', and says what's wrong with the others.
|
|
227
|
+
for (const key of /** @type {const} */ (['style', 'font'])) {
|
|
228
|
+
const raw = given(key);
|
|
229
|
+
if (raw !== undefined) o[key] = LINK[key](raw) ?? raw;
|
|
230
|
+
}
|
|
231
|
+
for (const key of ['genre', 'lang']) if (given(key) !== undefined) o[key] = given(key);
|
|
232
|
+
const palette = given('palette');
|
|
233
|
+
if (palette !== undefined) {
|
|
234
|
+
const read = LINK.palette(palette);
|
|
235
|
+
const colors = read ? customColors(read) : null;
|
|
236
|
+
if (colors) o.palette = { bg: colors[0], ink: colors[1], accents: colors.slice(2) };
|
|
237
|
+
else if (read !== 'auto') o.palette = read ?? palette;
|
|
238
|
+
}
|
|
239
|
+
const caseKey = given('case') !== undefined ? 'case' : 'titleCase';
|
|
240
|
+
const titleCase = given(caseKey);
|
|
241
|
+
if (titleCase !== undefined) {
|
|
242
|
+
o.titleCase = LINK.case(titleCase);
|
|
243
|
+
if (!o.titleCase) throw new RangeError(`${caseKey} is upper or normal, got "${titleCase}"`);
|
|
244
|
+
}
|
|
245
|
+
const texture = given('texture');
|
|
246
|
+
if (texture !== undefined) {
|
|
247
|
+
o.texture = LINK.texture(texture);
|
|
248
|
+
if (o.texture === undefined) throw new RangeError('texture is 1 (on) or 0 (off)');
|
|
249
|
+
}
|
|
250
|
+
const variant = given('variant');
|
|
251
|
+
if (variant !== undefined) {
|
|
252
|
+
const pairs = LINK.variant(variant);
|
|
253
|
+
if (!pairs) {
|
|
254
|
+
throw new RangeError('variant is name.value pairs joined by "-", such as scene.forest or field.pale-emblem.sun');
|
|
255
|
+
}
|
|
256
|
+
o.variants = Object.fromEntries(pairs.split('-').map((pair) => pair.split('.')));
|
|
257
|
+
}
|
|
258
|
+
const width = given('width') === undefined ? undefined : px('width', /** @type {string} */ (given('width')));
|
|
259
|
+
const height = given('height') === undefined ? undefined : px('height', /** @type {string} */ (given('height')));
|
|
260
|
+
const format = given('format');
|
|
261
|
+
if (format !== undefined) [o.width, o.height] = formatSize(format, width, height);
|
|
262
|
+
else {
|
|
263
|
+
if (width !== undefined) o.width = width;
|
|
264
|
+
if (height !== undefined) o.height = height;
|
|
265
|
+
}
|
|
266
|
+
const [bg, ink, accent] = [given('bg'), given('ink'), given('accent')];
|
|
267
|
+
if (bg || ink || accent) {
|
|
268
|
+
if (!bg || !ink || !accent) throw new RangeError('Your own colours need bg, ink and accent');
|
|
269
|
+
o.palette = {
|
|
270
|
+
bg: hex(bg),
|
|
271
|
+
ink: hex(ink),
|
|
272
|
+
accents: accent
|
|
273
|
+
.split(',')
|
|
274
|
+
.filter((c) => c.trim())
|
|
275
|
+
.map(hex),
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
return /** @type {import('./index.js').CoverOptions} */ (o);
|
|
279
|
+
}
|
package/src/mcp.js
ADDED
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
// A Model Context Protocol server, so AI assistants can make covers: `book-cover --mcp` speaks it
|
|
2
|
+
// over stdio, and the site's Worker over HTTP at /mcp. This module only answers messages; the
|
|
3
|
+
// transports are theirs. (Not part of the library's API.)
|
|
4
|
+
|
|
5
|
+
import { createCover, createMockup, listStyles, palettes, genres, formats, mockupKinds, version } from './index.js';
|
|
6
|
+
import { optionsFromLink, writeLink, customPalette, variantOf, sixDigits } from './link.js';
|
|
7
|
+
import { idFor } from './shared.js';
|
|
8
|
+
|
|
9
|
+
/** The site that draws covers from links, and edits them. */
|
|
10
|
+
export const SITE = 'https://covers.otsirh.dev';
|
|
11
|
+
/** Protocol versions this server speaks, newest first. */
|
|
12
|
+
const PROTOCOL_VERSIONS = ['2025-06-18', '2025-03-26', '2024-11-05'];
|
|
13
|
+
|
|
14
|
+
const INSTRUCTIONS =
|
|
15
|
+
'Makes book covers. create_cover draws one from a title (and an author, a style or a genre, a palette…) and ' +
|
|
16
|
+
'answers with a link to the image (an SVG) and a link that opens it in the editor at covers.otsirh.dev, where ' +
|
|
17
|
+
'it can be changed and downloaded as PNG or JPEG. Give the same seed to keep a cover, another for a variation. ' +
|
|
18
|
+
'list_styles and list_palettes show the choices; create_mockup shows a cover as a 3D book, on an e-reader or ' +
|
|
19
|
+
'on a social card.';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @typedef {{ base?: string, writeFile?: (path: string, data: string) => string }} Context
|
|
23
|
+
* `base`: the site links point to; `writeFile`: where a local server can save a file (it answers
|
|
24
|
+
* the path written), else undefined.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/** @param {Context} ctx */
|
|
28
|
+
function designProperties(ctx) {
|
|
29
|
+
/** @type {Record<string, object>} */
|
|
30
|
+
const properties = {
|
|
31
|
+
title: { type: 'string', description: "The book's title. A line break (\\n) breaks its line on the cover." },
|
|
32
|
+
author: { type: 'string', description: "The author's name." },
|
|
33
|
+
subtitle: { type: 'string', description: 'A subtitle or tagline.' },
|
|
34
|
+
series: { type: 'string', description: 'A series line above the title, such as "Book Two of the Ember Cycle".' },
|
|
35
|
+
credit: { type: 'string', description: 'A credit under the subtitle, such as "Translated by Ann Lee".' },
|
|
36
|
+
badge: { type: 'string', description: 'A seal in a corner, such as "Winner of the Hugo Award".' },
|
|
37
|
+
style: {
|
|
38
|
+
type: 'string',
|
|
39
|
+
description: 'A style, by id or name (see list_styles), or "random" (the default: the seed picks one).',
|
|
40
|
+
},
|
|
41
|
+
genre: {
|
|
42
|
+
type: 'string',
|
|
43
|
+
description: `With no style, pick one that suits a genre: ${genres.map((g) => `${g.id} (${g.name})`).join(', ')}.`,
|
|
44
|
+
},
|
|
45
|
+
palette: {
|
|
46
|
+
type: 'string',
|
|
47
|
+
description: 'A palette, by id or name (see list_palettes), or "auto" (the default: the seed picks one).',
|
|
48
|
+
},
|
|
49
|
+
colors: {
|
|
50
|
+
type: 'object',
|
|
51
|
+
description: 'Your own colours instead of a palette, as hex.',
|
|
52
|
+
properties: {
|
|
53
|
+
bg: { type: 'string', description: 'Background, such as #0b132b.' },
|
|
54
|
+
ink: { type: 'string', description: 'Text.' },
|
|
55
|
+
accents: { type: 'array', items: { type: 'string' }, minItems: 1, maxItems: 3 },
|
|
56
|
+
},
|
|
57
|
+
required: ['bg', 'ink', 'accents'],
|
|
58
|
+
},
|
|
59
|
+
seed: {
|
|
60
|
+
type: ['string', 'number'],
|
|
61
|
+
description: 'Every random choice comes from it: the same seed draws the same cover; another, a variation.',
|
|
62
|
+
},
|
|
63
|
+
format: {
|
|
64
|
+
type: 'string',
|
|
65
|
+
description: `${Object.keys(formats).join(', ')}, or a size in px such as 1600x2560 (Kindle). Default: paperback (2:3).`,
|
|
66
|
+
},
|
|
67
|
+
font: {
|
|
68
|
+
type: 'string',
|
|
69
|
+
description:
|
|
70
|
+
"The title's font instead of the style's: serif, didone, garamond, sans, geometric, humanist, condensed, display or mono.",
|
|
71
|
+
},
|
|
72
|
+
case: { type: 'string', enum: ['upper', 'normal'], description: 'The title in capitals, or as typed.' },
|
|
73
|
+
texture: { type: 'boolean', description: 'A fine film grain.' },
|
|
74
|
+
variants: {
|
|
75
|
+
type: 'object',
|
|
76
|
+
additionalProperties: { type: 'string' },
|
|
77
|
+
description: 'Choices a style lets you pin, as list_styles shows them, such as { "scene": "forest" }.',
|
|
78
|
+
},
|
|
79
|
+
svg: { type: 'boolean', description: 'Also answer with the SVG markup (large).' },
|
|
80
|
+
};
|
|
81
|
+
if (ctx.writeFile) properties.output = { type: 'string', description: 'An absolute path to save the SVG to.' };
|
|
82
|
+
return properties;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** @param {Context} ctx */
|
|
86
|
+
function tools(ctx) {
|
|
87
|
+
const design = designProperties(ctx);
|
|
88
|
+
return [
|
|
89
|
+
{
|
|
90
|
+
name: 'create_cover',
|
|
91
|
+
title: 'Create a book cover',
|
|
92
|
+
description:
|
|
93
|
+
'Draw a book cover. Answers with links to the image and to edit it in the app, and the style, palette and seed it used.',
|
|
94
|
+
inputSchema: { type: 'object', properties: design, required: ['title'] },
|
|
95
|
+
annotations: { readOnlyHint: !ctx.writeFile, openWorldHint: false },
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
name: 'create_mockup',
|
|
99
|
+
title: 'Create a mockup of a cover',
|
|
100
|
+
description:
|
|
101
|
+
'Draw a cover as a product shot (a 3D book, flat, on an e-reader) or on a social card with a line of text.',
|
|
102
|
+
inputSchema: {
|
|
103
|
+
type: 'object',
|
|
104
|
+
properties: {
|
|
105
|
+
...design,
|
|
106
|
+
kind: { type: 'string', enum: [...mockupKinds], description: 'Default: book.' },
|
|
107
|
+
text: { type: 'string', description: 'The line on a card, square or story, such as "Out now".' },
|
|
108
|
+
},
|
|
109
|
+
required: ['title'],
|
|
110
|
+
},
|
|
111
|
+
annotations: { readOnlyHint: !ctx.writeFile, openWorldHint: false },
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
name: 'list_styles',
|
|
115
|
+
title: 'List the cover styles',
|
|
116
|
+
description: 'The styles, with their genres and the choices each lets you pin.',
|
|
117
|
+
inputSchema: {
|
|
118
|
+
type: 'object',
|
|
119
|
+
properties: { genre: { type: 'string', description: 'Only styles that suit this genre.' } },
|
|
120
|
+
},
|
|
121
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
name: 'list_palettes',
|
|
125
|
+
title: 'List the colour palettes',
|
|
126
|
+
description: 'The palettes, light and dark, with their colours.',
|
|
127
|
+
inputSchema: { type: 'object', properties: {} },
|
|
128
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
129
|
+
},
|
|
130
|
+
];
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Whether the arguments give a seed (else the cover's comes from its title). @param {Record<string, unknown>} args */
|
|
134
|
+
const seedGiven = (args) => typeof args.seed === 'number' || (typeof args.seed === 'string' && args.seed.trim() !== '');
|
|
135
|
+
|
|
136
|
+
/** A tool's arguments as a link's query, read as the HTTP API reads one. @param {Record<string, unknown>} args */
|
|
137
|
+
function queryFrom(args) {
|
|
138
|
+
const q = new URLSearchParams();
|
|
139
|
+
if (seedGiven(args)) q.set('seed', String(args.seed).trim());
|
|
140
|
+
for (const key of [
|
|
141
|
+
'title',
|
|
142
|
+
'author',
|
|
143
|
+
'subtitle',
|
|
144
|
+
'series',
|
|
145
|
+
'credit',
|
|
146
|
+
'badge',
|
|
147
|
+
'style',
|
|
148
|
+
'genre',
|
|
149
|
+
'palette',
|
|
150
|
+
'format',
|
|
151
|
+
'font',
|
|
152
|
+
'case',
|
|
153
|
+
]) {
|
|
154
|
+
const value = args[key];
|
|
155
|
+
if (value != null && value !== '') q.set(key, String(value));
|
|
156
|
+
}
|
|
157
|
+
if (typeof args.texture === 'boolean') q.set('texture', args.texture ? '1' : '0');
|
|
158
|
+
if (args.variants && typeof args.variants === 'object') {
|
|
159
|
+
// (An empty pin leaves the choice to the seed, as the library takes it.)
|
|
160
|
+
const pairs = Object.entries(args.variants)
|
|
161
|
+
.filter(([, value]) => value != null && value !== '')
|
|
162
|
+
.map(([name, value]) => `${name}.${String(value)}`);
|
|
163
|
+
if (pairs.length) q.set('variant', pairs.join('-'));
|
|
164
|
+
}
|
|
165
|
+
const colors = /** @type {{ bg?: unknown, ink?: unknown, accents?: unknown } | undefined} */ (args.colors);
|
|
166
|
+
if (colors && typeof colors === 'object') {
|
|
167
|
+
if (!Array.isArray(colors.accents) || colors.accents.length < 1 || colors.accents.length > 3) {
|
|
168
|
+
throw new RangeError('colors.accents is a list of one to three colours');
|
|
169
|
+
}
|
|
170
|
+
// (Each as #rrggbb, as the link of the cover will write it: the two draw the same bytes.)
|
|
171
|
+
q.set('bg', sixDigits(String(colors.bg ?? '')));
|
|
172
|
+
q.set('ink', sixDigits(String(colors.ink ?? '')));
|
|
173
|
+
q.set('accent', colors.accents.map((c) => sixDigits(String(c))).join(','));
|
|
174
|
+
}
|
|
175
|
+
return q;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* The link of the cover drawn, written as the app writes its links from the options it was drawn
|
|
180
|
+
* with (not the tool's arguments), with what the seed chose written out: the app and the API draw
|
|
181
|
+
* that cover.
|
|
182
|
+
* @param {import('./index.js').CoverOptions} options @param {ReturnType<typeof createCover>} cover
|
|
183
|
+
*/
|
|
184
|
+
function linkOf(options, cover) {
|
|
185
|
+
/** @type {Record<string, string>} */
|
|
186
|
+
const pinned = {};
|
|
187
|
+
for (const name of Object.keys(options.variants ?? {}).map((n) => n.toLowerCase())) {
|
|
188
|
+
if (cover.variants[name]) pinned[name] = cover.variants[name];
|
|
189
|
+
}
|
|
190
|
+
const own = options.palette && typeof options.palette === 'object' ? options.palette : null;
|
|
191
|
+
const preset = Object.entries(formats).find(([, [w, h]]) => w === cover.width && h === cover.height);
|
|
192
|
+
return writeLink({
|
|
193
|
+
title: cover.title,
|
|
194
|
+
author: cover.author,
|
|
195
|
+
subtitle: options.subtitle ?? '',
|
|
196
|
+
series: options.series ?? '',
|
|
197
|
+
credit: options.credit ?? '',
|
|
198
|
+
badge: options.badge ?? '',
|
|
199
|
+
style: cover.style,
|
|
200
|
+
variant: variantOf(pinned),
|
|
201
|
+
font: options.font ?? '',
|
|
202
|
+
case: options.titleCase ?? '',
|
|
203
|
+
palette: own ? customPalette([own.bg, own.ink, ...(own.accents ?? [])]) : cover.palette,
|
|
204
|
+
// (A seed from the title stays one: the app reads an empty seed so.)
|
|
205
|
+
seed: options.seed != null ? cover.seed : '',
|
|
206
|
+
format: preset ? preset[0] : `${cover.width}x${cover.height}`,
|
|
207
|
+
texture: options.texture === true,
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Text for a tool's answer. @param {string} body */
|
|
212
|
+
const text = (body) => ({ type: 'text', text: body });
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Run a tool: its answer (an error is an answer with isError, so the assistant can fix the call).
|
|
216
|
+
* @param {string} tool @param {Record<string, unknown>} args @param {Context} ctx
|
|
217
|
+
*/
|
|
218
|
+
function callTool(tool, args, ctx) {
|
|
219
|
+
const base = ctx.base ?? SITE;
|
|
220
|
+
try {
|
|
221
|
+
if (tool === 'list_styles') {
|
|
222
|
+
const genre = typeof args.genre === 'string' && args.genre ? args.genre : null;
|
|
223
|
+
const genreId = genre ? idFor(genre, genres) : null;
|
|
224
|
+
if (genre && !genreId)
|
|
225
|
+
throw new RangeError(`Unknown genre "${genre}". Available: ${genres.map((g) => g.id).join(', ')}`);
|
|
226
|
+
const styles = listStyles().filter((s) => !genreId || s.genres.includes(genreId));
|
|
227
|
+
const lines = styles.map((s) => {
|
|
228
|
+
const pins = s.variants.map((v) => `${v.name}: ${v.values.map((x) => x.value).join(', ')}`).join('; ');
|
|
229
|
+
return `- ${s.id} (${s.name}, ${s.group}): ${s.description} Genres: ${s.genres.join(', ') || 'none'}.${pins ? ` Pins: ${pins}.` : ''}`;
|
|
230
|
+
});
|
|
231
|
+
return {
|
|
232
|
+
content: [text(`${styles.length} styles:\n${lines.join('\n')}`)],
|
|
233
|
+
structuredContent: {
|
|
234
|
+
styles: styles.map(({ id, name, group, description, genres: suits, variants }) => ({
|
|
235
|
+
id,
|
|
236
|
+
name,
|
|
237
|
+
group,
|
|
238
|
+
description,
|
|
239
|
+
genres: suits,
|
|
240
|
+
variants: variants.map((v) => ({ name: v.name, values: v.values.map((x) => x.value) })),
|
|
241
|
+
})),
|
|
242
|
+
},
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
if (tool === 'list_palettes') {
|
|
246
|
+
const lines = palettes.map(
|
|
247
|
+
(p) =>
|
|
248
|
+
`- ${p.id} (${p.name}, ${p.dark ? 'dark' : 'light'}): ${p.bg} background, ${p.ink} text, ${p.accents.join(' ')}`,
|
|
249
|
+
);
|
|
250
|
+
return {
|
|
251
|
+
content: [text(`${palettes.length} palettes:\n${lines.join('\n')}`)],
|
|
252
|
+
structuredContent: {
|
|
253
|
+
palettes: palettes.map(({ id, name, bg, ink, accents, dark }) => ({
|
|
254
|
+
id,
|
|
255
|
+
name,
|
|
256
|
+
bg,
|
|
257
|
+
ink,
|
|
258
|
+
accents,
|
|
259
|
+
dark: !!dark,
|
|
260
|
+
})),
|
|
261
|
+
},
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
if (tool !== 'create_cover' && tool !== 'create_mockup') return null;
|
|
265
|
+
if (typeof args.title !== 'string' || !args.title.trim()) throw new TypeError('A cover needs a title');
|
|
266
|
+
const options = optionsFromLink(queryFrom(args));
|
|
267
|
+
const cover = createCover(options);
|
|
268
|
+
const link = linkOf(options, cover);
|
|
269
|
+
let [width, height] = [cover.width, cover.height];
|
|
270
|
+
const edit = `${base}/#${link}`;
|
|
271
|
+
let image = `${base}/api/cover.svg?${link}`;
|
|
272
|
+
let svg = cover.svg;
|
|
273
|
+
let what = `"${cover.title.replace(/\n/g, ' ')}"${cover.author ? ` by ${cover.author}` : ''}`;
|
|
274
|
+
if (tool === 'create_mockup') {
|
|
275
|
+
const kind = typeof args.kind === 'string' && args.kind ? args.kind : 'book';
|
|
276
|
+
const line = typeof args.text === 'string' ? args.text : '';
|
|
277
|
+
const mockup = createMockup(cover, { kind: /** @type {import('./index.js').MockupKind} */ (kind), text: line });
|
|
278
|
+
({ svg, width, height } = mockup);
|
|
279
|
+
const extra = new URLSearchParams({ kind, ...(line ? { text: line } : {}) });
|
|
280
|
+
image = `${base}/api/mockup.svg?${link}&${extra}`;
|
|
281
|
+
what = `${/^[aeiou]/.test(kind) ? 'an' : 'a'} ${kind} mockup of ${what}`;
|
|
282
|
+
}
|
|
283
|
+
const saved =
|
|
284
|
+
typeof args.output === 'string' && args.output && ctx.writeFile ? ctx.writeFile(args.output, svg) : null;
|
|
285
|
+
const styleName = listStyles().find((s) => s.id === cover.style)?.name ?? cover.style;
|
|
286
|
+
const paletteName = palettes.find((p) => p.id === cover.palette)?.name ?? 'your own colours';
|
|
287
|
+
const seed = options.seed != null ? `seed ${cover.seed}` : 'seed from the title';
|
|
288
|
+
const content = [
|
|
289
|
+
text(
|
|
290
|
+
`Drew ${what} in the ${styleName} style (${cover.style}), palette ${paletteName}, ${seed} (${width} × ${height} px).\n` +
|
|
291
|
+
`Image (SVG): ${image}\nEdit, or download as PNG or JPEG: ${edit}` +
|
|
292
|
+
(saved ? `\nSaved to ${saved}` : ''),
|
|
293
|
+
),
|
|
294
|
+
];
|
|
295
|
+
if (args.svg === true) content.push(text(svg));
|
|
296
|
+
return {
|
|
297
|
+
content,
|
|
298
|
+
structuredContent: {
|
|
299
|
+
style: cover.style,
|
|
300
|
+
palette: cover.palette,
|
|
301
|
+
// (Null when it comes from the title: give the title again to keep the cover.)
|
|
302
|
+
seed: options.seed != null ? cover.seed : null,
|
|
303
|
+
// (The image's size: a mockup's, for a mockup.)
|
|
304
|
+
width,
|
|
305
|
+
height,
|
|
306
|
+
variants: cover.variants,
|
|
307
|
+
imageUrl: image,
|
|
308
|
+
editUrl: edit,
|
|
309
|
+
...(saved ? { file: saved } : {}),
|
|
310
|
+
},
|
|
311
|
+
};
|
|
312
|
+
} catch (err) {
|
|
313
|
+
return { content: [text(err instanceof Error ? err.message : String(err))], isError: true };
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** The most messages a batch may hold (a batch is one request: each message draws a cover). */
|
|
318
|
+
export const MAX_BATCH = 20;
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Answer what a client sent: one message, or a batch (older protocol versions), as each
|
|
322
|
+
* transport receives them. Null when nothing needs an answer (notifications only).
|
|
323
|
+
* @param {unknown} payload @param {Context} [ctx]
|
|
324
|
+
*/
|
|
325
|
+
export function handlePayload(payload, ctx = {}) {
|
|
326
|
+
if (!Array.isArray(payload)) return handleMessage(payload, ctx);
|
|
327
|
+
if (!payload.length) return failure(null, -32600, 'An empty batch');
|
|
328
|
+
if (payload.length > MAX_BATCH) return failure(null, -32600, `A batch holds at most ${MAX_BATCH} messages`);
|
|
329
|
+
const answers = payload.map((m) => handleMessage(m, ctx)).filter(Boolean);
|
|
330
|
+
return answers.length ? answers : null;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** @param {unknown} id @param {unknown} result */
|
|
334
|
+
const ok = (id, result) => ({ jsonrpc: '2.0', id, result });
|
|
335
|
+
/** @param {unknown} id @param {number} code @param {string} message */
|
|
336
|
+
const failure = (id, code, message) => ({ jsonrpc: '2.0', id, error: { code, message } });
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Answer one JSON-RPC message: a response, or null for a notification (which has none).
|
|
340
|
+
* @param {unknown} message @param {Context} [ctx]
|
|
341
|
+
*/
|
|
342
|
+
export function handleMessage(message, ctx = {}) {
|
|
343
|
+
if (!message || typeof message !== 'object' || Array.isArray(message))
|
|
344
|
+
return failure(null, -32600, 'Invalid request');
|
|
345
|
+
const { jsonrpc, id, method, params } = /** @type {Record<string, any>} */ (message);
|
|
346
|
+
if (jsonrpc !== '2.0' || typeof method !== 'string') {
|
|
347
|
+
return 'id' in message ? failure(id ?? null, -32600, 'Invalid request') : null;
|
|
348
|
+
}
|
|
349
|
+
// A notification (initialized, cancelled…) has no answer.
|
|
350
|
+
if (!('id' in message)) return null;
|
|
351
|
+
switch (method) {
|
|
352
|
+
case 'initialize': {
|
|
353
|
+
const asked = params?.protocolVersion;
|
|
354
|
+
return ok(id, {
|
|
355
|
+
protocolVersion: PROTOCOL_VERSIONS.includes(asked) ? asked : PROTOCOL_VERSIONS[0],
|
|
356
|
+
capabilities: { tools: { listChanged: false } },
|
|
357
|
+
serverInfo: { name: 'book-cover-generator', title: 'Book Cover Generator', version },
|
|
358
|
+
instructions: INSTRUCTIONS,
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
case 'ping':
|
|
362
|
+
return ok(id, {});
|
|
363
|
+
case 'tools/list':
|
|
364
|
+
return ok(id, { tools: tools(ctx) });
|
|
365
|
+
case 'tools/call': {
|
|
366
|
+
const name = params?.name;
|
|
367
|
+
// (Arguments may be left out, but not given as something other than an object.)
|
|
368
|
+
const args = params?.arguments === undefined ? {} : params.arguments;
|
|
369
|
+
if (typeof name !== 'string' || typeof args !== 'object' || args === null) {
|
|
370
|
+
return failure(id, -32602, 'tools/call needs a tool name and its arguments');
|
|
371
|
+
}
|
|
372
|
+
const answer = callTool(name, args, ctx);
|
|
373
|
+
return answer ? ok(id, answer) : failure(id, -32602, `Unknown tool "${name}"`);
|
|
374
|
+
}
|
|
375
|
+
default:
|
|
376
|
+
return failure(id, -32601, `Method not found: ${method}`);
|
|
377
|
+
}
|
|
378
|
+
}
|