designlang 6.0.0 → 7.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.
Files changed (92) hide show
  1. package/.github/FUNDING.yml +1 -0
  2. package/.github/ISSUE_TEMPLATE/bug_report.yml +62 -0
  3. package/.github/ISSUE_TEMPLATE/config.yml +8 -0
  4. package/.github/ISSUE_TEMPLATE/feature_request.yml +28 -0
  5. package/.vercel/README.txt +11 -0
  6. package/.vercel/project.json +1 -0
  7. package/CHANGELOG.md +58 -0
  8. package/CONTRIBUTING.md +25 -0
  9. package/README.md +120 -8
  10. package/bin/design-extract.js +106 -3
  11. package/chrome-extension/README.md +41 -0
  12. package/chrome-extension/icons/favicon.svg +7 -0
  13. package/chrome-extension/icons/icon-128.png +0 -0
  14. package/chrome-extension/icons/icon-16.png +0 -0
  15. package/chrome-extension/icons/icon-32.png +0 -0
  16. package/chrome-extension/icons/icon-48.png +0 -0
  17. package/chrome-extension/manifest.json +26 -0
  18. package/chrome-extension/popup.html +167 -0
  19. package/chrome-extension/popup.js +59 -0
  20. package/docs/superpowers/plans/2026-04-18-designlang-v7.md +1121 -0
  21. package/docs/superpowers/specs/2026-04-18-designlang-v7-design.md +150 -0
  22. package/docs/superpowers/specs/2026-04-18-website-redesign-design.md +120 -0
  23. package/docs/superpowers/specs/2026-04-19-designlang-v7-1-design.md +111 -0
  24. package/package.json +5 -4
  25. package/src/config.js +26 -0
  26. package/src/crawler.js +136 -2
  27. package/src/extractors/a11y-remediation.js +47 -0
  28. package/src/extractors/component-clusters.js +39 -0
  29. package/src/extractors/css-health.js +151 -0
  30. package/src/extractors/scoring.js +20 -1
  31. package/src/extractors/semantic-regions.js +44 -0
  32. package/src/extractors/stack-fingerprint.js +88 -0
  33. package/src/formatters/_token-ref.js +44 -0
  34. package/src/formatters/agent-rules.js +116 -0
  35. package/src/formatters/android-compose.js +164 -0
  36. package/src/formatters/dtcg-tokens.js +175 -0
  37. package/src/formatters/flutter-dart.js +130 -0
  38. package/src/formatters/ios-swiftui.js +161 -0
  39. package/src/formatters/markdown.js +25 -0
  40. package/src/formatters/wordpress.js +183 -0
  41. package/src/index.js +30 -0
  42. package/src/mcp/resources.js +64 -0
  43. package/src/mcp/server.js +110 -0
  44. package/src/mcp/tools.js +149 -0
  45. package/src/utils-cookies.js +73 -0
  46. package/tests/cli.test.js +50 -0
  47. package/tests/cookies.test.js +98 -0
  48. package/tests/extractors.test.js +131 -0
  49. package/tests/formatters.test.js +232 -0
  50. package/tests/mcp.test.js +68 -0
  51. package/website/app/api/extract/route.js +216 -56
  52. package/website/app/components/A11ySlider.js +369 -0
  53. package/website/app/components/Comparison.js +286 -0
  54. package/website/app/components/CssHealth.js +243 -0
  55. package/website/app/components/HeroExtractor.js +455 -0
  56. package/website/app/components/Marginalia.js +3 -0
  57. package/website/app/components/McpSection.js +223 -0
  58. package/website/app/components/PlatformTabs.js +250 -0
  59. package/website/app/components/RegionsComponents.js +429 -0
  60. package/website/app/components/Rule.js +13 -0
  61. package/website/app/components/Specimens.js +237 -0
  62. package/website/app/components/StructuredData.js +144 -0
  63. package/website/app/components/TokenBrowser.js +344 -0
  64. package/website/app/components/token-browser-sample.js +65 -0
  65. package/website/app/globals.css +415 -633
  66. package/website/app/icon.svg +7 -0
  67. package/website/app/layout.js +113 -6
  68. package/website/app/opengraph-image.js +170 -0
  69. package/website/app/page.js +325 -148
  70. package/website/app/robots.js +15 -0
  71. package/website/app/seo-config.js +82 -0
  72. package/website/app/sitemap.js +18 -0
  73. package/website/lib/cache.js +73 -0
  74. package/website/lib/rate-limit.js +30 -0
  75. package/website/lib/rate-limit.test.js +55 -0
  76. package/website/lib/specimens.json +86 -0
  77. package/website/lib/token-helpers.js +70 -0
  78. package/website/lib/url-safety.js +103 -0
  79. package/website/lib/url-safety.test.js +116 -0
  80. package/website/lib/zip-files.js +15 -0
  81. package/website/package-lock.json +85 -0
  82. package/website/package.json +1 -0
  83. package/website/public/favicon.svg +7 -0
  84. package/website/public/logo-specimen.svg +76 -0
  85. package/website/public/mark.svg +12 -0
  86. package/website/public/site.webmanifest +13 -0
  87. package/website/app/favicon.ico +0 -0
  88. package/website/public/file.svg +0 -1
  89. package/website/public/globe.svg +0 -1
  90. package/website/public/next.svg +0 -1
  91. package/website/public/vercel.svg +0 -1
  92. package/website/public/window.svg +0 -1
