@firenet-designs/fnd-cli 2.4.0 → 2.6.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 +147 -50
- package/dist/commands/alt-text.d.ts +56 -0
- package/dist/commands/alt-text.js +404 -0
- package/dist/commands/create-project.js +47 -4
- package/dist/commands/workspace/index.d.ts +16 -0
- package/dist/commands/workspace/index.js +87 -19
- package/dist/lib/alt-text.d.ts +56 -0
- package/dist/lib/alt-text.js +144 -0
- package/dist/lib/image-filter.d.ts +43 -0
- package/dist/lib/image-filter.js +71 -0
- package/dist/lib/webflow.d.ts +80 -0
- package/dist/lib/webflow.js +122 -0
- package/dist/lib/workspace.d.ts +10 -2
- package/dist/lib/workspace.js +64 -29
- package/oclif.manifest.json +155 -73
- package/package.json +5 -1
- package/dist/commands/workspace/cleanup.d.ts +0 -14
- package/dist/commands/workspace/cleanup.js +0 -84
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Alt-text generation: fetch an image, make it something a vision model can
|
|
3
|
+
* read, and ask a locally-hosted Ollama model to describe it.
|
|
4
|
+
*
|
|
5
|
+
* Everything runs against the user's own Ollama host, so no image ever leaves
|
|
6
|
+
* their network and there is no per-image API cost — which is also why the
|
|
7
|
+
* caller drives this strictly sequentially: a single local model gains nothing
|
|
8
|
+
* from concurrent requests.
|
|
9
|
+
*/
|
|
10
|
+
import type { ImageFilter, ImageMeta } from './image-filter.js';
|
|
11
|
+
export declare const DEFAULT_OLLAMA_HOST = "http://localhost:11434";
|
|
12
|
+
/**
|
|
13
|
+
* One image's worth of result. The caller already knows the URL, so this is
|
|
14
|
+
* everything else worth recording about the round trip.
|
|
15
|
+
*
|
|
16
|
+
* `bytes` is the size of what was actually sent to the model, i.e. after the
|
|
17
|
+
* PNG conversion below — not `meta.fileSize`, which is the (usually much
|
|
18
|
+
* smaller) WebP the site serves. `ms` is wall clock for the whole thing
|
|
19
|
+
* (download, convert, inference), because that is what a run's duration is
|
|
20
|
+
* actually made of.
|
|
21
|
+
*/
|
|
22
|
+
export interface Description {
|
|
23
|
+
alt: string;
|
|
24
|
+
bytes: number;
|
|
25
|
+
meta: ImageMeta;
|
|
26
|
+
ms: number;
|
|
27
|
+
skipped: false;
|
|
28
|
+
tokens: number;
|
|
29
|
+
}
|
|
30
|
+
/** An image the filter rejected. It was downloaded, but never sent to the model. */
|
|
31
|
+
export interface Skipped {
|
|
32
|
+
meta: ImageMeta;
|
|
33
|
+
skipped: true;
|
|
34
|
+
}
|
|
35
|
+
export type Describe = (url: string) => Promise<Description | Skipped>;
|
|
36
|
+
/**
|
|
37
|
+
* Every model pulled on the host that can actually read an image.
|
|
38
|
+
*
|
|
39
|
+
* /api/tags (`list`) is the only endpoint that enumerates models, but it says
|
|
40
|
+
* nothing about what a model can do — the capability list lives on /api/show
|
|
41
|
+
* (`show`), so each tag is asked individually. The calls are local and run
|
|
42
|
+
* concurrently; a model whose show() fails (pulled but broken, or removed
|
|
43
|
+
* between the two calls) is simply left out rather than failing the run.
|
|
44
|
+
*/
|
|
45
|
+
export declare const listVisionModels: (host: string) => Promise<string[]>;
|
|
46
|
+
/**
|
|
47
|
+
* Bind a describer to one Ollama host and model. The client is created once and
|
|
48
|
+
* reused so the connection (and the loaded model, via keep_alive) survives
|
|
49
|
+
* across images.
|
|
50
|
+
*
|
|
51
|
+
* `filter`, when given, decides whether an image is worth describing — it runs
|
|
52
|
+
* after the download (see fetchImage) but before inference, and a rejected
|
|
53
|
+
* image comes back as `{skipped: true}` rather than throwing, because being
|
|
54
|
+
* filtered out is a normal outcome and not a failure.
|
|
55
|
+
*/
|
|
56
|
+
export declare const createDescriber: (host: string, model: string, filter?: ImageFilter) => Describe;
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Alt-text generation: fetch an image, make it something a vision model can
|
|
3
|
+
* read, and ask a locally-hosted Ollama model to describe it.
|
|
4
|
+
*
|
|
5
|
+
* Everything runs against the user's own Ollama host, so no image ever leaves
|
|
6
|
+
* their network and there is no per-image API cost — which is also why the
|
|
7
|
+
* caller drives this strictly sequentially: a single local model gains nothing
|
|
8
|
+
* from concurrent requests.
|
|
9
|
+
*/
|
|
10
|
+
import { Ollama } from 'ollama';
|
|
11
|
+
export const DEFAULT_OLLAMA_HOST = 'http://localhost:11434';
|
|
12
|
+
/** How long Ollama keeps the model resident between images, in seconds. */
|
|
13
|
+
const KEEP_ALIVE = 60;
|
|
14
|
+
const PROMPT = "Make alt text for this image. The alt text should be no more than 1 sentence. Don't be overly descriptive. Include any text in the image in your description. Verbs should be in present tense.";
|
|
15
|
+
/**
|
|
16
|
+
* Everything the model sees is PNG.
|
|
17
|
+
*
|
|
18
|
+
* Which formats a vision model can actually decode is not documented and varies
|
|
19
|
+
* by model — WebP in particular gets accepted and then described as if it were
|
|
20
|
+
* noise. Re-encoding every image removes the question: the model only ever
|
|
21
|
+
* receives the one format they all handle. The cost is a decode + encode per
|
|
22
|
+
* image, which is nothing next to the inference that follows.
|
|
23
|
+
*
|
|
24
|
+
* Both converters are native modules with real startup cost, so they're
|
|
25
|
+
* imported lazily — the first image of a run pays for sharp, and a run with no
|
|
26
|
+
* SVGs never loads resvg at all.
|
|
27
|
+
*/
|
|
28
|
+
const svgToPng = async (bytes) => {
|
|
29
|
+
const { Resvg } = await import('@resvg/resvg-js');
|
|
30
|
+
// Rendered on black: SVG icons are overwhelmingly dark-on-transparent, which
|
|
31
|
+
// flattens to invisible on the white the model would otherwise see.
|
|
32
|
+
const rendered = new Resvg(Buffer.from(bytes), { background: '#000000' }).render();
|
|
33
|
+
// An SVG has no intrinsic pixel size, so `width`/`height` in a filter mean the
|
|
34
|
+
// size resvg chose to rasterize at — the viewBox, in practice.
|
|
35
|
+
return { height: rendered.height, png: new Uint8Array(rendered.asPng()), width: rendered.width };
|
|
36
|
+
};
|
|
37
|
+
/** Raster → PNG. Covers WebP, JPEG, AVIF, GIF, TIFF and PNG itself. */
|
|
38
|
+
const rasterToPng = async (bytes) => {
|
|
39
|
+
const { default: sharp } = await import('sharp');
|
|
40
|
+
// Alpha is left alone here, unlike the SVG path: a transparent raster image
|
|
41
|
+
// could be light or dark, so there's no background that's safe to guess.
|
|
42
|
+
// resolveWithObject gets the dimensions out of the same decode as the encode.
|
|
43
|
+
const { data, info } = await sharp(Buffer.from(bytes)).png().toBuffer({ resolveWithObject: true });
|
|
44
|
+
return { height: info.height, png: new Uint8Array(data), width: info.width };
|
|
45
|
+
};
|
|
46
|
+
/**
|
|
47
|
+
* The `type` a filter expression sees: a bare format token, not a MIME type.
|
|
48
|
+
*
|
|
49
|
+
* Content-Type is trusted first and the extension is the fallback, since CDNs
|
|
50
|
+
* serve plenty of images from extensionless URLs. `jpg` is normalized to `jpeg`
|
|
51
|
+
* and `svg+xml` to `svg` so an expression doesn't have to spell both.
|
|
52
|
+
*/
|
|
53
|
+
const detectType = (contentType, path) => {
|
|
54
|
+
const fromHeader = contentType.split(';')[0].trim().toLowerCase();
|
|
55
|
+
const subtype = fromHeader.startsWith('image/') ? fromHeader.slice('image/'.length) : '';
|
|
56
|
+
const raw = subtype || (path.includes('.') ? path.split('.').pop() : '');
|
|
57
|
+
const token = raw.replace('+xml', '');
|
|
58
|
+
return token === 'jpg' ? 'jpeg' : token;
|
|
59
|
+
};
|
|
60
|
+
/**
|
|
61
|
+
* Download an image, normalize it to bytes the model accepts, and measure it.
|
|
62
|
+
*
|
|
63
|
+
* The measuring has to happen here rather than before the download: width and
|
|
64
|
+
* height aren't knowable without the image itself, and Content-Length is absent
|
|
65
|
+
* often enough that fileSize is taken from the bytes we actually received. So a
|
|
66
|
+
* filter saves inference time — the expensive part — not bandwidth.
|
|
67
|
+
*/
|
|
68
|
+
const fetchImage = async (url) => {
|
|
69
|
+
const resp = await fetch(url);
|
|
70
|
+
if (!resp.ok)
|
|
71
|
+
throw new Error(`Could not download image (${resp.status} ${resp.statusText})`);
|
|
72
|
+
const bytes = new Uint8Array(await resp.arrayBuffer());
|
|
73
|
+
const contentType = resp.headers.get('content-type') ?? '';
|
|
74
|
+
const path = new URL(url).pathname.toLowerCase();
|
|
75
|
+
const type = detectType(contentType, path);
|
|
76
|
+
// SVG is vector, so it goes through the rasterizer rather than sharp's decoder.
|
|
77
|
+
const { height, png, width } = type === 'svg' || contentType.includes('svg') || path.endsWith('.svg')
|
|
78
|
+
? await svgToPng(bytes)
|
|
79
|
+
: await rasterToPng(bytes);
|
|
80
|
+
return { meta: { fileSize: bytes.byteLength, height, type, url, width }, png };
|
|
81
|
+
};
|
|
82
|
+
/**
|
|
83
|
+
* Every model pulled on the host that can actually read an image.
|
|
84
|
+
*
|
|
85
|
+
* /api/tags (`list`) is the only endpoint that enumerates models, but it says
|
|
86
|
+
* nothing about what a model can do — the capability list lives on /api/show
|
|
87
|
+
* (`show`), so each tag is asked individually. The calls are local and run
|
|
88
|
+
* concurrently; a model whose show() fails (pulled but broken, or removed
|
|
89
|
+
* between the two calls) is simply left out rather than failing the run.
|
|
90
|
+
*/
|
|
91
|
+
export const listVisionModels = async (host) => {
|
|
92
|
+
const ollama = new Ollama({ host });
|
|
93
|
+
const { models } = await ollama.list();
|
|
94
|
+
const checked = await Promise.all(models.map(async ({ model }) => {
|
|
95
|
+
try {
|
|
96
|
+
const { capabilities } = await ollama.show({ model });
|
|
97
|
+
return capabilities?.includes('vision') ? model : undefined;
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
}
|
|
101
|
+
}));
|
|
102
|
+
return checked.filter((model) => model !== undefined).sort();
|
|
103
|
+
};
|
|
104
|
+
/**
|
|
105
|
+
* Bind a describer to one Ollama host and model. The client is created once and
|
|
106
|
+
* reused so the connection (and the loaded model, via keep_alive) survives
|
|
107
|
+
* across images.
|
|
108
|
+
*
|
|
109
|
+
* `filter`, when given, decides whether an image is worth describing — it runs
|
|
110
|
+
* after the download (see fetchImage) but before inference, and a rejected
|
|
111
|
+
* image comes back as `{skipped: true}` rather than throwing, because being
|
|
112
|
+
* filtered out is a normal outcome and not a failure.
|
|
113
|
+
*/
|
|
114
|
+
export const createDescriber = (host, model, filter) => {
|
|
115
|
+
const ollama = new Ollama({ host });
|
|
116
|
+
return async (url) => {
|
|
117
|
+
const started = performance.now();
|
|
118
|
+
const { meta, png } = await fetchImage(url);
|
|
119
|
+
if (filter && !filter(meta))
|
|
120
|
+
return { meta, skipped: true };
|
|
121
|
+
const images = [Buffer.from(png).toString("base64")];
|
|
122
|
+
const resp = await ollama.chat({
|
|
123
|
+
// eslint-disable-next-line camelcase
|
|
124
|
+
keep_alive: KEEP_ALIVE,
|
|
125
|
+
messages: [{ content: PROMPT, images, role: 'user' }],
|
|
126
|
+
model,
|
|
127
|
+
stream: false,
|
|
128
|
+
// think: true,
|
|
129
|
+
});
|
|
130
|
+
const content = resp.message.content.trim();
|
|
131
|
+
if (!content)
|
|
132
|
+
throw new Error(`${model} returned an empty description`);
|
|
133
|
+
return {
|
|
134
|
+
alt: content,
|
|
135
|
+
bytes: png.byteLength,
|
|
136
|
+
meta,
|
|
137
|
+
ms: performance.now() - started,
|
|
138
|
+
skipped: false,
|
|
139
|
+
// Prompt tokens dominate here — an image is worth hundreds of them, the
|
|
140
|
+
// sentence that comes back is worth a few dozen — so both halves count.
|
|
141
|
+
tokens: (resp.prompt_eval_count ?? 0) + (resp.eval_count ?? 0),
|
|
142
|
+
};
|
|
143
|
+
};
|
|
144
|
+
};
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `--filter` expression for `fnd alt-text`.
|
|
3
|
+
*
|
|
4
|
+
* The expression is evaluated with `eval`, deliberately: it comes from the flag
|
|
5
|
+
* the person is typing into their own shell, so it is already code they are
|
|
6
|
+
* running on their own machine — there is no privilege boundary to cross and no
|
|
7
|
+
* mini-language worth inventing when JavaScript's operators are exactly what a
|
|
8
|
+
* filter needs. It is NOT safe to feed this a string from anywhere else (a
|
|
9
|
+
* config file pulled off the network, a CI variable someone else controls).
|
|
10
|
+
*
|
|
11
|
+
* What the expression can see is the image's metadata plus `sizes`; nothing is
|
|
12
|
+
* passed in from the surrounding scope, because the eval'd text is a standalone
|
|
13
|
+
* function expression whose only bindings are its own parameters.
|
|
14
|
+
*/
|
|
15
|
+
/** Everything a filter expression can test. Sizes are bytes, dimensions pixels. */
|
|
16
|
+
export interface ImageMeta {
|
|
17
|
+
/** Bytes as served by the site — not the size of the PNG we convert it to. */
|
|
18
|
+
fileSize: number;
|
|
19
|
+
height: number;
|
|
20
|
+
/** Short format token: `webp`, `png`, `jpeg`, `svg`, `avif`, `gif`, … */
|
|
21
|
+
type: string;
|
|
22
|
+
url: string;
|
|
23
|
+
width: number;
|
|
24
|
+
}
|
|
25
|
+
export type ImageFilter = (meta: ImageMeta) => boolean;
|
|
26
|
+
/**
|
|
27
|
+
* Byte-count helpers for filter expressions: `sizes.KB(100)` is 100 * 1024.
|
|
28
|
+
*
|
|
29
|
+
* Every casing of every unit is registered (`KB`, `Kb`, `kB`, `kb`) and they all
|
|
30
|
+
* mean the same thing — kb is NOT kilobits. Nobody filtering image files means
|
|
31
|
+
* bits, and a silent factor of eight is a worse outcome than a redundant alias.
|
|
32
|
+
* Multipliers are binary (1024), matching what an OS reports for a file.
|
|
33
|
+
*/
|
|
34
|
+
export declare const sizes: Record<string, (n: number) => number>;
|
|
35
|
+
/**
|
|
36
|
+
* Compile a filter expression once, up front.
|
|
37
|
+
*
|
|
38
|
+
* Building it eagerly means a typo (`filesize`, a stray paren) fails before the
|
|
39
|
+
* first image is downloaded rather than four hundred images into a run — the
|
|
40
|
+
* expression is both parsed and run once against a probe here, since a bad
|
|
41
|
+
* identifier is a runtime ReferenceError, not a syntax error.
|
|
42
|
+
*/
|
|
43
|
+
export declare const createFilter: (expression: string) => ImageFilter;
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `--filter` expression for `fnd alt-text`.
|
|
3
|
+
*
|
|
4
|
+
* The expression is evaluated with `eval`, deliberately: it comes from the flag
|
|
5
|
+
* the person is typing into their own shell, so it is already code they are
|
|
6
|
+
* running on their own machine — there is no privilege boundary to cross and no
|
|
7
|
+
* mini-language worth inventing when JavaScript's operators are exactly what a
|
|
8
|
+
* filter needs. It is NOT safe to feed this a string from anywhere else (a
|
|
9
|
+
* config file pulled off the network, a CI variable someone else controls).
|
|
10
|
+
*
|
|
11
|
+
* What the expression can see is the image's metadata plus `sizes`; nothing is
|
|
12
|
+
* passed in from the surrounding scope, because the eval'd text is a standalone
|
|
13
|
+
* function expression whose only bindings are its own parameters.
|
|
14
|
+
*/
|
|
15
|
+
const UNITS = {
|
|
16
|
+
b: 1,
|
|
17
|
+
gb: 1024 ** 3,
|
|
18
|
+
kb: 1024,
|
|
19
|
+
mb: 1024 ** 2,
|
|
20
|
+
tb: 1024 ** 4,
|
|
21
|
+
};
|
|
22
|
+
/**
|
|
23
|
+
* Byte-count helpers for filter expressions: `sizes.KB(100)` is 100 * 1024.
|
|
24
|
+
*
|
|
25
|
+
* Every casing of every unit is registered (`KB`, `Kb`, `kB`, `kb`) and they all
|
|
26
|
+
* mean the same thing — kb is NOT kilobits. Nobody filtering image files means
|
|
27
|
+
* bits, and a silent factor of eight is a worse outcome than a redundant alias.
|
|
28
|
+
* Multipliers are binary (1024), matching what an OS reports for a file.
|
|
29
|
+
*/
|
|
30
|
+
export const sizes = Object.fromEntries(Object.entries(UNITS).flatMap(([unit, multiplier]) => {
|
|
31
|
+
const cased = unit.length === 1
|
|
32
|
+
? [unit, unit.toUpperCase()]
|
|
33
|
+
: [unit, unit.toUpperCase(), unit[0].toUpperCase() + unit[1], unit[0] + unit[1].toUpperCase()];
|
|
34
|
+
return [...new Set(cased)].map((name) => [name, (n) => n * multiplier]);
|
|
35
|
+
}));
|
|
36
|
+
/** A metadata shape used only to smoke-test the expression at startup. */
|
|
37
|
+
const PROBE = {
|
|
38
|
+
fileSize: 0,
|
|
39
|
+
height: 0,
|
|
40
|
+
type: 'png',
|
|
41
|
+
url: 'https://example.com/probe.png',
|
|
42
|
+
width: 0,
|
|
43
|
+
};
|
|
44
|
+
/**
|
|
45
|
+
* Compile a filter expression once, up front.
|
|
46
|
+
*
|
|
47
|
+
* Building it eagerly means a typo (`filesize`, a stray paren) fails before the
|
|
48
|
+
* first image is downloaded rather than four hundred images into a run — the
|
|
49
|
+
* expression is both parsed and run once against a probe here, since a bad
|
|
50
|
+
* identifier is a runtime ReferenceError, not a syntax error.
|
|
51
|
+
*/
|
|
52
|
+
export const createFilter = (expression) => {
|
|
53
|
+
let compiled;
|
|
54
|
+
try {
|
|
55
|
+
// Indirect eval: evaluated in global scope, so the expression can't reach
|
|
56
|
+
// anything local to this module even by accident.
|
|
57
|
+
// eslint-disable-next-line no-eval
|
|
58
|
+
compiled = (0, eval)(`(({ fileSize, height, sizes, type, url, width }) => (${expression}))`);
|
|
59
|
+
}
|
|
60
|
+
catch (error) {
|
|
61
|
+
throw new Error(`--filter is not valid JavaScript: ${error.message}`);
|
|
62
|
+
}
|
|
63
|
+
const run = (meta) => Boolean(compiled({ ...meta, sizes }));
|
|
64
|
+
try {
|
|
65
|
+
run(PROBE);
|
|
66
|
+
}
|
|
67
|
+
catch (error) {
|
|
68
|
+
throw new Error(`--filter could not be evaluated: ${error.message}. Available values: fileSize, width, height, url, type, sizes.`);
|
|
69
|
+
}
|
|
70
|
+
return run;
|
|
71
|
+
};
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal Webflow Data API v2 client — only what `fnd alt-text` needs.
|
|
3
|
+
*
|
|
4
|
+
* Two kinds of images live in a Webflow site and they are updated through
|
|
5
|
+
* completely different endpoints:
|
|
6
|
+
*
|
|
7
|
+
* site asset library /v2/sites/:site/assets -> PATCH /v2/assets/:id {altText}
|
|
8
|
+
* CMS image fields /v2/collections/:id/items -> PATCH /v2/collections/:id/items {items[].fieldData}
|
|
9
|
+
*
|
|
10
|
+
* Read endpoints are async generators that paginate internally, so callers just
|
|
11
|
+
* `for await` and never deal with offsets.
|
|
12
|
+
*
|
|
13
|
+
* SECURITY: the API key is a site-wide bearer token. It is only ever put in an
|
|
14
|
+
* Authorization header — never logged, never included in an error message (we
|
|
15
|
+
* report the URL pathname, not the full URL, in case a token ever ends up in a
|
|
16
|
+
* query string).
|
|
17
|
+
*/
|
|
18
|
+
export interface WebflowAuth {
|
|
19
|
+
apiKey: string;
|
|
20
|
+
siteId: string;
|
|
21
|
+
}
|
|
22
|
+
export interface Asset {
|
|
23
|
+
altText: null | string;
|
|
24
|
+
contentType: string;
|
|
25
|
+
displayName: string;
|
|
26
|
+
hostedUrl: string;
|
|
27
|
+
id: string;
|
|
28
|
+
originalFileName: string;
|
|
29
|
+
siteId: string;
|
|
30
|
+
}
|
|
31
|
+
export interface Collection {
|
|
32
|
+
displayName: string;
|
|
33
|
+
id: string;
|
|
34
|
+
singularName: string;
|
|
35
|
+
slug: string;
|
|
36
|
+
}
|
|
37
|
+
/** A single CMS image value. `alt` is null until someone (or this command) fills it in. */
|
|
38
|
+
export interface ImageField {
|
|
39
|
+
alt: null | string;
|
|
40
|
+
fileId: string;
|
|
41
|
+
url: string;
|
|
42
|
+
}
|
|
43
|
+
export interface CollectionItem {
|
|
44
|
+
fieldData: Record<string, ImageField | ImageField[] | unknown>;
|
|
45
|
+
id: string;
|
|
46
|
+
isArchived: boolean;
|
|
47
|
+
isDraft: boolean;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Every asset in the site's asset library, oldest page first.
|
|
51
|
+
*
|
|
52
|
+
* @yields each asset, one page of 100 at a time.
|
|
53
|
+
*/
|
|
54
|
+
export declare function getAssets(auth: WebflowAuth, limit?: number): AsyncGenerator<Asset>;
|
|
55
|
+
/** Every CMS collection on the site. Not paginated by Webflow. */
|
|
56
|
+
export declare const getCollections: (auth: WebflowAuth) => Promise<Collection[]>;
|
|
57
|
+
/**
|
|
58
|
+
* Every item in a collection.
|
|
59
|
+
*
|
|
60
|
+
* Reads from STAGING by default (the `/live` endpoint is opt-in) to match
|
|
61
|
+
* `updateCollectionItem`, which also writes to staging — so a run's changes
|
|
62
|
+
* need publishing in Webflow before they show on the live site.
|
|
63
|
+
*
|
|
64
|
+
* @yields each item in the collection.
|
|
65
|
+
*/
|
|
66
|
+
export declare function getCollectionItems(auth: WebflowAuth, collectionId: string, { limit, staging }?: {
|
|
67
|
+
limit?: number;
|
|
68
|
+
staging?: boolean;
|
|
69
|
+
}): AsyncGenerator<CollectionItem>;
|
|
70
|
+
/** Set the alt text on a site asset. */
|
|
71
|
+
export declare const updateAssetAltText: (auth: WebflowAuth, assetId: string, altText: string) => Promise<void>;
|
|
72
|
+
/** Patch one item's fieldData. Only the fields present in `fieldData` are touched. */
|
|
73
|
+
export declare const updateCollectionItem: (auth: WebflowAuth, collectionId: string, itemId: string, fieldData: Record<string, unknown>) => Promise<void>;
|
|
74
|
+
/**
|
|
75
|
+
* Webflow hands back untyped `fieldData`, so image fields are duck-typed: a
|
|
76
|
+
* single image is an object with a `url`, a multi-image field is an array of
|
|
77
|
+
* those (an empty array counts — it is still an image field, just empty).
|
|
78
|
+
*/
|
|
79
|
+
export declare const isImageField: (value: unknown) => value is ImageField;
|
|
80
|
+
export declare const isImagesField: (value: unknown) => value is ImageField[];
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal Webflow Data API v2 client — only what `fnd alt-text` needs.
|
|
3
|
+
*
|
|
4
|
+
* Two kinds of images live in a Webflow site and they are updated through
|
|
5
|
+
* completely different endpoints:
|
|
6
|
+
*
|
|
7
|
+
* site asset library /v2/sites/:site/assets -> PATCH /v2/assets/:id {altText}
|
|
8
|
+
* CMS image fields /v2/collections/:id/items -> PATCH /v2/collections/:id/items {items[].fieldData}
|
|
9
|
+
*
|
|
10
|
+
* Read endpoints are async generators that paginate internally, so callers just
|
|
11
|
+
* `for await` and never deal with offsets.
|
|
12
|
+
*
|
|
13
|
+
* SECURITY: the API key is a site-wide bearer token. It is only ever put in an
|
|
14
|
+
* Authorization header — never logged, never included in an error message (we
|
|
15
|
+
* report the URL pathname, not the full URL, in case a token ever ends up in a
|
|
16
|
+
* query string).
|
|
17
|
+
*/
|
|
18
|
+
const API = 'https://api.webflow.com/v2';
|
|
19
|
+
/** How many times a 429 is retried before the request is allowed to fail. */
|
|
20
|
+
const RATE_LIMIT_RETRIES = 3;
|
|
21
|
+
/** Fallback wait when Webflow rate-limits us without a Retry-After header. */
|
|
22
|
+
const RATE_LIMIT_FALLBACK_MS = 15_000;
|
|
23
|
+
const sleep = (ms) => new Promise((resolve) => {
|
|
24
|
+
setTimeout(resolve, ms);
|
|
25
|
+
});
|
|
26
|
+
/**
|
|
27
|
+
* One request against the Webflow API, with a bounded retry on 429.
|
|
28
|
+
*
|
|
29
|
+
* A full site run is hundreds of sequential requests spread over however long
|
|
30
|
+
* the vision model takes, so hitting the per-minute cap is a matter of site
|
|
31
|
+
* size, not of anything the caller did wrong — dying on it would throw away all
|
|
32
|
+
* the work done so far.
|
|
33
|
+
*/
|
|
34
|
+
const request = async (auth, url, init = {}) => {
|
|
35
|
+
const { pathname } = new URL(url);
|
|
36
|
+
const method = init.method ?? 'GET';
|
|
37
|
+
for (let attempt = 0;; attempt++) {
|
|
38
|
+
// eslint-disable-next-line no-await-in-loop
|
|
39
|
+
const resp = await fetch(url, {
|
|
40
|
+
...init,
|
|
41
|
+
headers: { Authorization: `Bearer ${auth.apiKey}`, ...init.headers },
|
|
42
|
+
});
|
|
43
|
+
// eslint-disable-next-line no-await-in-loop
|
|
44
|
+
if (resp.ok)
|
|
45
|
+
return (await resp.json());
|
|
46
|
+
if (resp.status === 429 && attempt < RATE_LIMIT_RETRIES) {
|
|
47
|
+
const retryAfter = Number(resp.headers.get('retry-after'));
|
|
48
|
+
// eslint-disable-next-line no-await-in-loop
|
|
49
|
+
await sleep(Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter * 1000 : RATE_LIMIT_FALLBACK_MS);
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
// eslint-disable-next-line no-await-in-loop
|
|
53
|
+
const body = await resp.text().catch(() => '');
|
|
54
|
+
throw new Error(`Webflow ${method} ${pathname} failed (${resp.status} ${resp.statusText})${body ? `: ${body.slice(0, 300)}` : ''}`);
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
/**
|
|
58
|
+
* Every asset in the site's asset library, oldest page first.
|
|
59
|
+
*
|
|
60
|
+
* @yields each asset, one page of 100 at a time.
|
|
61
|
+
*/
|
|
62
|
+
export async function* getAssets(auth, limit = 100) {
|
|
63
|
+
for (let page = 0;; page++) {
|
|
64
|
+
const url = new URL(`${API}/sites/${auth.siteId}/assets`);
|
|
65
|
+
url.searchParams.set('offset', `${page * limit}`);
|
|
66
|
+
url.searchParams.set('limit', `${limit}`);
|
|
67
|
+
// eslint-disable-next-line no-await-in-loop
|
|
68
|
+
const data = await request(auth, url);
|
|
69
|
+
yield* data.assets;
|
|
70
|
+
if (page + 1 >= Math.ceil(data.pagination.total / data.pagination.limit))
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
/** Every CMS collection on the site. Not paginated by Webflow. */
|
|
75
|
+
export const getCollections = async (auth) => {
|
|
76
|
+
const data = await request(auth, `${API}/sites/${auth.siteId}/collections`);
|
|
77
|
+
return data.collections;
|
|
78
|
+
};
|
|
79
|
+
/**
|
|
80
|
+
* Every item in a collection.
|
|
81
|
+
*
|
|
82
|
+
* Reads from STAGING by default (the `/live` endpoint is opt-in) to match
|
|
83
|
+
* `updateCollectionItem`, which also writes to staging — so a run's changes
|
|
84
|
+
* need publishing in Webflow before they show on the live site.
|
|
85
|
+
*
|
|
86
|
+
* @yields each item in the collection.
|
|
87
|
+
*/
|
|
88
|
+
export async function* getCollectionItems(auth, collectionId, { limit = 100, staging = true } = {}) {
|
|
89
|
+
for (let page = 0;; page++) {
|
|
90
|
+
const url = new URL(`${API}/collections/${collectionId}/items${staging ? '' : '/live'}`);
|
|
91
|
+
url.searchParams.set('offset', `${page * limit}`);
|
|
92
|
+
url.searchParams.set('limit', `${limit}`);
|
|
93
|
+
// eslint-disable-next-line no-await-in-loop
|
|
94
|
+
const data = await request(auth, url);
|
|
95
|
+
yield* data.items;
|
|
96
|
+
if (page + 1 >= Math.ceil(data.pagination.total / data.pagination.limit))
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
/** Set the alt text on a site asset. */
|
|
101
|
+
export const updateAssetAltText = async (auth, assetId, altText) => {
|
|
102
|
+
await request(auth, `${API}/assets/${assetId}`, {
|
|
103
|
+
body: JSON.stringify({ altText }),
|
|
104
|
+
headers: { 'Content-Type': 'application/json' },
|
|
105
|
+
method: 'PATCH',
|
|
106
|
+
});
|
|
107
|
+
};
|
|
108
|
+
/** Patch one item's fieldData. Only the fields present in `fieldData` are touched. */
|
|
109
|
+
export const updateCollectionItem = async (auth, collectionId, itemId, fieldData) => {
|
|
110
|
+
await request(auth, `${API}/collections/${collectionId}/items`, {
|
|
111
|
+
body: JSON.stringify({ items: [{ fieldData, id: itemId }] }),
|
|
112
|
+
headers: { 'Content-Type': 'application/json' },
|
|
113
|
+
method: 'PATCH',
|
|
114
|
+
});
|
|
115
|
+
};
|
|
116
|
+
/**
|
|
117
|
+
* Webflow hands back untyped `fieldData`, so image fields are duck-typed: a
|
|
118
|
+
* single image is an object with a `url`, a multi-image field is an array of
|
|
119
|
+
* those (an empty array counts — it is still an image field, just empty).
|
|
120
|
+
*/
|
|
121
|
+
export const isImageField = (value) => typeof value === 'object' && value !== null && !Array.isArray(value) && 'url' in value;
|
|
122
|
+
export const isImagesField = (value) => Array.isArray(value) && value.every((entry) => isImageField(entry));
|
package/dist/lib/workspace.d.ts
CHANGED
|
@@ -139,7 +139,15 @@ export interface RemoteCleanupOptions {
|
|
|
139
139
|
/** Strip this project's local-shell MCP entry — only when the workspace registered one (--rpc). */
|
|
140
140
|
removeRpcMcp?: boolean;
|
|
141
141
|
}
|
|
142
|
-
/**
|
|
142
|
+
/**
|
|
143
|
+
* Run the remote-side teardown over a fresh ssh connection. Forces a remote PTY
|
|
144
|
+
* with `-tt`: the MCP-remove step runs under an *interactive* login shell
|
|
145
|
+
* (`$SHELL -lic`, so `claude2` aliases in rc files resolve — see
|
|
146
|
+
* `claudeMcpRemoveScript`), and an interactive bash without a PTY prints
|
|
147
|
+
* "cannot set terminal process group / no job control in this shell". `-tt`
|
|
148
|
+
* forces allocation even when this cleanup runs without a local TTY, mirroring
|
|
149
|
+
* the `-t` the interactive session ssh uses.
|
|
150
|
+
*/
|
|
143
151
|
export declare const runRemoteCleanup: (target: string, remoteDir: string, opts?: RemoteCleanupOptions) => Promise<number>;
|
|
144
152
|
/**
|
|
145
153
|
* The remote-side teardown script. Runs from inside the workspace dir so the
|
|
@@ -161,7 +169,7 @@ export declare const hasMutagen: () => boolean;
|
|
|
161
169
|
* winner. When a `source` is given, it becomes the Mutagen alpha endpoint and
|
|
162
170
|
* the mode switches to two-way-resolved (alpha always wins conflicts), so
|
|
163
171
|
* `--source remote` puts the server first and `--source local` puts this
|
|
164
|
-
* machine first. Labels let `workspace cleanup` find and terminate orphans.
|
|
172
|
+
* machine first. Labels let `workspace --cleanup` find and terminate orphans.
|
|
165
173
|
* `ctx.ignores` (from --ignore-vcs) excludes gitignored paths from the sync so
|
|
166
174
|
* each side keeps its own build artifacts and platform-specific binaries.
|
|
167
175
|
*/
|