svg-terminal 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -18,7 +18,7 @@ Generate animated SVG terminals from a declarative YAML config. The output is a
18
18
  ```bash
19
19
  npx svg-terminal init # writes terminal.yml
20
20
  npx svg-terminal generate # writes terminal.svg
21
- npx svg-terminal blocks # lists all 47 blocks
21
+ npx svg-terminal blocks # lists all 48 blocks
22
22
  ```
23
23
 
24
24
  Or as a GitHub Action — refresh your profile README on a schedule:
@@ -31,14 +31,14 @@ Or as a GitHub Action — refresh your profile README on a schedule:
31
31
  commit: true
32
32
  ```
33
33
 
34
- See the full [GitHub Action](#github-action) section below, the [block catalog](./examples/blocks/) (47 blocks, one preview each), and the [12-theme gallery](#themes).
34
+ See the full [GitHub Action](#github-action) section below, the [block catalog](./examples/blocks/) (48 blocks, one preview each), and the [12-theme gallery](#themes).
35
35
 
36
36
  ### What's in the box
37
37
 
38
38
  - **Declarative YAML config** — write blocks, pick a theme, run the CLI
39
- - **47 built-in blocks** — across identity, retro / fake-system, status, ASCII art, single-line animation, and humor categories. Browse the [block catalog](./examples/blocks/) for previews of each
39
+ - **48 built-in blocks** — across identity, retro / fake-system, status, ASCII art, single- and multi-line animation, and humor categories. Browse the [block catalog](./examples/blocks/) for previews of each
40
40
  - **12 built-in themes** — dracula, nord, monokai, amber, green-phosphor, cyberpunk, solarized-dark, win95, catppuccin, tokyo-night, gruvbox, high-contrast (with chrome to match)
41
- - **Single-line frame animation** — `BlockResult.animation = { frames, fps, loop }` powers the 9 animated blocks (spinners, clock, dice, progress bar, etc.). Multi-line is a known restriction
41
+ - **Frame animation** — `BlockResult.animation = { frames, fps, loop }` powers the 10 animated blocks (spinners, clock, dice, progress bar, etc.). Frames may be single- **or multi-line** as of #69 (`jumping-jack` is the reference multi-line block)
42
42
  - **Dynamic-block cache** — the 5 cacheable blocks (weather, github-stats, github-languages, quote, fun-fact) write to `.svg-terminal-cache.json`. Pair with `--frozen-cache` for offline CI builds
43
43
  - **Reduced-motion respected** — `@media (prefers-reduced-motion)` clamps the CSS fade-ins AND (since v0.17) the frame cycle. SMIL-driven typing reveal, cursor walk, and scroll-on-overflow remain animated; pair with `--static` for full stillness
44
44
  - **Schema-validated, XSS-safe** — strict zod schema on every config field; user-controllable values are escaped at SVG emit sites. See [SECURITY.md](./SECURITY.md)
@@ -125,7 +125,7 @@ Each is the same 2-block config (motd + neofetch) rendered against the named the
125
125
 
126
126
  ## Blocks
127
127
 
128
- Run `svg-terminal blocks` to list all 47 (cacheable ones marked `*`), or `svg-terminal blocks <name>` to print one block's config schema directly without grepping the source.
128
+ Run `svg-terminal blocks` to list all 48 (cacheable ones marked `*`), or `svg-terminal blocks <name>` to print one block's config schema directly without grepping the source.
129
129
 
130
130
  | Block | Description |
131
131
  |-------|-------------|
@@ -172,6 +172,7 @@ Run `svg-terminal blocks` to list all 47 (cacheable ones marked `*`), or `svg-te
172
172
  | `progress-bar` | Fake build progress bar that fills 0% → 100% |
173
173
  | `bouncing-dot` | Single glyph bouncing left ↔ right |
174
174
  | `dice-roll` | N d6 dice that tumble and land on a result |
175
+ | `jumping-jack` | Multi-line stick figure doing jumping jacks (reference multi-line animation) |
175
176
  | `palette-swatch` | One-line render of all 16 theme palette colors |
176
177
  | `semver-bump` | Current semver + bump preview (major/minor/patch) |