package/src/index.js CHANGED
@@ -16,6 +16,11 @@ import { extractZIndex } from './extractors/zindex.js';
16
16
  import { extractIcons } from './extractors/icons.js';
17
17
  import { extractFonts } from './extractors/fonts.js';
18
18
  import { extractImageStyles } from './extractors/images.js';
19
+ import { extractStackFingerprint } from './extractors/stack-fingerprint.js';
20
+ import { extractCssHealth } from './extractors/css-health.js';
21
+ import { remediateFailingPairs } from './extractors/a11y-remediation.js';
22
+ import { extractSemanticRegions } from './extractors/semantic-regions.js';
23
+ import { clusterComponents } from './extractors/component-clusters.js';
19
24
 
20
25
  function safeExtract(fn, ...args) {
21
26
  try { return fn(...args); } catch { return null; }
@@ -54,6 +59,10 @@ export async function extractDesignLanguage(url, options = {}) {
54
59
  fonts: rawData.light.fontData ? (safeExtract(extractFonts, rawData.light.fontData) || { fonts: [], systemFonts: [] }) : { fonts: [], systemFonts: [] },
55
60
  images: rawData.light.images ? (safeExtract(extractImageStyles, rawData.light.images) || { patterns: [], aspectRatios: [] }) : { patterns: [], aspectRatios: [] },
56
61
  componentScreenshots: rawData.componentScreenshots || {},
62
+ stack: safeExtract(extractStackFingerprint, rawData.light.stack) || { framework: 'unknown', css: { layer: 'unknown', tailwind: null }, analytics: [], detectedFrom: { globalCount: 0, scriptCount: 0, classSampleSize: 0 } },
63
+ cssHealth: safeExtract(extractCssHealth, rawData.light.cssCoverage) || null,
64
+ regions: safeExtract(extractSemanticRegions, rawData.light.sections) || [],
65
+ componentClusters: safeExtract(clusterComponents, rawData.light.componentCandidates) || [],
57
66
  score: null,
58
67
  };
59
68
 
@@ -77,6 +86,26 @@ export async function extractDesignLanguage(url, options = {}) {
77
86
  };
78
87
  }
79
88
 
89
+ // A11y remediation: derive failing pairs from accessibility extractor output
90
+ // and propose palette colors that pass the matching WCAG rule.
91
+ try {
92
+ const a11y = design.accessibility || {};
93
+ const palette = (design.colors?.all || []).map(c => c.hex).filter(Boolean);
94
+ const failingPairs = (a11y.pairs || [])
95
+ .filter(p => p.level === 'FAIL')
96
+ .map(p => ({
97
+ fg: p.foreground,
98
+ bg: p.background,
99
+ ratio: p.ratio,
100
+ rule: p.isLargeText ? 'AA-large' : 'AA-normal',
101
+ }));
102
+ design.accessibility = {
103
+ ...a11y,
104
+ failingPairs,
105
+ remediation: remediateFailingPairs(failingPairs, palette),
106
+ };
107
+ } catch { /* non-fatal */ }
108
+
80
109
  design.score = safeExtract(scoreDesignSystem, design);
