designlang 12.10.0 → 12.11.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.
@@ -9,7 +9,7 @@
9
9
  "name": "designlang",
10
10
  "source": "./",
11
11
  "description": "Eight slash commands wrapping the designlang CLI: /extract (full design language \u2192 DTCG, Tailwind, Figma), /grade (shareable HTML report card + SVG badge), /battle (head-to-head graded comparison), /remix (restyle in 6 vocabularies \u2014 brutalist, swiss, art-deco, cyberpunk, soft-ui, editorial), /pack (one downloadable design-system bundle), /theme-swap (OKLCH-correct recolour around a new brand primary), /brand (full editorial brand-guidelines book \u2014 13 chapters, hand-off-ready), /pair (fuse two designs across configurable axes \u2014 colours from one site, typography from another).",
12
- "version": "12.10.0",
12
+ "version": "12.10.1",
13
13
  "author": {
14
14
  "name": "Manavarya Singh"
15
15
  },
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "designlang",
3
3
  "description": "Extract any website's design language and ship it. Eight slash commands \u2014 /extract, /grade, /battle, /remix, /pack, /theme-swap, /brand, /pair \u2014 wrap the designlang CLI to pull DTCG tokens, Tailwind/shadcn/Figma vars, motion + voice, generate shareable graded report cards, head-to-head battle pages, six-vocabulary remixes, downloadable design-system bundles, OKLCH-correct theme recolouring, full editorial brand-guidelines books, and design crossovers between two sites.",