177
178
  | `ascii-calendar` | Current-month calendar grid with today highlighted |
@@ -230,7 +231,7 @@ accessibility:
230
231
  describe: false # default true — emit <desc> with full content
231
232
  ```
232
233
 
233
- **Reduced-motion caveat.** The SVG emits an inline `@media (prefers-reduced-motion: reduce)` rule, but it only applies to CSS animations. The typing reveal, cursor walk, scroll, and frame-cycle animations are SMIL (`<animate>` elements) and SMIL doesn't read the same CSS media query. Users who set the OS-level reduced-motion preference will still see full-speed animation. If that's a problem for your audience, generate with `--static` — same content, no motion at all.
234
+ **Reduced-motion caveat.** The SVG emits an inline `@media (prefers-reduced-motion: reduce)` rule, which applies to CSS animations the fade-ins and the frame cycle (single- and multi-line) honor it (migrated SMIL CSS in v0.17). The remaining SMIL holdouts — typing reveal, cursor walk, and scroll-on-overflow — don't read the same CSS media query, so users who set the OS-level reduced-motion preference still see those animate. If that's a problem for your audience, generate with `--static` — same content, no motion at all.
234
235
 
235
236
  ### Caching API responses
236
237
 
@@ -1,5 +1,5 @@
1
1
  // src/index.ts
2
- import { z as z50 } from "zod";
2
+ import { z as z51 } from "zod";
3
3
 
4
4
  // src/core/config.ts
5
5
  import { readFileSync } from "fs";
@@ -1284,22 +1284,39 @@ function buildRevealClip(clipId, startX, charWidth, charCount, fontSize, startTi
1284
1284
  const finalWidth = roundCoord(charCount * charWidth);
1285
1285
  return `<defs><clipPath id="${clipId}"><rect x="${startX}" y="${-fontSize}" width="${finalWidth}" height="${fontSize * 2}">${setHold("width", 0, startTime)}<animate attributeName="width" values="${values.join(";")}" keyTimes="${keyTimes.join(";")}" calcMode="discrete" begin="${startTime}ms" dur="${typingDuration}ms" fill="freeze"/></rect></clipPath></defs>`;
1286
1286
  }
1287
- function generateAnimatedOutputLine(y, frames, color, startTime, colorMap, chrome, fps, loop) {
1287
+ function generateAnimatedOutputLine(y, frames, color, startTime, colorMap, chrome, fps, loop, lineHeight) {
1288
1288
  const n = frames.length;
1289
1289
  const frameDurMs = 1e3 / fps;
1290
1290
  const cycleMs = Math.round(n * frameDurMs);
1291
1291
  const iter = loop ? "infinite" : "1";
1292
- const textElements = frames.map((frame, i) => {
1293
- const styled = hasMarkup(frame);
1294
- const textContent = styled ? generateStyledText(parseMarkup(frame, colorMap, color), color, chrome.dimOpacity) : escapeXml(frame);
1295
- const textFill = styled ? "" : ` fill="${escapeXml(color)}"`;
1296
- const delayMs = Math.round(i * frameDurMs);
1297
- const anim = `animation: frame-cycle-${n} ${cycleMs}ms linear ${delayMs}ms ${iter}`;
1298
- return `<text class="tt frame-cycle-${n}"${textFill} opacity="${i === 0 ? "1" : "0"}" style="${anim}">${textContent}</text>`;
1299
- }).join("");
1292
+ const height = Math.max(1, ...frames.map((f) => f.length));
1293
+ const animFor = (i) => `animation: frame-cycle-${n} ${cycleMs}ms linear ${Math.round(i * frameDurMs)}ms ${iter}`;
1294
+ const renderRow = (row) => {
1295
+ const styled = hasMarkup(row);
1296
+ return {
1297
+ fill: styled ? "" : ` fill="${escapeXml(color)}"`,
1298
+ content: styled ? generateStyledText(parseMarkup(row, colorMap, color), color, chrome.dimOpacity) : escapeXml(row)
1299
+ };
1300
+ };
1301
+ let body;
1302
+ if (height === 1) {
1303
+ body = frames.map((frame, i) => {
1304
+ const { fill, content } = renderRow(frame[0] ?? "");
1305
+ return `<text class="tt frame-cycle-${n}"${fill} opacity="${i === 0 ? "1" : "0"}" style="${animFor(i)}">${content}</text>`;
1306
+ }).join("");
1307
+ } else {
1308
+ body = frames.map((frame, i) => {
1309
+ const rows = [];
1310
+ for (let r = 0; r < height; r++) {
1311
+ const { fill, content } = renderRow(frame[r] ?? "");
1312
+ rows.push(`<text y="${roundCoord(r * lineHeight)}"${fill}>${content}</text>`);
1313
+ }
1314
+ return `<g class="tt frame-cycle-${n}" opacity="${i === 0 ? "1" : "0"}" style="${animFor(i)}">${rows.join("")}</g>`;
1315
+ }).join("");
1316
+ }
1300
1317
  return `