81
110
  if (design.score === null) warnings.push('scoring failed');
82
111
 
@@ -85,6 +114,7 @@ export async function extractDesignLanguage(url, options = {}) {
85
114
 
86
115
  export { crawlPage } from './crawler.js';
87
116
  export { formatTokens } from './formatters/tokens.js';
117
+ export { formatDtcgTokens } from './formatters/dtcg-tokens.js';
88
118
  export { formatMarkdown } from './formatters/markdown.js';
89
119
  export { formatTailwind } from './formatters/tailwind.js';
90
120
  export { formatCssVars } from './formatters/css-vars.js';
@@ -0,0 +1,64 @@
1
+ // MCP resources builder. Pure/testable — returns { list, read } over the
2
+ // loaded design + tokens. No transport concerns here.
3
+
4
+ const URIS = {
5
+ 'designlang://tokens/primitive': {
6
+ name: 'Primitive tokens',
7
+ description: 'DTCG primitive tier (raw colors, spacing, fonts, etc.)',
8
+ },
9
+ 'designlang://tokens/semantic': {
10
+ name: 'Semantic tokens',
11
+ description: 'DTCG semantic tier (aliases like action.primary, surface.default)',
12
+ },
13
+ 'designlang://regions': {
14
+ name: 'Semantic regions',
15
+ description: 'Detected page regions (hero, nav, footer, etc.) with bounds',
16
+ },
17
+ 'designlang://components': {
18
+ name: 'Component clusters',
19
+ description: 'Clustered component instances with variant CSS',
20
+ },
21
+ 'designlang://health': {
22
+ name: 'CSS health',
23
+ description: 'Coverage, dead-rule, and z-index-stack diagnostics',
24
+ },
25
+ };
26
+
27
+ function rpcError(code, message) {
28
+ const e = new Error(message);
29
+ e.code = code;
30
+ return e;
31
+ }
32
+
33
+ export function buildResources({ design, tokens }) {
34
+ function payloadFor(uri) {
35
+ switch (uri) {
36
+ case 'designlang://tokens/primitive': return tokens?.primitive ?? null;
37
+ case 'designlang://tokens/semantic': return tokens?.semantic ?? null;
38
+ case 'designlang://regions': return design?.regions ?? [];
39
+ case 'designlang://components': return design?.componentClusters ?? [];
40
+ case 'designlang://health': return design?.cssHealth ?? null;
41
+ default: return undefined;
42
+ }
43
+ }
44
+
45
+ return {
46
+ list() {
47
+ return Object.keys(URIS).map((uri) => ({
48
+ uri,
49
+ name: URIS[uri].name,
50
+ description: URIS[uri].description,
51
+ mimeType: 'application/json',
52
+ }));
53
+ },
54
+ read(uri) {
55
+ const payload = payloadFor(uri);
56
+ if (payload === undefined) throw rpcError(-32602, `Unknown resource URI: ${uri}`);
57
+ return {
58
+ uri,
59
+ mimeType: 'application/json',
60
+ text: JSON.stringify(payload),
61
+ };
62
+ },
63
+ };
64
+ }
@@ -0,0 +1,110 @@
1
+ // Thin stdio MCP server glue. Loads the latest extraction from the given
2
+ // output directory and wires buildResources + buildTools into the SDK.
3
+
4
+ import { existsSync, readdirSync, readFileSync, statSync } from 'fs';
5
+ import { join, resolve } from 'path';
6
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
7
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
8
+ import {
9
+ ListResourcesRequestSchema,
10
+ ReadResourceRequestSchema,
11
+ ListToolsRequestSchema,
12
+ CallToolRequestSchema,
13
+ } from '@modelcontextprotocol/sdk/types.js';
14
+ import { buildResources } from './resources.js';
15
+ import { buildTools } from './tools.js';
16
+
17
+ // Best-effort reconstruction of a design object from the files on disk.
18
+ // We only need what the tools/resources consume: tokens + regions +
19
+ // componentClusters + accessibility.remediation + cssHealth + colors.all.
20
+ function loadExtraction(outputDir) {
21
+ if (!existsSync(outputDir)) return null;
22
+ const entries = readdirSync(outputDir);
23
+ const tokenFiles = entries
24
+ .filter((f) => f.endsWith('-design-tokens.json'))
25
+ .map((f) => {
26
+ const p = join(outputDir, f);
27
+ return { path: p, mtime: statSync(p).mtimeMs, prefix: f.replace(/-design-tokens\.json$/, '') };
28
+ })
29
+ .sort((a, b) => b.mtime - a.mtime);
30
+
31
+ if (!tokenFiles.length) return null;
32
+ const latest = tokenFiles[0];
33
+
34
+ let tokens;
35
+ try { tokens = JSON.parse(readFileSync(latest.path, 'utf-8')); } catch { return null; }
36
+
37
+ // Legacy (pre-v7) tokens don't have primitive/semantic. Skip silently.
38
+ if (!tokens?.primitive || !tokens?.semantic) {
39
+ return { tokens: null, design: {} };
40
+ }
41
+
42
+ // Load the MCP companion written at extraction time (`*-mcp.json`).
43
+ // Falls back to empty shape if absent — older extractions stay usable for
44
+ // token resources/tools even without regions/components/health.
45
+ const companionPath = join(outputDir, `${latest.prefix}-mcp.json`);
46
+ let companion = null;
47
+ if (existsSync(companionPath)) {
48
+ try { companion = JSON.parse(readFileSync(companionPath, 'utf-8')); } catch { /* ignore */ }
49
+ }
50
+
51
+ const design = {
52
+ colors: { all: companion?.colors?.all || [] },
53
+ regions: companion?.regions || [],
54
+ componentClusters: companion?.componentClusters || [],
55
+ accessibility: { remediation: companion?.accessibility?.remediation || [] },
56
+ cssHealth: companion?.cssHealth ?? null,
57
+ };
58
+
59
+ return { tokens, design };
60
+ }
61
+
62
+ export async function run({ outputDir }) {
63
+ const resolved = resolve(outputDir || './design-extract-output');
64
+ const loaded = loadExtraction(resolved);
65
+ const tokens = loaded?.tokens ?? null;
66
+ const design = loaded?.design ?? {};
67
+
68
+ const resources = buildResources({ design, tokens });
69
+ const tools = buildTools({ design, tokens });
70
+
71
+ const server = new Server(
72
+ { name: 'designlang', version: '7.0.0' },
73
+ { capabilities: { resources: {}, tools: {} } },
74
+ );
75
+
76
+ server.setRequestHandler(ListResourcesRequestSchema, async () => ({
77
+ resources: tokens ? resources.list() : [],
78
+ }));
79
+
80
+ server.setRequestHandler(ReadResourceRequestSchema, async (req) => {
81
+ if (!tokens) throw Object.assign(new Error('no extraction loaded'), { code: -32002 });
82
+ const { uri } = req.params;
83
+ const r = resources.read(uri);
84
+ return { contents: [r] };
85
+ });
86
+
87
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: tools.list() }));
88
+
89
+ server.setRequestHandler(CallToolRequestSchema, async (req) => {
90
+ if (!tokens) {
91
+ return {
92
+ isError: true,
93
+ content: [{ type: 'text', text: 'no extraction loaded' }],
94
+ };
95
+ }
96
+ const { name, arguments: args } = req.params;
97
+ try {
98
+ const result = await tools.call(name, args);
99
+ return { content: [{ type: 'text', text: JSON.stringify(result) }] };
100
+ } catch (err) {
101
+ return {
102
+ isError: true,
103
+ content: [{ type: 'text', text: err?.message || String(err) }],
104
+ };
105
+ }
106
+ });
107
+
108
+ const transport = new StdioServerTransport();
109
+ await server.connect(transport);
110
+ }
@@ -0,0 +1,149 @@
1
+ // MCP tools builder. Pure/testable — returns { list, call } over the
2
+ // loaded design + tokens. No transport concerns here.
3
+
4
+ import { remediateFailingPairs } from '../extractors/a11y-remediation.js';
5
+
6
+ function rpcError(code, message) {
7
+ const e = new Error(message);
8
+ e.code = code;
9
+ return e;
10
+ }
11
+
12
+ // Flatten a DTCG token tree to [{ path, $type, $value }] leaves.
13
+ function flattenTokens(tree, prefix, out) {
14
+ if (tree == null || typeof tree !== 'object') return;
15
+ if ('$value' in tree) {
16
+ out.push({ path: prefix, $type: tree.$type, $value: tree.$value });
17
+ return;
18
+ }
19
+ for (const key of Object.keys(tree)) {
20
+ const next = prefix ? `${prefix}.${key}` : key;
21
+ flattenTokens(tree[key], next, out);
22
+ }
23
+ }
24
+
25
+ function paletteHexes(design) {
26
+ const all = design?.colors?.all || [];
27
+ const out = [];
28
+ for (const entry of all) {
29
+ if (!entry) continue;
30
+ if (typeof entry === 'string') out.push(entry);
31
+ else if (typeof entry === 'object' && typeof entry.hex === 'string') out.push(entry.hex);
32
+ }
33
+ return out;
34
+ }
35
+
36
+ const TOOL_DEFS = [
37
+ {
38
+ name: 'search_tokens',
39
+ description: 'Case-insensitive substring match over all token dot-paths.',
40
+ inputSchema: {
41
+ type: 'object',
42
+ properties: { query: { type: 'string', description: 'substring to search for' } },
43
+ required: ['query'],
44
+ },
45
+ },
46
+ {
47
+ name: 'find_nearest_color',
48
+ description: 'Find the nearest palette color that passes the requested WCAG contrast rule against the given hex.',
49
+ inputSchema: {
50
+ type: 'object',
51
+ properties: {
52
+ hex: { type: 'string', description: 'background hex (e.g. #ffffff)' },
53
+ level: { type: 'string', enum: ['AA-normal', 'AA-large', 'AAA-normal', 'AAA-large'] },
54
+ },
55
+ required: ['hex', 'level'],
56
+ },
57
+ },
58
+ {
59
+ name: 'get_region',
60
+ description: 'Return the region with the given role (e.g. hero, nav, footer).',
61
+ inputSchema: {
62
+ type: 'object',
63
+ properties: { name: { type: 'string' } },
64
+ required: ['name'],
65
+ },
66
+ },
67
+ {
68
+ name: 'get_component',
69
+ description: 'Return the component cluster with matching kind, optionally narrowed to a variant index.',
70
+ inputSchema: {
71
+ type: 'object',
72
+ properties: {
73
+ name: { type: 'string' },
74
+ variant: { type: 'number' },
75
+ },
76
+ required: ['name'],
77
+ },
78
+ },
79
+ {
80
+ name: 'list_failing_contrast_pairs',
81
+ description: 'Return the accessibility remediation array (failing fg/bg pairs with suggestions).',
82
+ inputSchema: { type: 'object', properties: {} },
83
+ },
84
+ ];
85
+
86
+ export function buildTools({ design, tokens }) {
87
+ // Pre-flatten for search.
88
+ const flat = [];
89
+ flattenTokens(tokens?.primitive, 'primitive', flat);
90
+ flattenTokens(tokens?.semantic, 'semantic', flat);
91
+
92
+ async function searchTokens(args) {
93
+ if (!args || typeof args.query !== 'string') throw rpcError(-32602, 'search_tokens: query must be a string');
94
+ const q = args.query.toLowerCase();
95
+ const matches = flat.filter(t => t.path.toLowerCase().includes(q));
96
+ return { matches };
97
+ }
98
+
99
+ async function findNearestColor(args) {
100
+ if (!args || typeof args.hex !== 'string') throw rpcError(-32602, 'find_nearest_color: hex must be a string');
101
+ const validLevels = new Set(['AA-normal', 'AA-large', 'AAA-normal', 'AAA-large']);
102
+ if (!validLevels.has(args.level)) throw rpcError(-32602, 'find_nearest_color: level must be one of AA-normal|AA-large|AAA-normal|AAA-large');
103
+ const palette = paletteHexes(design);
104
+ const [res] = remediateFailingPairs(
105
+ [{ fg: '#000000', bg: args.hex, ratio: 0, rule: args.level }],
106
+ palette,
107
+ );
108
+ if (!res?.suggestion) return { color: null, newRatio: null };
109
+ return { color: res.suggestion.color, newRatio: res.suggestion.newRatio, replace: res.suggestion.replace };
110
+ }
111
+
112
+ async function getRegion(args) {
113
+ if (!args || typeof args.name !== 'string') throw rpcError(-32602, 'get_region: name must be a string');
114
+ const regions = design?.regions || [];
115
+ return regions.find(r => r && (r.role === args.name || r.name === args.name)) || null;
116
+ }
117
+
118
+ async function getComponent(args) {
119
+ if (!args || typeof args.name !== 'string') throw rpcError(-32602, 'get_component: name must be a string');
120
+ const clusters = design?.componentClusters || [];
121
+ const found = clusters.find(c => c && c.kind === args.name);
122
+ if (!found) return null;
123
+ if (typeof args.variant === 'number' && Array.isArray(found.variants)) {
124
+ return found.variants[args.variant] ?? null;
125
+ }
126
+ return found;
127
+ }
128
+
129
+ async function listFailing() {
130
+ return design?.accessibility?.remediation || [];
131
+ }
132
+
133
+ const dispatch = {
134
+ search_tokens: searchTokens,
135
+ find_nearest_color: findNearestColor,
136
+ get_region: getRegion,
137
+ get_component: getComponent,
138
+ list_failing_contrast_pairs: listFailing,
139
+ };
140
+
141
+ return {
142
+ list() { return TOOL_DEFS.slice(); },
143
+ async call(name, args) {
144
+ const fn = dispatch[name];
145
+ if (!fn) throw rpcError(-32602, `Unknown tool: ${name}`);
146
+ return await fn(args || {});
147
+ },
148
+ };
149
+ }
@@ -0,0 +1,73 @@
1
+ // Cookie file loaders. Supports three formats so users can paste whatever
2
+ // their existing tooling exports:
3
+ // - JSON array of Playwright cookie objects: [{name, value, domain, path, …}]
4
+ // - Playwright storageState JSON: { cookies: [...], origins: [...] }
5
+ // - Netscape cookies.txt: tab-separated lines (curl / wget / browser extensions)
6
+ //
7
+ // Returned shape is always the Playwright cookie array.
8
+
9
+ import { readFileSync } from 'fs';
10
+
11
+ function parseNetscape(text, targetUrl) {
12
+ const cookies = [];
13
+ const lines = text.split(/\r?\n/);
14
+ for (const raw of lines) {
15
+ const line = raw.trim();
16
+ if (!line) continue;
17
+ // Skip comment lines, but keep the Netscape `#HttpOnly_<domain>` prefix
18
+ // that browsers use to mark HttpOnly cookies — those are real entries.
19
+ if (line.startsWith('#') && !/^#HttpOnly_/i.test(line)) continue;
20
+ const parts = raw.split('\t');
21
+ if (parts.length < 7) continue;
22
+ const [domain, , path, secure, expires, name, value] = parts;
23
+ if (!name) continue;
24
+ const cookie = {
25
+ name,
26
+ value: value ?? '',
27
+ domain: domain.replace(/^#HttpOnly_/i, ''),
28
+ path: path || '/',
29
+ secure: secure === 'TRUE',
30
+ httpOnly: /^#HttpOnly_/i.test(domain),
31
+ };
32
+ const exp = Number(expires);
33
+ if (Number.isFinite(exp) && exp > 0) cookie.expires = exp;
34
+ if (!cookie.domain && targetUrl) cookie.url = targetUrl;
35
+ cookies.push(cookie);
36
+ }
37
+ return cookies;
38
+ }
39
+
40
+ function parseJson(text) {
41
+ const parsed = JSON.parse(text);
42
+ // Playwright storageState: { cookies: [...], origins: [...] }
43
+ if (parsed && Array.isArray(parsed.cookies)) return parsed.cookies;
44
+ // Raw array
45
+ if (Array.isArray(parsed)) return parsed;
46
+ throw new Error('cookie file: JSON must be a cookie array or Playwright storageState');
47
+ }
48
+
49
+ export function loadCookiesFromFile(filePath, targetUrl) {
50
+ const text = readFileSync(filePath, 'utf-8');
51
+ const trimmed = text.trimStart();
52
+ if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
53
+ return parseJson(text);
54
+ }
55
+ return parseNetscape(text, targetUrl);
56
+ }
57
+
58
+ // Merge CLI-provided cookies (name=value strings) with file cookies.
59
+ // Later entries (file) override earlier entries (CLI) at the same name+domain.
60
+ export function mergeCookies(cliCookies = [], fileCookies = [], targetUrl) {
61
+ const seen = new Map();
62
+ const parseCli = (c) => {
63
+ if (typeof c !== 'string') return c;
64
+ const [name, ...rest] = c.split('=');
65
+ return { name, value: rest.join('='), url: targetUrl };
66
+ };
67
+ for (const c of [...cliCookies.map(parseCli), ...fileCookies]) {
68
+ if (!c || !c.name) continue;
69
+ const key = `${c.name}|${c.domain || c.url || ''}`;
70
+ seen.set(key, c);
71
+ }
72
+ return [...seen.values()];
73
+ }
package/tests/cli.test.js CHANGED
@@ -2,6 +2,7 @@ import { describe, it } from 'node:test';
2
2
  import assert from 'node:assert/strict';
3
3
  import { execFileSync } from 'node:child_process';
4
4
  import { resolve } from 'node:path';
5
+ import { parsePlatforms, mergeConfig } from '../src/config.js';
5
6
 
6
7
  const CLI_PATH = resolve(import.meta.dirname, '..', 'bin', 'design-extract.js');
7
8
 
@@ -31,4 +32,53 @@ describe('CLI', () => {
31
32
  assert.ok(err.status !== 0);
32
33
  }
33
34
  });