4
- "version": "12.10.0",
4
+ "version": "12.10.1",
5
5
  "author": {
6
6
  "name": "Manavarya Singh",
7
7
  "url": "https://github.com/Manavarya09"
package/CHANGELOG.md CHANGED
@@ -1,5 +1,50 @@
1
1
  # Changelog
2
2
 
3
+ ## [12.11.0] — 2026-05-15
4
+
5
+ **`brand --pdf` ships native PDF brand guides.**
6
+
7
+ ```bash
8
+ npx designlang brand stripe.com --pdf
9
+ # → stripe-com.brand.pdf (print-ready, ~3–5s)
10
+ ```
11
+
12
+ What you get on top of any "save as PDF":
13
+
14
+ - **Per-chapter page breaks** — every section starts on a fresh page.
15
+ - **Running footer** — `designlang · <subject> · <page> of <total>` on every page.
16
+ - **Selectable text + embedded fonts** — never rasterized.
17
+ - **`--paper a4|letter|tabloid` + `--landscape`** for any output target.
18
+ - **`--attach-tokens`** embeds the DTCG tokens JSON *inside* the PDF as a
19
+ proper file attachment — open the PDF in Preview/Acrobat, hit the
20
+ paperclip, drop the JSON straight into Tailwind.
21
+ - **`--no-print-background`** strips the brand-colour cover band for a
22
+ smaller file when you need it.
23
+
24
+ Adds one tiny dep (`pdf-lib`, MIT, ~600KB) used only when `--attach-tokens`
25
+ is passed; lazy-imported behind a dynamic `import()`.
26
+
27
+ ## [12.10.1] — 2026-05-13
28
+
29
+ **Tiny ship: \`stats\` now scans multiple URLs at once.**
30
+
31
+ \`designlang stats\` previously took exactly one URL. Now it takes any
32
+ number, runs them in parallel, and prints each block with a divider:
33
+
34
+ \`\`\`bash
35
+ npx designlang stats stripe.com vercel.com linear.app
36
+ \`\`\`
37
+
38
+ In \`--as-json\` mode:
39
+
40
+ - One URL → a bare object (legacy shape, unchanged)
41
+ - Two-or-more URLs → an array of objects
42
+ - Per-URL failures don't kill the batch — they surface as
43
+ \`{ url, error }\` in JSON or a red row in pretty mode, and the
44
+ process exits non-zero only when at least one site failed.
45
+
46
+ No flag changes, no schema breaks for the existing single-URL callers.
47
+
3
48
  ## [12.10.0] — 2026-05-12
4
49
 
5
50
  **Small ship: \`designlang stats\` + low-confidence warning in grade cards.**
@@ -53,6 +53,7 @@ import { buildPack } from '../src/pack.js';
53
53
  import { recolorDesign } from '../src/recolor.js';
54
54
  import { formatThemeSwap, formatThemeSwapMarkdown } from '../src/formatters/theme-swap.js';
55
55
  import { formatBrandBook, formatBrandBookMarkdown } from '../src/formatters/brand-book.js';
56
+ import { htmlToPdf } from '../src/pdf.js';
56
57
  import { fuseDesigns, AXES } from '../src/fuse.js';
57
58
  import { formatPair, formatPairMarkdown } from '../src/formatters/pair.js';
58
59
  import { nameFromUrl } from '../src/utils.js';
@@ -946,23 +947,31 @@ program
946
947
 
947
948
  // ── Stats command — fast stdout summary, no files written ──
948
949
  program
949
- .command('stats <url>')
950
- .description('Print a concise one-screen summary to stdout — grade, primary, fonts, spacing, voice. No files written.')
951
- .option('-j, --as-json', 'emit machine-readable JSON to stdout instead of pretty text')
952
- .action(async (url, opts) => {
953
- if (!url.startsWith('http')) url = `https://${url}`;
954
- validateUrl(url);
950
+ .command('stats <urls...>')
951
+ .description('Print a concise one-screen summary to stdout — grade, primary, fonts, spacing, voice. Accepts multiple URLs. No files written.')
952
+ .option('-j, --as-json', 'emit machine-readable JSON to stdout instead of pretty text (an array when multiple URLs)')
953
+ .action(async (urls, opts) => {
954
+ // Normalise each URL the same way the single-URL path used to.
955
+ const targets = urls.map(u => {
956
+ const full = u.startsWith('http') ? u : `https://${u}`;
957
+ validateUrl(full);
958
+ return full;
959
+ });
955
960
 
956
961
  // Quiet path for --as-json: no spinner / chrome noise, just data on stdout.
957
962
  // (`--json` is already a global program flag; `--as-json` avoids the clash.)
958
963
  const wantJson = !!opts.asJson;
959
- const spinner = wantJson ? null : ora(`Reading ${url}...`).start();
960
- try {
964
+ const spinner = wantJson
965
+ ? null
966
+ : ora(targets.length === 1 ? `Reading ${targets[0]}...` : `Reading ${targets.length} sites in parallel...`).start();
967
+
968
+ // Single helper used by both pretty and JSON paths.
969
+ async function summarise(url) {
961
970
  const design = await extractDesignLanguage(url);
962
971
  const s = design.score || {};
963
972
  const primary = design.colors?.primary;
964
973
  const families = (design.typography?.families || []).map(f => f?.name || f).filter(Boolean);
965
- const summary = {
974
+ return {
966
975
  url,
967
976
  title: design.meta?.title,
968
977
  grade: s.grade ?? null,
@@ -985,30 +994,24 @@ program
985
994
  stack: design.stack?.framework,
986
995
  intent: design.pageIntent?.type,
987
996
  };
997
+ }
988
998
 
989
- if (wantJson) {
990
- if (spinner) spinner.stop();
991
- process.stdout.write(JSON.stringify(summary, null, 2) + '\n');
992
- return;
993
- }
994
-
995
- spinner.stop();
999
+ function printPretty(summary) {
996
1000
  const gradeColor =
997
1001
  summary.grade === 'A' ? chalk.green
998
1002
  : summary.grade === 'B' ? chalk.cyan
999
1003
  : summary.grade === 'C' ? chalk.yellow
1000
1004
  : summary.grade === 'D' ? chalk.magenta
1001
1005
  : chalk.red;
1006
+ const primary = summary.primary;
1002
1007
  const confTag = primary && primary.confidence != null
1003
1008
  ? (primary.confidence < 0.5
1004
1009
  ? chalk.yellow(`~${Math.round(primary.confidence * 100)}% conf`)
1005
1010
  : chalk.gray(`${Math.round(primary.confidence * 100)}% conf`))
1006
1011
  : '';
1007
- const line = (label, value) =>
1008
- ` ${chalk.gray(label.padEnd(12))} ${value}`;
1009
-
1012
+ const line = (label, value) => ` ${chalk.gray(label.padEnd(12))} ${value}`;
1010
1013
  console.log('');
1011
- console.log(` ${chalk.bold(url)}`);
1014
+ console.log(` ${chalk.bold(summary.url)}`);
1012
1015
  if (summary.title) console.log(` ${chalk.gray(summary.title)}`);
1013
1016
  console.log('');
1014
1017
  console.log(line('Grade', `${gradeColor.bold(summary.grade || '—')} ${chalk.gray('·')} ${chalk.bold(String(summary.score ?? '—') + '/100')}`));
@@ -1017,10 +1020,10 @@ program
1017
1020
  } else {
1018
1021
  console.log(line('Primary', chalk.gray('—')));
1019
1022
  }
1020
- if (families.length) {
1021
- const head = families[0];
1022
- const body = families[1] || head;
1023
- const extra = families.length > 2 ? chalk.gray(` +${families.length - 2}`) : '';
1023
+ if (summary.families.length) {
1024
+ const head = summary.families[0];
1025
+ const body = summary.families[1] || head;
1026
+ const extra = summary.fontFamilyCount > 2 ? chalk.gray(` +${summary.fontFamilyCount - 2}`) : '';
1024
1027
  console.log(line('Fonts', `${head}${body && body !== head ? chalk.gray(' / ') + body : ''}${extra}`));
1025
1028
  } else {
1026
1029
  console.log(line('Fonts', chalk.gray('—')));
@@ -1034,7 +1037,41 @@ program
1034
1037
  console.log(line('Material', summary.material || chalk.gray('—')));
1035
1038
  console.log(line('Tone', summary.tone || chalk.gray('—')));
1036
1039
  console.log(line('Intent', summary.intent || chalk.gray('—')));
1040
+ }
1041
+
1042
+ try {
1043
+ // Settle so a single failure doesn't kill the whole batch; per-URL
1044
+ // errors are surfaced as individual rejections in both paths.
1045
+ const results = await Promise.allSettled(targets.map(summarise));
1046
+
1047
+ if (wantJson) {
1048
+ if (spinner) spinner.stop();
1049
+ const payload = results.map((r, i) => r.status === 'fulfilled'
1050
+ ? r.value
1051
+ : { url: targets[i], error: r.reason?.message || String(r.reason) });
1052
+ // When the caller passed a single URL we keep the legacy shape
1053
+ // (a bare object) so existing scripts don't break. Multiple URLs
1054
+ // become an array.
1055
+ const out = targets.length === 1 ? payload[0] : payload;
1056
+ process.stdout.write(JSON.stringify(out, null, 2) + '\n');
1057
+ return;
1058
+ }
1059
+
1060
+ spinner.stop();
1061
+ let anyFailed = false;
1062
+ results.forEach((r, i) => {
1063
+ if (i > 0) console.log(chalk.gray(' ' + '─'.repeat(60)));
1064
+ if (r.status === 'fulfilled') {
1065
+ printPretty(r.value);
1066
+ } else {
1067
+ anyFailed = true;
1068
+ console.log('');
1069
+ console.log(` ${chalk.bold(targets[i])}`);
1070
+ console.log(` ${chalk.red('failed:')} ${chalk.red(r.reason?.message || String(r.reason))}`);
1071
+ }
1072
+ });
1037
1073
  console.log('');
1074
+ if (anyFailed) process.exitCode = 1;
1038
1075
  } catch (err) {
1039
1076
  if (spinner) spinner.fail('Stats failed');
1040
1077
  console.error(chalk.red(`\n ${err.message}\n`));
@@ -1420,6 +1457,11 @@ program
1420
1457
  .option('-n, --name <name>', 'output file prefix (default: derived from URL)')
1421
1458
  .option('--format <fmt>', 'output format: html, md, json, all', 'all')
1422
1459
  .option('--open', 'open the HTML book in the default browser')
1460
+ .option('--pdf', 'also emit a print-ready PDF brand guide (chapter bookmarks, running page numbers)')
1461
+ .option('--paper <size>', 'PDF paper size: a4 | letter | tabloid', 'a4')
1462
+ .option('--landscape', 'PDF landscape orientation')
1463
+ .option('--no-print-background', 'strip the brand-colour cover band from the PDF (smaller file)')
1464
+ .option('--attach-tokens', 'embed the DTCG tokens JSON as a PDF file attachment')
1423
1465
  .action(async (url, opts) => {
1424
1466
  if (!url.startsWith('http')) url = `https://${url}`;
1425
1467
  validateUrl(url);
@@ -1441,11 +1483,14 @@ program
1441
1483
  const prefix = opts.name || `${nameFromUrl(url)}.brand`;
1442
1484
  const written = [];
1443
1485
 
1444
- if (opts.format === 'all' || opts.format === 'html') {
1445
- const html = formatBrandBook(design, { version: PKG_VERSION });
1446
- const p = join(outDir, `${prefix}.html`);
1447
- writeFileSync(p, html);
1448
- written.push(p);
1486
+ let bookHtml = null;
1487
+ if (opts.format === 'all' || opts.format === 'html' || opts.pdf) {
1488
+ bookHtml = formatBrandBook(design, { version: PKG_VERSION });
1489
+ if (opts.format === 'all' || opts.format === 'html') {
1490
+ const p = join(outDir, `${prefix}.html`);
1491
+ writeFileSync(p, bookHtml);
1492
+ written.push(p);
1493
+ }
1449
1494
  }
1450
1495
  if (opts.format === 'all' || opts.format === 'md') {
1451
1496
  const md = formatBrandBookMarkdown(design);
@@ -1479,6 +1524,42 @@ program
1479
1524
  written.push(p);
1480
1525
  }
1481
1526
 
1527
+ if (opts.pdf) {
1528
+ spinner.text = 'Rendering PDF...';
1529
+ const pdfPath = join(outDir, `${prefix}.pdf`);
1530
+ const attachments = [];
1531
+ if (opts.attachTokens) {
1532
+ // Reuse the JSON we just wrote if available; otherwise build a DTCG payload on the fly.
1533
+ let tokensJson;
1534
+ try {
1535
+ tokensJson = readFileSync(join(outDir, `${prefix}.json`));
1536
+ } catch {
1537
+ tokensJson = Buffer.from(JSON.stringify({
1538
+ colors: design.colors, typography: design.typography,
1539
+ spacing: design.spacing, motion: design.motion,
1540
+ }, null, 2));
1541
+ }
1542
+ attachments.push({
1543
+ filename: `${nameFromUrl(url)}-tokens.json`,
1544
+ contents: tokensJson,
1545
+ mimeType: 'application/json',
1546
+ description: 'Design tokens (DTCG-aligned)',
1547
+ });
1548
+ }
1549
+ await htmlToPdf(bookHtml, {
1550
+ paper: opts.paper,
1551
+ landscape: !!opts.landscape,
1552
+ printBackground: opts.printBackground !== false,
1553
+ attachments,
1554
+ metadata: {
1555
+ title: `${new URL(url).hostname} brand guidelines`,
1556
+ subject: `${new URL(url).hostname} brand guidelines`,
1557
+ },
1558
+ outPath: pdfPath,
1559
+ });
1560
+ written.push(pdfPath);
1561
+ }
1562
+
1482
1563
  spinner.stop();
1483
1564
  const colorCount = (design.colors?.all || []).length;
1484
1565
  const fontCount = (design.typography?.families || []).length;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "designlang",
3
- "version": "12.10.0",
4
- "description": "Extract the complete design language from any website and ship it \u2014 clone to a working Next.js starter, guard tokens with a CI drift bot, or browse everything in a local studio. Outputs W3C DTCG tokens, motion tokens, typed anatomy stubs, Tailwind config, and ready-to-paste v0 / Lovable / Cursor / Claude-Artifacts prompts.",
3
+ "version": "12.11.0",
4
+ "description": "Extract the complete design language from any website and ship it clone to a working Next.js starter, guard tokens with a CI drift bot, or browse everything in a local studio. Outputs W3C DTCG tokens, motion tokens, typed anatomy stubs, Tailwind config, and ready-to-paste v0 / Lovable / Cursor / Claude-Artifacts prompts.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "designlang": "./bin/design-extract.js"
@@ -17,6 +17,7 @@
17
17
  "chalk": "^5.3.0",
18
18
  "commander": "^12.0.0",
19
19
  "ora": "^8.0.0",
20
+ "pdf-lib": "^1.17.1",
20
21
  "playwright": "^1.42.0"
21
22
  },
22
23
  "engines": {
@@ -48,4 +49,4 @@
48
49
  ],
49
50
  "author": "masyv",
50
51
  "license": "MIT"
51
- }
52
+ }
@@ -945,7 +945,8 @@ export function formatBrandBook(design, opts = {}) {
945
945
  .cover, .toc, section { page-break-inside: avoid; border-color: #ddd; padding: 36px 32px; }
946
946
  .cover-band { background-color: var(--accent) !important; -webkit-print-color-adjust: exact; print-color-adjust: exact; }
947
947
  .toc, .cover { page-break-after: always; }
948
- section { page-break-after: always; }
948
+ section { page-break-before: always; page-break-after: always; break-before: page; }
949
+ .topbar { display: none !important; }
949
950
  .scale-table, .a11y-pair, .component-card { page-break-inside: avoid; }
950
951
  }
951
952
  </style>
package/src/pdf.js ADDED
@@ -0,0 +1,56 @@
1
+ import { chromium } from 'playwright';
2
+ import { writeFileSync } from 'fs';
3
+
4
+ export async function htmlToPdf(html, opts = {}) {
5
+ const {
6
+ paper = 'a4',
7
+ landscape = false,
8
+ printBackground = true,
9
+ attachments = [],
10
+ metadata = {},
11
+ outPath,
12
+ } = opts;
13
+
14
+ const format = String(paper).toLowerCase();
15
+ const browser = await chromium.launch();
16
+ try {
17
+ const page = await browser.newPage();
18
+ await page.setContent(html, { waitUntil: 'networkidle' });
19
+
20
+ const subject = metadata.subject || '';
21
+ const buffer = await page.pdf({
22
+ format,
23
+ landscape: !!landscape,
24
+ printBackground,
25
+ margin: { top: '24mm', right: '18mm', bottom: '20mm', left: '18mm' },
26
+ displayHeaderFooter: true,
27
+ headerTemplate: `<div></div>`,
28
+ footerTemplate: `<div style="font-family: -apple-system, sans-serif; font-size: 9px; color: #888; width: 100%; padding: 0 18mm; display: flex; justify-content: space-between;"><span>designlang${subject ? ' · ' + escapeHtml(subject) : ''}</span><span><span class="pageNumber"></span> of <span class="totalPages"></span></span></div>`,
29
+ });
30
+
31
+ if (attachments.length) {
32
+ const { PDFDocument } = await import('pdf-lib');
33
+ const pdfDoc = await PDFDocument.load(buffer);
34
+ if (metadata.title) pdfDoc.setTitle(metadata.title);
35
+ if (metadata.subject) pdfDoc.setSubject(metadata.subject);
36
+ pdfDoc.setAuthor('designlang');
37
+ pdfDoc.setCreator('designlang');
38
+ for (const a of attachments) {
39
+ await pdfDoc.attach(a.contents, a.filename, {
40
+ mimeType: a.mimeType || 'application/json',
41
+ description: a.description || a.filename,
42
+ });
43
+ }
44
+ writeFileSync(outPath, await pdfDoc.save());
45
+ } else {
46
+ writeFileSync(outPath, buffer);
47
+ }
48
+ } finally {
49
+ await browser.close();
50
+ }
51
+ return outPath;
52
+ }
53
+
54
+ function escapeHtml(s) {
55
+ return String(s).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
56
+ }