1301
1318
  <g transform="translate(0, ${y})"${fadeInStyle(startTime)}>
1302
- ${textElements}
1319
+ ${body}
1303
1320
  </g>`;
1304
1321
  }
1305
1322
  function generateOutputLine(y, content, color, startTime, colorMap, chrome, pinWidth, fontSize) {
@@ -1351,7 +1368,8 @@ function generateAllLines(frames, terminal, lineHeight, colors, chrome, animatio
1351
1368
  colorMap,
1352
1369
  chromeConfig,
1353
1370
  frame.framesFps ?? 4,
1354
- frame.framesLoop ?? true
1371
+ frame.framesLoop ?? true,
1372
+ lineHeight
1355
1373
  )
1356
1374
  );
1357
1375
  } else {
@@ -1375,11 +1393,16 @@ function generateAllLines(frames, terminal, lineHeight, colors, chrome, animatio
1375
1393
  }
1376
1394
 
1377
1395
  // src/core/svg-generator.ts
1396
+ function animationHeight(frames) {
1397
+ return Math.max(1, ...frames.map((f) => f.length));
1398
+ }
1378
1399
  function countTotalLines(sequences) {
1379
1400
  let total = 0;
1380
1401
  for (const seq of sequences) {
1381
1402
  if (seq.type === "command") {
1382
1403
  total += 1;
1404
+ } else if (seq.frames && seq.frames.length > 0) {
1405
+ total += animationHeight(seq.frames);
1383
1406
  } else {
1384
1407
  total += seq.content.split("\n").length;
1385
1408
  }
@@ -1625,12 +1648,14 @@ function createAnimationFrames(sequences, terminal, maxVisibleLines, scrollDurat
1625
1648
  continue;
1626
1649
  }
1627
1650
  if (seq.frames && seq.frames.length > 0) {
1628
- buffer.push({ type: "output" });
1651
+ const height = animationHeight(seq.frames);
1652
+ for (let r = 0; r < height; r++) buffer.push({ type: "output" });
1629
1653
  frames.push({
1630
1654
  time: currentTime,
1631
1655
  type: "add-output",
1632
- lineIndex: buffer.length - 1,
1633
- content: seq.frames[0],
1656
+ lineIndex: buffer.length - height,
1657
+ // top row of the reserved band
1658
+ content: seq.frames[0].join("\n"),
1634
1659
  // frame 0 acts as the static fallback
1635
1660
  color: seq.color,
1636
1661
  frames: seq.frames,
@@ -2170,7 +2195,63 @@ import { z as z7 } from "zod";
2170
2195
  import { z as z6 } from "zod";
2171
2196
 
2172
2197
  // src/core/http.ts
2198
+ import { isIP } from "net";
2173
2199
  var DEFAULT_FETCH_TIMEOUT = 1e4;
2200
+ var MAX_REDIRECTS = 5;
2201
+ function ipv4Blocked(ip) {
2202
+ const parts = ip.split(".").map(Number);
2203
+ if (parts.length !== 4 || parts.some((p) => !Number.isInteger(p) || p < 0 || p > 255)) {
2204
+ return false;
2205
+ }
2206
+ const [a, b] = parts;
2207
+ if (a === 0) return true;
2208
+ if (a === 10) return true;
2209
+ if (a === 127) return true;
2210
+ if (a === 169 && b === 254) return true;
2211
+ if (a === 172 && b >= 16 && b <= 31) return true;
2212
+ if (a === 192 && b === 168) return true;
2213
+ if (a === 100 && b >= 64 && b <= 127) return true;
2214
+ if (a === 255 && b === 255 && parts[2] === 255 && parts[3] === 255) return true;
2215
+ return false;
2216
+ }
2217
+ function ipv6Blocked(ip) {
2218
+ const v = ip.toLowerCase();
2219
+ if (v === "::1" || v === "::") return true;
2220
+ if (/^fe[89ab]/.test(v)) return true;
2221
+ if (/^f[cd]/.test(v)) return true;
2222
+ const dotted = v.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/);
2223
+ if (dotted) return ipv4Blocked(dotted[1]);
2224
+ const hex = v.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
2225
+ if (hex) {
2226
+ const hi = parseInt(hex[1], 16);
2227
+ const lo = parseInt(hex[2], 16);
2228
+ return ipv4Blocked(`${hi >> 8 & 255}.${hi & 255}.${lo >> 8 & 255}.${lo & 255}`);
2229
+ }
2230
+ return false;
2231
+ }
2232
+ function isBlockedHost(hostname) {
2233
+ const host = hostname.replace(/^\[|\]$/g, "").replace(/\.$/, "").toLowerCase();
2234
+ if (host === "localhost" || host.endsWith(".localhost")) return true;
2235
+ const kind = isIP(host);
2236
+ if (kind === 4) return ipv4Blocked(host);
2237
+ if (kind === 6) return ipv6Blocked(host);
2238
+ return false;
2239
+ }
2240
+ function fetchBlockReason(url) {
2241
+ let parsed;
2242
+ try {
2243
+ parsed = new URL(url);
2244
+ } catch {
2245
+ return "unparseable URL";
2246
+ }
2247
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
2248
+ return `unsupported scheme "${parsed.protocol}"`;
2249
+ }
2250
+ if (isBlockedHost(parsed.hostname)) {
2251
+ return "private / loopback / link-local address";
2252
+ }
2253
+ return null;
2254
+ }
2174
2255
  function safeUrlForLog(url) {
2175
2256
  try {
2176
2257
  const u = new URL(url);
@@ -2207,20 +2288,47 @@ async function readCappedText(response, url) {
2207
2288
  }
2208
2289
  return new TextDecoder().decode(Buffer.concat(chunks));
2209
2290
  }
2210
- var USER_AGENT = `svg-terminal/${true ? "1.0.0" : "0.0.0-dev"}`;
2291
+ var USER_AGENT = `svg-terminal/${true ? "1.1.0" : "0.0.0-dev"}`;
2211
2292
  async function fetchWithTimeout(url, timeoutMs = DEFAULT_FETCH_TIMEOUT) {
2293
+ const blocked = fetchBlockReason(url);
2294
+ if (blocked) {
2295
+ console.warn(`[svg-terminal] Refused to fetch ${safeUrlForLog(url)}: ${blocked}`);
2296
+ return null;
2297
+ }
2212
2298
  const controller = new AbortController();
2213
2299
  const timer = setTimeout(() => controller.abort(), timeoutMs);
2214
2300
  try {
2215
- const response = await fetch(url, {
2216
- signal: controller.signal,
2217
- headers: { "User-Agent": USER_AGENT }
2218
- });
2219
- if (!response.ok) {
2220
- console.warn(`[svg-terminal] HTTP ${response.status} from ${safeUrlForLog(url)}`);
2221
- return null;
2301
+ let currentUrl = url;
2302
+ for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
2303
+ const response = await fetch(currentUrl, {
2304
+ signal: controller.signal,
2305
+ headers: { "User-Agent": USER_AGENT },
2306
+ redirect: "manual"
2307
+ });
2308
+ if (response.status >= 300 && response.status < 400 && response.headers.has("location")) {
2309
+ let next;
2310
+ try {
2311
+ next = new URL(response.headers.get("location"), currentUrl).toString();
2312
+ } catch {
2313
+ console.warn(`[svg-terminal] Bad redirect Location from ${safeUrlForLog(currentUrl)}`);
2314
+ return null;
2315
+ }
2316
+ const blockedHop = fetchBlockReason(next);
2317
+ if (blockedHop) {
2318
+ console.warn(`[svg-terminal] Refused redirect to ${safeUrlForLog(next)}: ${blockedHop}`);
2319
+ return null;
2320
+ }
2321
+ currentUrl = next;
2322
+ continue;
2323
+ }
2324
+ if (!response.ok) {
2325
+ console.warn(`[svg-terminal] HTTP ${response.status} from ${safeUrlForLog(currentUrl)}`);
2326
+ return null;
2327
+ }
2328
+ return response;
2222
2329
  }
2223
- return response;
2330
+ console.warn(`[svg-terminal] Too many redirects (>${MAX_REDIRECTS}) fetching ${safeUrlForLog(url)}`);
2331
+ return null;
2224
2332
  } catch (error) {
2225
2333
  const message = error instanceof Error ? error.message : String(error);
2226
2334
  if (message.includes("abort")) {
@@ -4339,6 +4447,34 @@ var tocBlock = {
4339
4447
  }
4340
4448
  };
4341
4449
 
4450
+ // src/blocks/jumping-jack.ts
4451
+ import { z as z50 } from "zod";
4452
+ var POSE_OPEN = ["\\o/", " | ", "/ \\"];
4453
+ var POSE_SHUT = [" o ", "/|\\", " | "];
4454
+ var jumpingJackSchema = z50.object({
4455
+ fps: z50.number().int().min(1).max(30).optional(),
4456
+ command: z50.string().optional(),
4457
+ color: z50.string().optional()
4458
+ }).strict();
4459
+ var jumpingJackBlock = {
4460
+ name: "jumping-jack",
4461
+ description: "A multi-line stick figure doing jumping jacks",
4462
+ configSchema: jumpingJackSchema,
4463
+ render(_context, config) {
4464
+ const fps = config["fps"] ?? 2;
4465
+ const command = config["command"] ?? "exercise";
4466
+ const color = config["color"] ?? "yellow";
4467
+ const paint = (rows) => rows.map((r) => `[[fg:${color}]]${r}[[/fg]]`);
4468
+ const frames = [paint(POSE_OPEN), paint(POSE_SHUT)];
4469
+ return {
4470
+ command,
4471
+ lines: frames[0],
4472
+ // static fallback (first pose, all 3 rows)
4473
+ animation: { frames, fps }
4474
+ };
4475
+ }
4476
+ };
4477
+
4342
4478
  // src/blocks/index.ts
4343
4479
  function registerBuiltinBlocks() {
4344
4480
  registerBlocks([
@@ -4388,7 +4524,8 @@ function registerBuiltinBlocks() {
4388
4524
  paletteSwatchBlock,
4389
4525
  semverBumpBlock,
4390
4526
  asciiCalendarBlock,
4391
- tocBlock
4527
+ tocBlock,
4528
+ jumpingJackBlock
4392
4529
  ]);
4393
4530
  }
4394
4531
 
@@ -4404,7 +4541,7 @@ function validateBlockEntry(block, entry, index) {
4404
4541
  try {
4405
4542
  block.configSchema.parse(cfg);
4406
4543
  } catch (err) {
4407
- if (err instanceof z50.ZodError) {
4544
+ if (err instanceof z51.ZodError) {
4408
4545
  const issues = err.issues.map((i) => {
4409
4546
  const path = i.path.length ? i.path.join(".") : "<root>";
4410
4547
  return ` ${path}: ${i.message}`;
@@ -4470,7 +4607,9 @@ async function generate(userConfig, options = {}) {
4470
4607
  pause: resolvePause(entry.pause ?? result.pause),
4471
4608
  pinWidth: result.pinWidth,
4472
4609
  ...result.animation ? {
4473
- frames: result.animation.frames.map((f) => f.join("\n")),
4610
+ // Carry frames as string[][] (rows preserved) straight through the
4611
+ // timeline — no lossy join/split round-trip (#69, nexus B-PIPELINE).
4612
+ frames: result.animation.frames,
4474
4613
  framesFps: Math.min(30, Math.max(1, result.animation.fps ?? 4)),
4475
4614
  framesLoop: result.animation.loop ?? true
4476
4615
  } : {}
@@ -4600,4 +4739,4 @@ export {
4600
4739
  inspectCache,
4601
4740
  generateStatic
4602
4741
  };
4603
- //# sourceMappingURL=chunk-IVINEQLU.js.map
4742
+ //# sourceMappingURL=chunk-DVACBVLX.js.map
package/dist/cli.js CHANGED
@@ -11,7 +11,7 @@ import {
11
11
  mergeConfig,
12
12
  setStrictBlockConfig,
13
13
  themes
14
- } from "./chunk-IVINEQLU.js";
14
+ } from "./chunk-DVACBVLX.js";
15
15
 
16
16
  // src/cli.ts
17
17
  import { writeFileSync, watch as fsWatch } from "fs";
@@ -127,7 +127,7 @@ function isZodOptional(t) {
127
127
  }
128
128
 
129
129
  // src/cli.ts
130
- var VERSION = true ? "1.0.0" : "0.0.0-dev";
130
+ var VERSION = true ? "1.1.0" : "0.0.0-dev";
131
131
  var args = process.argv.slice(2);
132
132
  var command = args[0];
133
133
  function getFlag(name) {
package/dist/index.d.ts CHANGED
@@ -211,8 +211,11 @@ interface Sequence {
211
211
  pause?: number;
212
212
  /** Delay before this sequence starts in ms */
213
213
  delay?: number;
214
- /** Optional multi-frame payload — output sequences only. Each entry is one frame's content (newline-separated lines). Triggers frame-cycle rendering. */
215
- frames?: string[];
214
+ /** Optional multi-frame payload — output sequences only. Each entry is one
215
+ * frame; each frame is an array of rows. Multi-line frames are supported
216
+ * (#69) — single-row frames (the common case) are `[oneLine]`. Triggers
217
+ * frame-cycle rendering. */
218
+ frames?: string[][];
216
219
  /** Frames per second when `frames` is set (default 4, capped at 30). */
217
220
  framesFps?: number;
218
221
  /** Loop frames forever when set (default true). */
@@ -233,8 +236,9 @@ interface AnimationFrame {
233
236
  typingDuration?: number;
234
237
  scrollLines?: number;
235
238
  bufferStart?: number;
236
- /** Multi-frame payload — present only on add-output frames spawned from animated blocks. */
237
- frames?: string[];
239
+ /** Multi-frame payload — present only on add-output frames spawned from
240
+ * animated blocks. Each frame is an array of rows (#69 multi-line). */
241
+ frames?: string[][];
238
242
  framesFps?: number;
239
243
  framesLoop?: boolean;
240
244
  /** Width-pinning opt-in from BlockResult.pinWidth. */
package/dist/index.js CHANGED
@@ -47,7 +47,7 @@ import {
47
47
  themes,
48
48
  tokyoNight,
49
49
  win95
50
- } from "./chunk-IVINEQLU.js";
50
+ } from "./chunk-DVACBVLX.js";
51
51
  export {
52
52
  BlockConfigError,
53
53
  ConfigError,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "svg-terminal",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "Generate animated SVG terminals for GitHub READMEs from a declarative YAML config. 47 built-in blocks, 12 themes, zero runtime deps in the output.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -59,13 +59,14 @@
59
59
  "node": ">=22.0.0"
60
60
  },
61
61
  "devDependencies": {
62
+ "@eslint/js": "^10.0.1",
62
63
  "@types/js-yaml": "^4.0.9",
63
64
  "@types/node": "^22.19.19",
64
65
  "@vitest/coverage-v8": "^4.1.7",
65
- "eslint": "^9.39.4",
66
+ "eslint": "^10.4.1",
66
67
  "tsup": "^8.0.0",
67
68
  "tsx": "^4.22.3",
68
- "typescript": "^5.7.0",
69
+ "typescript": "^6.0.3",
69
70
  "typescript-eslint": "^8.60.0",
70
71
  "vitest": "^4.1.7"
71
72
  },