35
+
36
+ it('lists --platforms option in help output', () => {
37
+ const output = execFileSync('node', [CLI_PATH, '--help'], { encoding: 'utf-8' });
38
+ assert.ok(output.includes('--platforms'));
39
+ });
40
+ });
41
+
42
+ describe('parsePlatforms', () => {
43
+ it('defaults to web only', () => {
44
+ assert.deepEqual(parsePlatforms('web'), ['web']);
45
+ });
46
+
47
+ it('parses comma-separated values', () => {
48
+ assert.deepEqual(parsePlatforms('ios,android'), ['web', 'ios', 'android']);
49
+ });
50
+
51
+ it('expands "all" to every known platform', () => {
52
+ assert.deepEqual(parsePlatforms('all'), ['web', 'ios', 'android', 'flutter', 'wordpress']);
53
+ });
54
+
55
+ it('always includes web (additive)', () => {
56
+ assert.ok(parsePlatforms('ios').includes('web'));
57
+ assert.ok(parsePlatforms('wordpress').includes('web'));
58
+ });
59
+
60
+ it('ignores unknown platforms', () => {
61
+ assert.deepEqual(parsePlatforms('ios,badplatform,android'), ['web', 'ios', 'android']);
62
+ });
63
+
64
+ it('accepts arrays', () => {
65
+ assert.deepEqual(parsePlatforms(['ios', 'flutter']), ['web', 'ios', 'flutter']);
66
+ });
67
+ });
68
+
69
+ describe('mergeConfig platforms', () => {
70
+ it('threads CLI --platforms through mergeConfig', () => {
71
+ const merged = mergeConfig({ platforms: 'ios,flutter' }, {});
72
+ assert.deepEqual(merged.platforms, ['web', 'ios', 'flutter']);
73
+ });
74
+
75
+ it('honors platforms from config file', () => {
76
+ const merged = mergeConfig({}, { platforms: 'android' });
77
+ assert.deepEqual(merged.platforms, ['web', 'android']);
78
+ });
79
+
80
+ it('defaults to [web] when neither CLI nor config provides platforms', () => {
81
+ const merged = mergeConfig({}, {});
82
+ assert.deepEqual(merged.platforms, ['web']);
83
+ });
34
84
  });
