hostinger-api-mcp 3.25.0 → 3.26.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/CHANGELOG.md +20 -0
- package/README.md +7 -7
- package/gemini-extension.json +1 -1
- package/package.json +1 -1
- package/server.json +2 -2
- package/skills/headless/VERSION +2 -2
- package/skills/headless/steps/05-design/SKILL.md +38 -138
- package/skills/headless/steps/06-frontend/SKILL.md +2 -1
- package/skills/headless/steps/09-done/SKILL.md +1 -1
- package/src/core/runtime.js +1 -1
- package/src/core/runtime.ts +1 -1
- package/src/core/skills.js +9 -16
- package/src/core/skills.ts +9 -16
- package/src/core/tools/all.js +4 -4
- package/src/core/tools/all.ts +4 -4
- package/src/core/tools/hosting.js +2 -2
- package/src/core/tools/hosting.ts +2 -2
- package/src/core/tools/wordpress.js +2 -2
- package/src/core/tools/wordpress.ts +2 -2
- package/src/servers/agency-hosting.js +1 -1
- package/src/servers/agency-hosting.ts +1 -1
- package/src/servers/agent-readiness.js +1 -1
- package/src/servers/agent-readiness.ts +1 -1
- package/src/servers/all.js +1 -1
- package/src/servers/all.ts +1 -1
- package/src/servers/billing.js +1 -1
- package/src/servers/billing.ts +1 -1
- package/src/servers/dns.js +1 -1
- package/src/servers/dns.ts +1 -1
- package/src/servers/domains.js +1 -1
- package/src/servers/domains.ts +1 -1
- package/src/servers/ecommerce.js +1 -1
- package/src/servers/ecommerce.ts +1 -1
- package/src/servers/horizons.js +1 -1
- package/src/servers/horizons.ts +1 -1
- package/src/servers/hosting.js +1 -1
- package/src/servers/hosting.ts +1 -1
- package/src/servers/mail.js +1 -1
- package/src/servers/mail.ts +1 -1
- package/src/servers/reach.js +1 -1
- package/src/servers/reach.ts +1 -1
- package/src/servers/vps.js +1 -1
- package/src/servers/vps.ts +1 -1
- package/src/servers/wordpress.js +1 -1
- package/src/servers/wordpress.ts +1 -1
- package/types.d.ts +9 -9
- package/skills/headless/steps/05-design/logo-kit.mjs +0 -430
|
@@ -1,430 +0,0 @@
|
|
|
1
|
-
// Hostinger Headless — logo kit (step 05-design).
|
|
2
|
-
//
|
|
3
|
-
// Primitives for building a designed logo as outlined SVG: the letters become vector paths, so the
|
|
4
|
-
// logo needs no web font and renders identically everywhere. The agent composes the design; this
|
|
5
|
-
// file only does the parts that are easy to get wrong.
|
|
6
|
-
//
|
|
7
|
-
// Workflow (from ~/.hostinger/<domain>/brand/, so packages and this kit sit next to the build):
|
|
8
|
-
// cp <installed skill>/steps/05-design/logo-kit.mjs .
|
|
9
|
-
// npm init -y && npm i opentype.js @resvg/resvg-js @fontsource/<display-family-slug>
|
|
10
|
-
// node logo-build.mjs // your script: import { … } from './logo-kit.mjs'
|
|
11
|
-
// // then read logo-preview.png
|
|
12
|
-
// rm -rf node_modules // package.json keeps the deps; a redesign runs `npm i` again
|
|
13
|
-
//
|
|
14
|
-
// Packages resolve from the current working directory, so run the build from brand/.
|
|
15
|
-
|
|
16
|
-
import { createRequire } from 'node:module';
|
|
17
|
-
import { readFileSync, writeFileSync } from 'node:fs';
|
|
18
|
-
import { join } from 'node:path';
|
|
19
|
-
|
|
20
|
-
const requireFromCwd = createRequire(join(process.cwd(), 'logo-build.js'));
|
|
21
|
-
const opentype = requireFromCwd('opentype.js');
|
|
22
|
-
// Optional: without resvg the kit still builds logos, it just can't measure what's drawn or render.
|
|
23
|
-
const Resvg = (() => {
|
|
24
|
-
try {
|
|
25
|
-
return requireFromCwd('@resvg/resvg-js').Resvg;
|
|
26
|
-
} catch {
|
|
27
|
-
return null;
|
|
28
|
-
}
|
|
29
|
-
})();
|
|
30
|
-
|
|
31
|
-
// @fontsource splits each weight into unicode subsets; `latin` alone has no ą č ę ė į š ų ū ž,
|
|
32
|
-
// ő, ł, đ… so load every subset that exists and look each character up across them.
|
|
33
|
-
const SUBSETS = ['latin', 'latin-ext', 'vietnamese', 'cyrillic', 'cyrillic-ext', 'greek', 'greek-ext'];
|
|
34
|
-
|
|
35
|
-
function parseWoff(file) {
|
|
36
|
-
const buf = readFileSync(file);
|
|
37
|
-
return opentype.parse(buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength));
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
/**
|
|
41
|
-
* Load a static @fontsource weight (the static packages ship .woff, which opentype.js reads;
|
|
42
|
-
* variable packages ship only .woff2, which it can't). The returned font is the `latin` subset
|
|
43
|
-
* with `charToGlyph` widened to every other subset in the package, so accented letters resolve.
|
|
44
|
-
* @param {string} slug family slug, e.g. "unbounded"
|
|
45
|
-
* @param {number} weight e.g. 900
|
|
46
|
-
*/
|
|
47
|
-
export function loadFont(slug, weight = 400, style = 'normal') {
|
|
48
|
-
const fonts = [];
|
|
49
|
-
for (const subset of SUBSETS) {
|
|
50
|
-
let file;
|
|
51
|
-
try {
|
|
52
|
-
file = requireFromCwd.resolve(`@fontsource/${slug}/files/${slug}-${subset}-${weight}-${style}.woff`);
|
|
53
|
-
} catch {
|
|
54
|
-
continue;
|
|
55
|
-
}
|
|
56
|
-
fonts.push(parseWoff(file));
|
|
57
|
-
}
|
|
58
|
-
if (!fonts.length) throw new Error(`logo-kit: @fontsource/${slug} has no ${weight} ${style} .woff files`);
|
|
59
|
-
const [primary] = fonts;
|
|
60
|
-
const own = primary.charToGlyph.bind(primary);
|
|
61
|
-
primary.charToGlyph = (ch) => {
|
|
62
|
-
for (const font of fonts) {
|
|
63
|
-
const glyph = font === primary ? own(ch) : font.charToGlyph(ch);
|
|
64
|
-
if (glyph.index) {
|
|
65
|
-
glyph.kitFont = font;
|
|
66
|
-
return glyph;
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
return own(ch);
|
|
70
|
-
};
|
|
71
|
-
return primary;
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
const VIEWBOX = /viewBox="0 0 ([\d.]+) ([\d.]+)"/;
|
|
75
|
-
|
|
76
|
-
const num = (v) => {
|
|
77
|
-
if (!Number.isFinite(v)) throw new Error(`logo-kit: non-finite coordinate ${v}`);
|
|
78
|
-
return +v.toFixed(2);
|
|
79
|
-
};
|
|
80
|
-
|
|
81
|
-
/**
|
|
82
|
-
* Serialize an opentype Path to SVG path data. Use this instead of path.toPathData():
|
|
83
|
-
* opentype.js 2.0 emits "NaN" when a coordinate's fraction is a tiny float residue
|
|
84
|
-
* (its roundDecimal builds strings like "5.6e-14e+2"), and the browser silently stops drawing
|
|
85
|
-
* the path at the first NaN.
|
|
86
|
-
*/
|
|
87
|
-
export function toD(path) {
|
|
88
|
-
return path.commands
|
|
89
|
-
.map((c) => {
|
|
90
|
-
switch (c.type) {
|
|
91
|
-
case 'Z':
|
|
92
|
-
return 'Z';
|
|
93
|
-
case 'Q':
|
|
94
|
-
return `Q${num(c.x1)} ${num(c.y1)} ${num(c.x)} ${num(c.y)}`;
|
|
95
|
-
case 'C':
|
|
96
|
-
return `C${num(c.x1)} ${num(c.y1)} ${num(c.x2)} ${num(c.y2)} ${num(c.x)} ${num(c.y)}`;
|
|
97
|
-
default:
|
|
98
|
-
return `${c.type}${num(c.x)} ${num(c.y)}`;
|
|
99
|
-
}
|
|
100
|
-
})
|
|
101
|
-
.join('');
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
/**
|
|
105
|
-
* Lay out a string with kerning. Positions are relative to x = 0 on the baseline.
|
|
106
|
-
* One glyph per character: no ligatures or other substitutions, so `char` always matches its glyph
|
|
107
|
-
* (and fonts whose substitution tables opentype.js can't run still work).
|
|
108
|
-
* @returns {{ glyphs: { glyph, char: string, x: number, width: number }[], width: number, ascent: number, capHeight: number, xHeight: number }}
|
|
109
|
-
*/
|
|
110
|
-
export function layout(font, text, size, { tracking = 0 } = {}) {
|
|
111
|
-
const scale = size / font.unitsPerEm;
|
|
112
|
-
const chars = [...text];
|
|
113
|
-
const glyphs = chars.map((ch) => font.charToGlyph(ch));
|
|
114
|
-
glyphs.forEach((glyph, i) => {
|
|
115
|
-
if (!glyph.index && chars[i].trim()) {
|
|
116
|
-
throw new Error(`logo-kit: "${chars[i]}" is not in this font (it would render as an empty box); pick a font that has it`);
|
|
117
|
-
}
|
|
118
|
-
});
|
|
119
|
-
let x = 0;
|
|
120
|
-
const out = glyphs.map((glyph, i) => {
|
|
121
|
-
// Kerning pairs only exist inside one subset file; across subsets the indexes don't match.
|
|
122
|
-
if (i && (glyphs[i - 1].kitFont ?? font) === (glyph.kitFont ?? font)) {
|
|
123
|
-
const kern = font.getKerningValue(glyphs[i - 1], glyph);
|
|
124
|
-
x += (Number.isFinite(kern) ? kern : 0) * scale + tracking;
|
|
125
|
-
}
|
|
126
|
-
const item = { glyph, char: chars[i] ?? '', x, width: glyph.advanceWidth * scale };
|
|
127
|
-
x += item.width;
|
|
128
|
-
return item;
|
|
129
|
-
});
|
|
130
|
-
const capHeight = (font.tables.os2?.sCapHeight || font.ascender * 0.7) * scale;
|
|
131
|
-
const xHeight = (font.tables.os2?.sxHeight || font.ascender * 0.5) * scale;
|
|
132
|
-
return { glyphs: out, width: x, ascent: font.ascender * scale, capHeight, xHeight };
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
/**
|
|
136
|
-
* Tight bounding box of a word's outlines at (x, baselineY), before any transform. Use it to size
|
|
137
|
-
* the viewBox; add room for outlines, shadows and rotations yourself.
|
|
138
|
-
* @returns {{ x1: number, y1: number, x2: number, y2: number }}
|
|
139
|
-
*/
|
|
140
|
-
export function pathBox(font, text, x, baselineY, size, opts) {
|
|
141
|
-
const path = new opentype.Path();
|
|
142
|
-
for (const g of layout(font, text, size, opts).glyphs) path.extend(g.glyph.getPath(x + g.x, baselineY, size));
|
|
143
|
-
return path.getBoundingBox();
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
/** Path data for one glyph with its origin at (x, baselineY). */
|
|
147
|
-
export const glyphPath = (glyph, x, baselineY, size) => toD(glyph.getPath(x, baselineY, size));
|
|
148
|
-
|
|
149
|
-
/** Path data for a whole string starting at (x, baselineY). */
|
|
150
|
-
export function textPath(font, text, x, baselineY, size, opts) {
|
|
151
|
-
return layout(font, text, size, opts)
|
|
152
|
-
.glyphs.map((g) => glyphPath(g.glyph, x + g.x, baselineY, size))
|
|
153
|
-
.join('');
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
/**
|
|
157
|
-
* Set text around a circle (seals, badges). Each glyph is centred on its arc position and rotated
|
|
158
|
-
* to stand on the circle. `fill: true` stretches the spacing so the text closes the full ring.
|
|
159
|
-
* @returns {{ d: string, transform: string, char: string }[]} style each glyph however you like
|
|
160
|
-
*/
|
|
161
|
-
export function arcText(font, text, { cx, cy, r, size, startDeg = -90, fill = true }) {
|
|
162
|
-
const { glyphs, width, capHeight } = layout(font, text, size);
|
|
163
|
-
const k = fill ? (2 * Math.PI * r) / width : 1;
|
|
164
|
-
const offset = fill ? 0 : -width / 2; // centred on startDeg when not filling
|
|
165
|
-
return glyphs.map((g) => {
|
|
166
|
-
const along = (g.x + g.width / 2 + offset) * k;
|
|
167
|
-
const angle = along / r + (startDeg * Math.PI) / 180;
|
|
168
|
-
const px = cx + r * Math.cos(angle);
|
|
169
|
-
const py = cy + r * Math.sin(angle);
|
|
170
|
-
const deg = (angle * 180) / Math.PI + 90;
|
|
171
|
-
return {
|
|
172
|
-
d: glyphPath(g.glyph, -g.width / 2, capHeight / 2, size),
|
|
173
|
-
transform: `translate(${num(px)} ${num(py)}) rotate(${num(deg)})`,
|
|
174
|
-
char: g.char,
|
|
175
|
-
};
|
|
176
|
-
});
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
/**
|
|
180
|
-
* The outlined "cut-out" treatment: an ink outline drawn behind the fill (paint-order) plus an
|
|
181
|
-
* optional hard offset shadow. `shadowColor` defaults to the ink; set it separately on dark
|
|
182
|
-
* backgrounds, where a dark ink outline vanishes but a coloured shadow still reads.
|
|
183
|
-
* Returns SVG markup.
|
|
184
|
-
*/
|
|
185
|
-
export function sticker(d, { fill, ink = '#000', strokeWidth = 8, shadow = 8, shadowColor = ink, transform = '' }) {
|
|
186
|
-
const t = transform ? ` transform="${transform}"` : '';
|
|
187
|
-
const shade = shadow
|
|
188
|
-
? `<path data-kit="shadow" d="${d}" fill="${shadowColor}" stroke="${shadowColor}" stroke-width="${strokeWidth}" stroke-linejoin="round" transform="translate(${shadow} ${shadow})"/>`
|
|
189
|
-
: '';
|
|
190
|
-
return `<g${t}>${shade}<path d="${d}" fill="${fill}" stroke="${ink}" stroke-width="${strokeWidth}" stroke-linejoin="round" paint-order="stroke"/></g>`;
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
/** The solid rounded tile behind `logo-mark.svg` and `favicon.svg`; draw the symbol after it. */
|
|
194
|
-
export function tile(size, fill) {
|
|
195
|
-
return `<rect data-kit="tile" width="${num(size)}" height="${num(size)}" rx="${num(size / 4)}" fill="${fill}"/>`;
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
/** Root SVG with an accessible name. Pass a distinct `id` if two logos are inlined on one page. */
|
|
199
|
-
export function svgDoc({ width, height, title, body, id = 'logo-title' }) {
|
|
200
|
-
const esc = String(title).replace(/&/g, '&').replace(/</g, '<');
|
|
201
|
-
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${num(width)} ${num(height)}" role="img" aria-labelledby="${id}"><title id="${id}">${esc}</title>${body}</svg>`;
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
/**
|
|
205
|
-
* Validate a finished SVG string before writing it. Throws on: NaN/undefined/Infinity in the
|
|
206
|
-
* markup, live <text> (the logo must be outlined), a missing <title>, a missing viewBox, or an "&" that
|
|
207
|
-
* doesn't start an XML entity (a browser shows nothing for an SVG file that isn't well-formed XML).
|
|
208
|
-
* @returns {{ bytes: number }}
|
|
209
|
-
*/
|
|
210
|
-
export function check(svg) {
|
|
211
|
-
const problems = markupProblems(svg);
|
|
212
|
-
if (problems.length) throw new Error(`logo-kit check failed: ${problems.join('; ')}`);
|
|
213
|
-
return { bytes: Buffer.byteLength(svg) };
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
function markupProblems(svg) {
|
|
217
|
-
const problems = [];
|
|
218
|
-
if (/NaN|undefined|Infinity/.test(svg)) problems.push('contains NaN/undefined/Infinity');
|
|
219
|
-
if (/&(?!(?:amp|lt|gt|quot|apos|#\d+|#x[\dA-Fa-f]+);)/.test(svg)) {
|
|
220
|
-
problems.push('contains a bare "&" or an HTML-only entity such as ; build the file with svgDoc(), which escapes the title');
|
|
221
|
-
}
|
|
222
|
-
if (/<text[\s>]/.test(svg)) problems.push('contains <text>; outline the letters with textPath/glyphPath');
|
|
223
|
-
if (!/<title[^>]*>[^<]+<\/title>/.test(svg)) problems.push('missing <title>');
|
|
224
|
-
if (!VIEWBOX.test(svg)) problems.push('missing or invalid viewBox');
|
|
225
|
-
return problems;
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
const MIN_LETTER_PX = 16;
|
|
229
|
-
const MAX_SMALL_WIDTH_PX = 192;
|
|
230
|
-
const MIN_DESKTOP_PX = 48;
|
|
231
|
-
const MAX_HEADER_PX = 80; // automatic heights only: taller header logos crowd the nav, and stacked lockups reached 144
|
|
232
|
-
const HEADROOM = 0.02; // of the viewBox height, on every edge
|
|
233
|
-
const CLIP_SLACK = 0.5; // viewBox units of anti-aliasing a "nothing outside the viewBox" check allows
|
|
234
|
-
|
|
235
|
-
function viewBoxOf(svg) {
|
|
236
|
-
const m = svg.match(VIEWBOX);
|
|
237
|
-
if (!m) throw new Error('logo-kit: missing or invalid viewBox');
|
|
238
|
-
return { width: +m[1], height: +m[2] };
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
/** Gap between everything drawn (strokes, shadows, transforms) and each viewBox edge, smallest first. */
|
|
242
|
-
function edgeGaps(svg, vb) {
|
|
243
|
-
const b = new Resvg(svg).getBBox();
|
|
244
|
-
if (!b) return [];
|
|
245
|
-
const gaps = { top: b.y, bottom: vb.height - b.y - b.height, left: b.x, right: vb.width - b.x - b.width };
|
|
246
|
-
return Object.entries(gaps).sort((a, b) => a[1] - b[1]);
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
const px = (v) => +v.toFixed(1);
|
|
250
|
-
const ceil1 = (v) => Math.ceil(v * 10) / 10;
|
|
251
|
-
const ceil8 = (v) => Math.ceil(v / 8) * 8;
|
|
252
|
-
const floor8 = (v) => Math.floor(v / 8) * 8;
|
|
253
|
-
|
|
254
|
-
const minHeaderPx = (letterHeight, height) => ceil8((MIN_LETTER_PX * height) / letterHeight);
|
|
255
|
-
const maxWidthAt = (height, smallPx) => Math.floor((MAX_SMALL_WIDTH_PX * height) / smallPx);
|
|
256
|
-
|
|
257
|
-
function autoHeights(letterHeight, height) {
|
|
258
|
-
const small = Math.min(minHeaderPx(letterHeight, height), MAX_HEADER_PX);
|
|
259
|
-
return { desktop: Math.max(small, MIN_DESKTOP_PX), small };
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
/**
|
|
263
|
-
* The widest viewBox a logo of this height can have and still fit 192 px on small screens at the
|
|
264
|
-
* height `checkLogo` picks. Call it while composing, before the first build: it is roughly
|
|
265
|
-
* 10–12 × `letterHeight`, so a long name stacks or tags instead of running on one line.
|
|
266
|
-
* @param {number} letterHeight viewBox units, as passed to `checkLogo`
|
|
267
|
-
* @param {number} height the viewBox height
|
|
268
|
-
*/
|
|
269
|
-
export function widthBudget(letterHeight, height) {
|
|
270
|
-
return maxWidthAt(height, autoHeights(letterHeight, height).small);
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
/** Collects PASS/FAIL/SKIP lines; `print` returns how many failed. */
|
|
274
|
-
function sizeReport(label, vb) {
|
|
275
|
-
const lines = [];
|
|
276
|
-
return {
|
|
277
|
-
rule(ok, text, fix) {
|
|
278
|
-
lines.push(ok ? `PASS ${text}` : `FAIL ${text} → ${fix}`);
|
|
279
|
-
},
|
|
280
|
-
skip(text) {
|
|
281
|
-
lines.push(`SKIP ${text}`);
|
|
282
|
-
},
|
|
283
|
-
headroom(svg, of) {
|
|
284
|
-
if (!Resvg) return this.skip('headroom: install @resvg/resvg-js to measure it');
|
|
285
|
-
const [edge, gap] = edgeGaps(svg, vb)[0] ?? [];
|
|
286
|
-
if (edge === undefined) return this.skip(`headroom: nothing drawn on ${of}`);
|
|
287
|
-
const min = HEADROOM * vb.height;
|
|
288
|
-
this.rule(gap >= min, `headroom ${px(gap)} at the ${edge} of ${of} (≥ ${px(min)} viewBox units)`,
|
|
289
|
-
`add ${ceil1(min - gap)} viewBox units at the ${edge}: grow the viewBox on that side or move the drawing in; accents, descenders, tilt, outline and shadow all need room`);
|
|
290
|
-
},
|
|
291
|
-
print() {
|
|
292
|
-
console.log(`${label} (viewBox ${px(vb.width)} × ${px(vb.height)}):`);
|
|
293
|
-
for (const line of lines) console.log(` ${line}`);
|
|
294
|
-
return lines.filter((l) => l.startsWith('FAIL')).length;
|
|
295
|
-
},
|
|
296
|
-
};
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
function logoSizes(svg, { letterHeight, heights }) {
|
|
300
|
-
if (!letterHeight || (heights && (!heights.desktop || !heights.small))) {
|
|
301
|
-
throw new TypeError('logo-kit checkLogo: pass letterHeight, and heights: { desktop, small } only to set both');
|
|
302
|
-
}
|
|
303
|
-
const vb = viewBoxOf(svg);
|
|
304
|
-
const report = sizeReport('logo.svg', vb);
|
|
305
|
-
const minHeaderH = minHeaderPx(letterHeight, vb.height);
|
|
306
|
-
const maxSmall = floor8((MAX_SMALL_WIDTH_PX * vb.height) / vb.width);
|
|
307
|
-
const { desktop, small } = heights ?? autoHeights(letterHeight, vb.height);
|
|
308
|
-
const lettersAt = (h) => (letterHeight / vb.height) * h;
|
|
309
|
-
const width = (vb.width / vb.height) * small;
|
|
310
|
-
const canLowerSmall = small > minHeaderH && minHeaderH <= maxSmall;
|
|
311
|
-
const widthFixes = [
|
|
312
|
-
...(canLowerSmall ? [`--logo-h-sm ${minHeaderH === maxSmall ? minHeaderH : `${minHeaderH}–${maxSmall}`} px passes both`] : []),
|
|
313
|
-
`make the viewBox at most ${maxWidthAt(vb.height, small)} units wide (now ${px(vb.width)}) at this height: stack or tag the words, tighten gaps, or show logo-mark.svg with the name beside it on small screens`,
|
|
314
|
-
];
|
|
315
|
-
|
|
316
|
-
report.rule(desktop % 8 === 0 && small % 8 === 0, `heights ${desktop} / ${small} px are multiples of 8`, 'round them to multiples of 8');
|
|
317
|
-
const capped = !heights && minHeaderH > MAX_HEADER_PX;
|
|
318
|
-
const minLetterAtCap = ceil1((MIN_LETTER_PX * vb.height) / MAX_HEADER_PX);
|
|
319
|
-
const maxHeightAtCap = Math.floor((letterHeight * MAX_HEADER_PX) / MIN_LETTER_PX);
|
|
320
|
-
const lettersFixes = (name) => capped
|
|
321
|
-
? [`the header logo stops at ${MAX_HEADER_PX} px: make letterHeight at least ${minLetterAtCap} viewBox units (now ${px(letterHeight)})`,
|
|
322
|
-
`make the viewBox at most ${maxHeightAtCap} units tall (now ${px(vb.height)}): trim empty space or set the mark beside the words instead of above them`]
|
|
323
|
-
: [`raise ${name} to ${minHeaderH} px or more`, 'enlarge the letters inside the viewBox', 'trim empty viewBox space'];
|
|
324
|
-
const letterChecks = desktop === small ? [['--logo-h and --logo-h-sm', small]] : [['--logo-h', desktop], ['--logo-h-sm', small]];
|
|
325
|
-
for (const [name, h] of letterChecks) {
|
|
326
|
-
report.rule(lettersAt(h) >= MIN_LETTER_PX, `letters ${px(lettersAt(h))} px tall at ${name} ${h} px (≥ ${MIN_LETTER_PX})`,
|
|
327
|
-
lettersFixes(name).join(', or '));
|
|
328
|
-
}
|
|
329
|
-
report.rule(width <= MAX_SMALL_WIDTH_PX, `width ${px(width)} px at --logo-h-sm ${small} px (≤ ${MAX_SMALL_WIDTH_PX})`,
|
|
330
|
-
widthFixes.join(', or '));
|
|
331
|
-
report.headroom(svg, 'the viewBox');
|
|
332
|
-
const measured = `- **Measured:** letters ${px(lettersAt(small))} px tall at \`--logo-h-sm\`; width ${px(width)} px at \`--logo-h-sm\``;
|
|
333
|
-
return { report, heights: { desktop, small }, measured };
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
function tileChecks(svg, { favicon = false } = {}) {
|
|
337
|
-
const vb = viewBoxOf(svg);
|
|
338
|
-
const report = sizeReport(favicon ? 'favicon.svg' : 'logo-mark.svg', vb);
|
|
339
|
-
const tileTag = svg.match(/<rect data-kit="tile"[^>]*\/>/)?.[0];
|
|
340
|
-
report.rule(Boolean(tileTag), 'drawn on a tile()', "draw the tile with the kit's tile(size, fill)");
|
|
341
|
-
if (!Resvg) report.skip('clipping: install @resvg/resvg-js to measure it');
|
|
342
|
-
else {
|
|
343
|
-
const gap = edgeGaps(svg, vb)[0]?.[1] ?? 0;
|
|
344
|
-
report.rule(gap >= -CLIP_SLACK, 'nothing drawn outside the viewBox', 'shrink or move the drawing in; the tile already fills the viewBox');
|
|
345
|
-
}
|
|
346
|
-
if (tileTag) report.headroom(svg.replace(tileTag, ''), 'the tile');
|
|
347
|
-
if (favicon) {
|
|
348
|
-
report.rule(!/data-kit="shadow"|<filter|drop-shadow/.test(svg), 'no shadow', 'pass shadow: 0 to sticker() and draw no other shadow');
|
|
349
|
-
}
|
|
350
|
-
return report;
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
function renderPreview({ logo, mark, favicon, heights, background, out }) {
|
|
354
|
-
const render = (svg, h, zoom) => {
|
|
355
|
-
const img = new Resvg(svg, { fitTo: { mode: 'height', value: h } }).render();
|
|
356
|
-
return { w: img.width * zoom, h: img.height * zoom, href: `data:image/png;base64,${img.asPng().toString('base64')}` };
|
|
357
|
-
};
|
|
358
|
-
const tiles = (fill) => ({ fill, items: [render(mark, 32, 1), render(mark, 32, 4), render(favicon, 16, 1), render(favicon, 16, 8)] });
|
|
359
|
-
const bands = [
|
|
360
|
-
{
|
|
361
|
-
label: `logo.svg at --logo-h ${heights.desktop} px and --logo-h-sm ${heights.small} px, then 2×, on the page background`,
|
|
362
|
-
fill: background,
|
|
363
|
-
items: [render(logo, heights.desktop, 1), render(logo, heights.small, 1), render(logo, heights.desktop, 2), render(logo, heights.small, 2)],
|
|
364
|
-
},
|
|
365
|
-
{ label: 'light: logo-mark.svg at 32 px and 4×, favicon.svg at 16 px and 8×', ...tiles('#f1f3f4') },
|
|
366
|
-
{ label: 'dark: the same', ...tiles('#202124') },
|
|
367
|
-
];
|
|
368
|
-
const PAD = 16;
|
|
369
|
-
const LABEL = 28;
|
|
370
|
-
let body = '';
|
|
371
|
-
let y = 0;
|
|
372
|
-
let width = 480;
|
|
373
|
-
for (const { label, fill, items } of bands) {
|
|
374
|
-
const rowH = Math.max(...items.map((i) => i.h)) + PAD * 2;
|
|
375
|
-
body += `<text x="${PAD}" y="${y + 19}" font-family="sans-serif" font-size="13" fill="#555">${label}</text>`;
|
|
376
|
-
y += LABEL;
|
|
377
|
-
body += `<rect y="${y}" width="100%" height="${rowH}" fill="${fill}"/>`;
|
|
378
|
-
let x = PAD;
|
|
379
|
-
for (const i of items) {
|
|
380
|
-
body += `<image href="${i.href}" x="${x}" y="${y + (rowH - i.h) / 2}" width="${i.w}" height="${i.h}" image-rendering="optimizeSpeed"/>`;
|
|
381
|
-
x += i.w + PAD * 2;
|
|
382
|
-
}
|
|
383
|
-
width = Math.max(width, x - PAD);
|
|
384
|
-
y += rowH;
|
|
385
|
-
}
|
|
386
|
-
const sheet = `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${y}"><rect width="100%" height="100%" fill="#fff"/>${body}</svg>`;
|
|
387
|
-
writeFileSync(out, new Resvg(sheet).render().asPng());
|
|
388
|
-
}
|
|
389
|
-
|
|
390
|
-
/**
|
|
391
|
-
* Check the three finished files and render one PNG to look at instead of the SVG source.
|
|
392
|
-
* Throws first if any file fails `check()`.
|
|
393
|
-
* - logo.svg: letters ≥ 16 px at both header heights, width ≤ 192 px on small screens, 2% headroom
|
|
394
|
-
* around everything drawn, heights in multiples of 8. Without `heights`, `--logo-h-sm` is the
|
|
395
|
-
* smallest multiple of 8 that makes the letters 16 px, and `--logo-h` the same but at least 48 px;
|
|
396
|
-
* both stop at 80 px, so letters too small for 80 px fail.
|
|
397
|
-
* - logo-mark.svg, favicon.svg: drawn on a `tile()`, nothing outside the viewBox, 2% headroom
|
|
398
|
-
* around the symbol; the favicon has no shadow.
|
|
399
|
-
* - logo-preview.png: the logo at both heights on the page background, the mark at 32 px and the
|
|
400
|
-
* favicon at 16 px on a light and a dark strip, each also enlarged with hard pixels.
|
|
401
|
-
* Prints the heights and the Measured line first, then every PASS/FAIL line, each failure with its
|
|
402
|
-
* fixes, renders the preview, then throws if anything failed.
|
|
403
|
-
* @param {object} o
|
|
404
|
-
* @param {number} o.letterHeight viewBox units: `layout().xHeight` (mostly lowercase) or
|
|
405
|
-
* `.capHeight` (capitals) of the smaller word
|
|
406
|
-
* @param {{ desktop: number, small: number }} [o.heights] set both header heights yourself
|
|
407
|
-
* @param {string} [o.background] the page background colour
|
|
408
|
-
* @returns {{ heights: { desktop: number, small: number }, measured: string }} heights for
|
|
409
|
-
* `--logo-h` / `--logo-h-sm`, and the design.md → Logo → **Measured** line
|
|
410
|
-
*/
|
|
411
|
-
export function checkLogo({ logo, mark, favicon, letterHeight, heights, background = '#ffffff', out = 'logo-preview.png' }) {
|
|
412
|
-
// resvg can't measure or render a file check() rejects, so these fail before any size line.
|
|
413
|
-
const broken = Object.entries({ 'logo.svg': logo, 'logo-mark.svg': mark, 'favicon.svg': favicon })
|
|
414
|
-
.flatMap(([name, svg]) => markupProblems(svg).map((p) => `${name}: ${p}`));
|
|
415
|
-
if (broken.length) throw new Error(`logo-kit checkLogo: ${broken.join('; ')}`);
|
|
416
|
-
const sizes = logoSizes(logo, { letterHeight, heights });
|
|
417
|
-
// First, so piping the output through `head` can't cut them off.
|
|
418
|
-
console.log(`--logo-h: ${sizes.heights.desktop}px; --logo-h-sm: ${sizes.heights.small}px`);
|
|
419
|
-
console.log(sizes.measured);
|
|
420
|
-
const reports = [sizes.report, tileChecks(mark), tileChecks(favicon, { favicon: true })];
|
|
421
|
-
const failed = reports.reduce((n, r) => n + r.print(), 0);
|
|
422
|
-
if (Resvg) {
|
|
423
|
-
renderPreview({ logo, mark, favicon, heights: sizes.heights, background, out });
|
|
424
|
-
console.log(`preview: read ${out}`);
|
|
425
|
-
} else {
|
|
426
|
-
console.log('preview not rendered: @resvg/resvg-js is not installed; list "logo not visually checked" under gaps');
|
|
427
|
-
}
|
|
428
|
-
if (failed) throw new Error(`logo-kit checkLogo: ${failed} check(s) failed, see the FAIL lines above`);
|
|
429
|
-
return { heights: sizes.heights, measured: sizes.measured };
|
|
430
|
-
}
|