@@ -0,0 +1,98 @@
1
+ import { describe, it } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { writeFileSync, mkdtempSync } from 'fs';
4
+ import { tmpdir } from 'os';
5
+ import { join } from 'path';
6
+ import { loadCookiesFromFile, mergeCookies } from '../src/utils-cookies.js';
7
+
8
+ const tmp = mkdtempSync(join(tmpdir(), 'dl-cookies-'));
9
+
10
+ function tmpFile(name, text) {
11
+ const p = join(tmp, name);
12
+ writeFileSync(p, text, 'utf-8');
13
+ return p;
14
+ }
15
+
16
+ describe('loadCookiesFromFile', () => {
17
+ it('loads JSON array', () => {
18
+ const f = tmpFile('c.json', JSON.stringify([
19
+ { name: 'session', value: 'abc', domain: '.example.com', path: '/' },
20
+ ]));
21
+ const out = loadCookiesFromFile(f, 'https://example.com');
22
+ assert.equal(out.length, 1);
23
+ assert.equal(out[0].name, 'session');
24
+ assert.equal(out[0].domain, '.example.com');
25
+ });
26
+
27
+ it('loads Playwright storageState', () => {
28
+ const f = tmpFile('state.json', JSON.stringify({
29
+ cookies: [{ name: 'csrf', value: 'xyz', domain: 'example.com', path: '/' }],
30
+ origins: [],
31
+ }));
32
+ const out = loadCookiesFromFile(f, 'https://example.com');
33
+ assert.equal(out.length, 1);
34
+ assert.equal(out[0].name, 'csrf');
35
+ });
36
+
37
+ it('loads Netscape cookies.txt with tab-separated lines and comment', () => {
38
+ const netscape = [
39
+ '# Netscape HTTP Cookie File',
40
+ '# comment',
41
+ '.example.com\tTRUE\t/\tFALSE\t1765000000\tsid\tabc123',
42
+ '#HttpOnly_.example.com\tTRUE\t/\tTRUE\t0\tauth\ttoken-value',
43
+ ].join('\n');
44
+ const f = tmpFile('c.txt', netscape);
45
+ const out = loadCookiesFromFile(f, 'https://example.com');
46
+ assert.equal(out.length, 2);
47
+ const sid = out.find((c) => c.name === 'sid');
48
+ assert.equal(sid.value, 'abc123');
49
+ assert.equal(sid.domain, '.example.com');
50
+ assert.equal(sid.secure, false);
51
+ assert.equal(sid.expires, 1765000000);
52
+ const auth = out.find((c) => c.name === 'auth');
53
+ assert.equal(auth.httpOnly, true);
54
+ assert.equal(auth.secure, true);
55
+ });
56
+
57
+ it('throws on invalid JSON shape', () => {
58
+ const f = tmpFile('bad.json', '{"not":"array"}');
59
+ assert.throws(() => loadCookiesFromFile(f, 'https://example.com'));
60
+ });
61
+ });
62
+
63
+ describe('mergeCookies', () => {
64
+ it('parses CLI name=value strings into cookie objects', () => {
65
+ const out = mergeCookies(['session=abc'], [], 'https://example.com');
66
+ assert.equal(out.length, 1);
67
+ assert.equal(out[0].name, 'session');
68
+ assert.equal(out[0].value, 'abc');
69
+ assert.equal(out[0].url, 'https://example.com');
70
+ });
71
+
72
+ it('lets file cookies override CLI cookies with the same name+domain', () => {
73
+ const out = mergeCookies(
74
+ [{ name: 'session', value: 'cli', domain: '.example.com' }],
75
+ [{ name: 'session', value: 'file', domain: '.example.com' }],
76
+ 'https://example.com',
77
+ );
78
+ assert.equal(out.length, 1);
79
+ assert.equal(out[0].value, 'file');
80
+ });
81
+
82
+ it('keeps cookies with different domains separately', () => {
83
+ const out = mergeCookies(
84
+ [],
85
+ [
86
+ { name: 'x', value: '1', domain: 'a.example.com' },
87
+ { name: 'x', value: '2', domain: 'b.example.com' },
88
+ ],
89
+ 'https://example.com',
90
+ );
91
+ assert.equal(out.length, 2);
92
+ });
93
+
94
+ it('strips entries without a name', () => {
95
+ const out = mergeCookies([], [{ value: 'orphan' }], 'https://example.com');
96
+ assert.equal(out.length, 0);
97
+ });
98
+ });