@gakim-digital/dexter-bridge 0.11.17 → 0.11.20

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.
@@ -4,6 +4,7 @@ import { spawn } from 'node:child_process';
4
4
  import { createRequire } from 'node:module';
5
5
  import path from 'node:path';
6
6
  import jsTokens from 'js-tokens';
7
+ import { loadGoogleFontsCatalog, searchGoogleFonts } from './googleFontsFallback.js';
7
8
  import {
8
9
  canonicalIdMappings,
9
10
  evidenceNodeIds,
@@ -81,19 +82,6 @@ const EMPTY_SCHEMA = {
81
82
  };
82
83
 
83
84
  export const FRAMER_AGENT_TOOL_DEFINITIONS = [
84
- {
85
- name: 'progress_update',
86
- description:
87
- 'Tell the user what you are about to do or are doing now in one or two natural first-person sentences. Name the specific page area, component, or behavior and the concrete change; optionally say what you will check next. Avoid generic updates such as "working on the next step", "thinking", "loading", or "making changes". Call this before the first Framer inspection or edit and at meaningful phase changes.',
88
- inputSchema: {
89
- type: 'object',
90
- properties: {
91
- message: { type: 'string', minLength: 1, maxLength: 500 },
92
- },
93
- required: ['message'],
94
- additionalProperties: false,
95
- },
96
- },
97
85
  {
98
86
  name: 'framer_instructions',
99
87
  description:
@@ -124,7 +112,7 @@ export const FRAMER_AGENT_TOOL_DEFINITIONS = [
124
112
  {
125
113
  name: 'framer_read_project',
126
114
  description:
127
- 'Run Framer Agent readProject queries against the connected project. Use this for focused canvas inspection and screenshots.',
115
+ 'Run Framer Agent readProject queries: "screenshot", "font-search", and "implementation-guide-from-index" are the only query types. Read nodes and layout with framer_read instead.',
128
116
  inputSchema: {
129
117
  type: 'object',
130
118
  properties: {
@@ -228,7 +216,7 @@ export const FRAMER_AGENT_TOOL_DEFINITIONS = [
228
216
  {
229
217
  name: 'framer_verify_interactions',
230
218
  description:
231
- 'Confirm planned behaviors against the live project. For each target node, name the Framer property that carries the behavior (for example loopEffect, hoverEffect, appearEffect, link, or onTap) and optionally a literal the value must contain (such as a route or SHOW_OVERLAY). Results: verified (the property is set and matches), contradicted (the property holds a different value), or unknown (the property was not found on the node). Unknown never blocks your work; it is reported as unverified. Checks accumulate across calls, so you can verify targets on several pages.',
219
+ 'Confirm planned behaviors against the live project. For each target node, name the Framer property that carries the behavior (for example loopEffect, hoverEffect, appearEffect, link, or onTap, or a field inside one such as link.href or hoverEffect.opacity) and optionally a literal the value must contain (such as a route or SHOW_OVERLAY). Results: verified (the property is set and matches), contradicted (the property holds a different value), or unknown (the property was not found on the node). Unknown never blocks your work; it is reported as unverified. Checks accumulate across calls, so you can verify targets on several pages.',
232
220
  inputSchema: {
233
221
  type: 'object',
234
222
  properties: {
@@ -288,12 +276,20 @@ export const FRAMER_AGENT_TOOL_DEFINITIONS = [
288
276
  {
289
277
  name: 'framer_apply_changes',
290
278
  description:
291
- 'Apply Framer Agent DSL changes directly to the connected project. Prefer this for page, layout, style, design-token, component, and CMS-on-canvas work.',
279
+ 'Apply Framer Agent DSL changes directly to the connected project. Prefer this for page, layout, style, design-token, component, and CMS-on-canvas work. Pass screenshotNodeIds to get screenshots of those nodes taken after the change in the same result, instead of a separate read.',
292
280
  inputSchema: {
293
281
  type: 'object',
294
282
  properties: {
295
283
  changes: { type: 'string', minLength: 1, maxLength: 250_000 },
296
284
  pagePath: { type: 'string', maxLength: 500, default: '/' },
285
+ screenshotNodeIds: {
286
+ type: 'array',
287
+ maxItems: MAX_SCREENSHOTS_PER_CALL,
288
+ uniqueItems: true,
289
+ items: { type: 'string', minLength: 1, maxLength: 200 },
290
+ description:
291
+ 'Nodes (or component variants) to screenshot after the change is applied. The screenshots count as visual evidence after this mutation.',
292
+ },
297
293
  },
298
294
  required: ['changes'],
299
295
  additionalProperties: false,
@@ -360,7 +356,7 @@ export const FRAMER_AGENT_TOOL_DEFINITIONS = [
360
356
  {
361
357
  name: 'framer_read',
362
358
  description:
363
- 'Execute read-only JavaScript with the official Framer Agent connection for capabilities not covered by readProject.',
359
+ 'Execute read-only JavaScript with the official Framer Agent connection. Use it to read nodes and layout, for example with framer.agent.getNode, serialize, or serializeNodes; combine several reads in one script.',
364
360
  inputSchema: {
365
361
  type: 'object',
366
362
  properties: {
@@ -539,12 +535,21 @@ function onDemandGuideCatalog(projectRoot) {
539
535
  return catalog;
540
536
  }
541
537
 
538
+ // Returns a "## " or "### " section up to the next heading of the same or a
539
+ // higher level.
542
540
  function markdownSection(markdown, heading) {
543
541
  const lines = markdown.split('\n');
544
- const start = lines.findIndex((line) => line.trim() === `## ${heading}`);
542
+ const headingLevel = (line) => /^(#{1,6}) /.exec(line)?.[1].length || 0;
543
+ const start = lines.findIndex(
544
+ (line) => headingLevel(line) >= 2
545
+ && line.trim().replace(/^#+ /, '') === heading,
546
+ );
545
547
  if (start < 0) return '';
548
+ const level = headingLevel(lines[start]);
546
549
  const end = lines.findIndex(
547
- (line, index) => index > start && line.startsWith('## '),
550
+ (line, index) => index > start
551
+ && headingLevel(line) > 0
552
+ && headingLevel(line) <= level,
548
553
  );
549
554
  return lines.slice(start, end < 0 ? undefined : end).join('\n').trim();
550
555
  }
@@ -1196,7 +1201,6 @@ export function createFramerAgentToolRuntime({
1196
1201
  let lowLevelWriteCalls = 0;
1197
1202
  let imageSearchCalls = 0;
1198
1203
  let readCalls = 0;
1199
- let progressCalls = 0;
1200
1204
  let toolCallCount = 0;
1201
1205
  const toolCalls = [];
1202
1206
  let repeatedFailureFingerprint = null;
@@ -1207,6 +1211,7 @@ export function createFramerAgentToolRuntime({
1207
1211
  let lowLevelWriteDisabled = false;
1208
1212
  let lowLevelWriteBlockedCalls = 0;
1209
1213
  let instructionFallbacks = 0;
1214
+ let fontSearchFallbacks = 0;
1210
1215
  let instructionLookupUnavailable = false;
1211
1216
  let branchChange = null;
1212
1217
  let lastInspectionAt = null;
@@ -1556,14 +1561,23 @@ export function createFramerAgentToolRuntime({
1556
1561
  // Every run gets the guidance an edit always needs: principles, command
1557
1562
  // syntax, tool APIs, scope and replica rules, and the edit loop. Topic
1558
1563
  // guides (CMS, components, creation strategy, examples, ...) are listed
1559
- // in a catalog and read on demand through framer_instructions.
1564
+ // in a catalog and read on demand through framer_instructions. The CLI,
1565
+ // session, and file-map parts of SKILL.md and index.md do not apply to
1566
+ // Dexter's tools, and index.md changes every session, so both are left
1567
+ // out; the guidance stays byte-identical across runs and can be cached.
1568
+ // The project inventory changes with the project and is returned apart.
1560
1569
  const requestedFiles = [
1561
- { root: skillRoot, path: 'SKILL.md', label: 'SKILL.md' },
1562
- { root: projectRoot, path: 'index.md', label: 'project/index.md' },
1563
1570
  {
1564
- root: projectRoot,
1565
- path: 'project-inventory.md',
1566
- label: 'project/project-inventory.md',
1571
+ root: skillRoot,
1572
+ path: 'SKILL.md',
1573
+ section: 'Method Selection',
1574
+ label: 'SKILL.md#Method Selection',
1575
+ },
1576
+ {
1577
+ root: skillRoot,
1578
+ path: 'SKILL.md',
1579
+ section: 'Core Usage Principles',
1580
+ label: 'SKILL.md#Core Usage Principles',
1567
1581
  },
1568
1582
  { root: projectRoot, path: 'prompt/overview.md' },
1569
1583
  { root: projectRoot, path: 'prompt/core-principles.md' },
@@ -1609,8 +1623,10 @@ export function createFramerAgentToolRuntime({
1609
1623
  ...files.map((file) => `## ${file.path}\n\n${file.content}`),
1610
1624
  ...(catalogText ? [catalogText] : []),
1611
1625
  ].join('\n\n');
1612
- const inventory = files.find(
1613
- (file) => file.path.endsWith('project-inventory.md'),
1626
+ const inventory = readGuidanceFile(
1627
+ projectRoot,
1628
+ 'project-inventory.md',
1629
+ MAX_GUIDANCE_FILE_BYTES,
1614
1630
  )?.content || '';
1615
1631
  guidanceMetadata = {
1616
1632
  loaded: Boolean(content),
@@ -1620,10 +1636,13 @@ export function createFramerAgentToolRuntime({
1620
1636
  projectContextHash: inventory
1621
1637
  ? crypto.createHash('sha256').update(inventory).digest('hex')
1622
1638
  : null,
1623
- files: files.map((file) => file.path),
1624
- bytes: Buffer.byteLength(content, 'utf8'),
1639
+ files: [
1640
+ ...files.map((file) => file.path),
1641
+ ...(inventory ? ['project/project-inventory.md'] : []),
1642
+ ],
1643
+ bytes: Buffer.byteLength(content + inventory, 'utf8'),
1625
1644
  };
1626
- return { content, ...guidanceMetadata };
1645
+ return { content, inventory, ...guidanceMetadata };
1627
1646
  };
1628
1647
 
1629
1648
  const configurePromptContext = ({
@@ -1736,7 +1755,74 @@ export function createFramerAgentToolRuntime({
1736
1755
  capture: captured.error,
1737
1756
  });
1738
1757
  }
1739
- return { images, viaDownload, viaSession, undelivered, failures };
1758
+ screenshotsViaDownload += viaDownload;
1759
+ screenshotsViaSession += viaSession;
1760
+ screenshotsUndelivered += undelivered;
1761
+ screenshotFailures.push(...failures);
1762
+ screenshotFailures.splice(
1763
+ 0,
1764
+ Math.max(0, screenshotFailures.length - MAX_SCREENSHOT_FAILURES_REPORTED),
1765
+ );
1766
+ return images;
1767
+ };
1768
+
1769
+ const readProject = (queries, pagePath) => execCode(
1770
+ [
1771
+ `const result = await framer.agent.readProject(${JSON.stringify(queries)}, { pagePath: ${JSON.stringify(pagePath)} });`,
1772
+ 'console.log(JSON.stringify(result, null, 2));',
1773
+ ].join('\n'),
1774
+ );
1775
+
1776
+ // Runs readProject screenshot queries and hands the pixels to the model.
1777
+ const readScreenshots = async (queries, pagePath) => {
1778
+ const result = await readProject(queries, pagePath);
1779
+ return deliverScreenshots(queries, parseStructuredOutput(result.stdout));
1780
+ };
1781
+
1782
+ // Framer bills style font searches to the workspace's AI credit and fails
1783
+ // the whole read when they cannot run. Those searches are then answered
1784
+ // from the Google Fonts catalog and the remaining queries are read again.
1785
+ // Returns null when there is nothing to answer, so the original error stands.
1786
+ const readProjectWithFontFallback = async (queries, pagePath) => {
1787
+ try {
1788
+ return await readProject(queries, pagePath);
1789
+ } catch (error) {
1790
+ const styleSearch = (query) =>
1791
+ query.type === 'font-search' && typeof query.query === 'string';
1792
+ if (!queries.some(styleSearch)) throw error;
1793
+ let catalog;
1794
+ try {
1795
+ catalog = await loadGoogleFontsCatalog(fetchImpl);
1796
+ } catch (catalogError) {
1797
+ trace?.warn?.('google_fonts_fallback_unavailable', {
1798
+ message: catalogError?.message,
1799
+ });
1800
+ throw error;
1801
+ }
1802
+ const remaining = queries.filter((query) => !styleSearch(query));
1803
+ const remainingResults = remaining.length
1804
+ ? parseStructuredOutput((await readProject(remaining, pagePath)).stdout)?.results || []
1805
+ : [];
1806
+ fontSearchFallbacks += 1;
1807
+ trace?.warn?.('framer_font_search_fallback', {
1808
+ message: boundedText(String(error?.message || ''), 300),
1809
+ });
1810
+ const reason = boundedText(String(error?.message || 'unavailable'), 160);
1811
+ let next = 0;
1812
+ return {
1813
+ stdout: JSON.stringify({
1814
+ results: queries.map((query) => (styleSearch(query)
1815
+ ? {
1816
+ type: 'font-search',
1817
+ source: 'google-fonts',
1818
+ note: `Framer's style font search failed (${reason}), so these candidates come from the Google Fonts library, which Framer includes. Choose by how the family looks, set fontName to it, and use a listed weight. If you know a Google Fonts family that fits better, confirm it with a font-search by name.`,
1819
+ results: searchGoogleFonts(catalog, query),
1820
+ }
1821
+ : remainingResults[next++] ?? null)),
1822
+ }),
1823
+ stderr: '',
1824
+ };
1825
+ }
1740
1826
  };
1741
1827
 
1742
1828
  const referenceAssetFiles = Array.isArray(assignment?.context?.referenceAssetFiles)
@@ -2105,6 +2191,17 @@ export function createFramerAgentToolRuntime({
2105
2191
  ].join(' ');
2106
2192
  };
2107
2193
 
2194
+ const evidenceRequirement = (check) => {
2195
+ if (check === 'structural') return 'a focused live read';
2196
+ if (check === 'visual') {
2197
+ return 'a screenshot you inspect (screenshotNodeIds on your last framer_apply_changes provides it)';
2198
+ }
2199
+ if (check === 'interactions') {
2200
+ return 'framer_verify_interactions for every declared behavior and target node';
2201
+ }
2202
+ return `a read tagged with verifies:["${check}"]`;
2203
+ };
2204
+
2108
2205
  const verificationRepairPrompt = () => {
2109
2206
  const current = verification();
2110
2207
  if (current.completed || mutationCalls === 0) return null;
@@ -2173,7 +2270,6 @@ export function createFramerAgentToolRuntime({
2173
2270
 
2174
2271
  const reserveToolCall = (name, category) => {
2175
2272
  toolCallCount += 1;
2176
- if (category === 'progress') progressCalls += 1;
2177
2273
  if (category === 'read') readCalls += 1;
2178
2274
  const call = {
2179
2275
  sequence: toolCallCount,
@@ -2238,25 +2334,6 @@ export function createFramerAgentToolRuntime({
2238
2334
  'The same Framer tool call has failed five times in a row. Change the approach or report the concrete blocker.',
2239
2335
  );
2240
2336
  }
2241
- if (name === 'progress_update') {
2242
- const message = String(rawArguments.message || '').trim().slice(0, 500);
2243
- if (!message) {
2244
- throw toolError(
2245
- 'FRAMER_AGENT_PROGRESS_INVALID',
2246
- 'A progress message is required.',
2247
- );
2248
- }
2249
- const toolCall = reserveToolCall(name, 'progress');
2250
- reportActivity({
2251
- kind: 'status_update',
2252
- source: 'harness',
2253
- message,
2254
- });
2255
- repeatedFailureFingerprint = null;
2256
- repeatedFailureCount = 0;
2257
- toolCall.status = 'succeeded';
2258
- return { acknowledged: true };
2259
- }
2260
2337
 
2261
2338
  const startedAt = Date.now();
2262
2339
  const toolCall = reserveToolCall(name, category);
@@ -2275,6 +2352,7 @@ export function createFramerAgentToolRuntime({
2275
2352
  try {
2276
2353
  let result;
2277
2354
  let modelImages = [];
2355
+ let screenshotRequestCount = 0;
2278
2356
  if (name === 'framer_write' && lowLevelWriteDisabled) {
2279
2357
  lowLevelWriteBlockedCalls += 1;
2280
2358
  throw toolError(
@@ -2392,7 +2470,10 @@ export function createFramerAgentToolRuntime({
2392
2470
  ...mergeBehaviorPlans(taskPlan, nextPlan),
2393
2471
  };
2394
2472
  result = {
2395
- stdout: JSON.stringify({ plan: taskPlanSnapshot() }),
2473
+ stdout: JSON.stringify({
2474
+ plan: taskPlanSnapshot(),
2475
+ beforeFinishing: `After your final change, and before returning, produce: ${requiredChecks().map(evidenceRequirement).join('; ')}.`,
2476
+ }),
2396
2477
  stderr: '',
2397
2478
  };
2398
2479
  } else if (name === 'framer_verify_interactions') {
@@ -2422,7 +2503,15 @@ export function createFramerAgentToolRuntime({
2422
2503
  .map((entry) => [entry.id, entry.node]),
2423
2504
  );
2424
2505
  const results = verifyBehaviorChecks({ checks, nodes });
2425
- markInspection();
2506
+ // Live link values read here are link evidence too, so the model does
2507
+ // not spend another turn re-reading them under verifies:["links"].
2508
+ const linkChecks = results.filter((check) => /^link(\.|$)/.test(check.property));
2509
+ markInspection({
2510
+ verifies: linkChecks.length > 0
2511
+ && linkChecks.every((check) => check.verdict === 'verified')
2512
+ ? ['links']
2513
+ : [],
2514
+ });
2426
2515
  interactionVerificationCalls += 1;
2427
2516
  interactionVerifiedAt = new Date().toISOString();
2428
2517
  for (const checkResult of results) {
@@ -2451,28 +2540,15 @@ export function createFramerAgentToolRuntime({
2451
2540
  );
2452
2541
  }
2453
2542
  const pagePath = normalizePagePath(rawArguments.pagePath);
2454
- result = await execCode(
2455
- [
2456
- `const result = await framer.agent.readProject(${JSON.stringify(queries)}, { pagePath: ${JSON.stringify(pagePath)} });`,
2457
- 'console.log(JSON.stringify(result, null, 2));',
2458
- ].join('\n'),
2459
- );
2543
+ result = await readProjectWithFontFallback(queries, pagePath);
2460
2544
  const requestedScreenshots = screenshotQueries(rawArguments);
2461
- const delivery = requestedScreenshots.length
2545
+ screenshotRequestCount = requestedScreenshots.length;
2546
+ modelImages = requestedScreenshots.length
2462
2547
  ? await deliverScreenshots(
2463
2548
  requestedScreenshots,
2464
2549
  parseStructuredOutput(result.stdout),
2465
2550
  )
2466
- : { images: [], viaDownload: 0, viaSession: 0, undelivered: 0, failures: [] };
2467
- modelImages = delivery.images;
2468
- screenshotsViaDownload += delivery.viaDownload;
2469
- screenshotsViaSession += delivery.viaSession;
2470
- screenshotsUndelivered += delivery.undelivered;
2471
- screenshotFailures.push(...delivery.failures);
2472
- screenshotFailures.splice(
2473
- 0,
2474
- Math.max(0, screenshotFailures.length - MAX_SCREENSHOT_FAILURES_REPORTED),
2475
- );
2551
+ : [];
2476
2552
  markInspection({
2477
2553
  visual: modelImages.length > 0,
2478
2554
  visualAttempted: requestedScreenshots.length > 0,
@@ -2868,6 +2944,20 @@ export function createFramerAgentToolRuntime({
2868
2944
  ...summarizeFramerChanges(changes),
2869
2945
  renamedIds: canonicalIdMappings(parsedResult),
2870
2946
  });
2947
+ // A screenshot taken here saves the model a whole turn re-reading
2948
+ // its context just to look at the change.
2949
+ const screenshotIds = Array.isArray(rawArguments.screenshotNodeIds)
2950
+ ? uniqueStrings(rawArguments.screenshotNodeIds, MAX_SCREENSHOTS_PER_CALL)
2951
+ : [];
2952
+ if (screenshotIds.length > 0) {
2953
+ const queries = screenshotIds.map((id) => ({ type: 'screenshot', id }));
2954
+ modelImages = await readScreenshots(queries, pagePath);
2955
+ screenshotRequestCount = queries.length;
2956
+ markInspection({
2957
+ visual: modelImages.length > 0,
2958
+ visualAttempted: true,
2959
+ });
2960
+ }
2871
2961
  } else if (name === 'framer_read') {
2872
2962
  result = await execCode(
2873
2963
  validateCode(rawArguments.code, {
@@ -2937,17 +3027,22 @@ export function createFramerAgentToolRuntime({
2937
3027
  if (name === 'framer_read_website') {
2938
3028
  return modelImages.length ? { ...normalized, modelImages } : normalized;
2939
3029
  }
2940
- if (name !== 'framer_read_project') return normalized;
2941
- const screenshotCount = screenshotQueries(rawArguments).length;
3030
+ // Naming the evidence still owed after the latest change lets the model
3031
+ // finish it in this session instead of in a resumed repair turn.
3032
+ const evidenceStillDue =
3033
+ taskPlan && mutationCalls > 0 && (category === 'read' || screenshotRequestCount > 0)
3034
+ ? missingVerificationChecks()
3035
+ : [];
2942
3036
  return {
2943
3037
  ...normalized,
2944
3038
  ...(modelImages.length ? { modelImages } : {}),
2945
- ...(screenshotCount > modelImages.length
3039
+ ...(screenshotRequestCount > modelImages.length
2946
3040
  ? {
2947
3041
  screenshotWarning:
2948
- `${screenshotCount - modelImages.length} screenshot(s) could not be downloaded for you to view. Treat that visual state as unverified.`,
3042
+ `${screenshotRequestCount - modelImages.length} screenshot(s) could not be downloaded for you to view. Treat that visual state as unverified.`,
2949
3043
  }
2950
3044
  : {}),
3045
+ ...(evidenceStillDue.length > 0 ? { evidenceStillDue } : {}),
2951
3046
  };
2952
3047
  } catch (error) {
2953
3048
  if (
@@ -3125,7 +3220,6 @@ export function createFramerAgentToolRuntime({
3125
3220
  : {}),
3126
3221
  })),
3127
3222
  readCalls,
3128
- progressCalls,
3129
3223
  toolCallCount,
3130
3224
  toolDurationMs: toolCalls.reduce(
3131
3225
  (total, call) => total + call.durationMs,
@@ -3153,6 +3247,7 @@ export function createFramerAgentToolRuntime({
3153
3247
  lowLevelWriteBlockedCalls,
3154
3248
  instructionFallbacks,
3155
3249
  },
3250
+ fontSearchFallbacks,
3156
3251
  branch: branchChange,
3157
3252
  lastInspectionAt,
3158
3253
  lastMutationAt,
@@ -110,8 +110,22 @@ function hasContent(value) {
110
110
  }
111
111
 
112
112
  // Finds the property on the node itself. Child nodes are skipped so a
113
- // descendant cannot satisfy a claim about its parent.
113
+ // descendant cannot satisfy a claim about its parent. A dotted name such as
114
+ // `link.href` or `hoverEffect.opacity`, the way the change language writes
115
+ // it, reads the field inside the property's value.
114
116
  function propertyValues(node, property) {
117
+ const values = keyValues(node, property);
118
+ const [head, ...path] = property.split('.');
119
+ if (values.length > 0 || path.length === 0) return values;
120
+ return keyValues(node, head)
121
+ .map((value) => path.reduce(
122
+ (current, key) => (isRecord(current) ? current[key] : undefined),
123
+ value,
124
+ ))
125
+ .filter(hasContent);
126
+ }
127
+
128
+ function keyValues(node, property) {
115
129
  const values = [];
116
130
  const visit = (current, depth) => {
117
131
  if (depth > MAX_SEARCH_DEPTH || values.length >= 10) return;
@@ -0,0 +1,196 @@
1
+ // Framer's style-based font search ("font-search" with a `query`) is matched
2
+ // by Framer's AI and billed to the workspace's AI credit; when that fails, the
3
+ // run still needs typography candidates. Google Fonts publishes its full
4
+ // catalog (category, weights, italics, variable axes, popularity) without a
5
+ // key, and every Google Fonts family is in Framer's font library, so the
6
+ // bridge answers those searches from it in Framer's own result shape.
7
+
8
+ const GOOGLE_FONTS_METADATA_URL = 'https://fonts.google.com/metadata/fonts';
9
+ const CATALOG_TTL_MS = 24 * 60 * 60_000;
10
+ const CATALOG_TIMEOUT_MS = 20_000;
11
+ const MAX_RESULTS = 20;
12
+
13
+ const WEIGHT_DESCRIPTORS = {
14
+ thin: 100,
15
+ 'extra-light': 200,
16
+ light: 300,
17
+ regular: 400,
18
+ medium: 500,
19
+ semibold: 600,
20
+ bold: 700,
21
+ 'extra-bold': 800,
22
+ black: 900,
23
+ };
24
+
25
+ // Words in a style query that point at a category.
26
+ const CATEGORY_WORDS = {
27
+ serif: 'serif',
28
+ serifs: 'serif',
29
+ sans: 'sans-serif',
30
+ 'sans-serif': 'sans-serif',
31
+ grotesque: 'sans-serif',
32
+ grotesk: 'sans-serif',
33
+ geometric: 'sans-serif',
34
+ slab: 'slab',
35
+ mono: 'monospace',
36
+ monospace: 'monospace',
37
+ monospaced: 'monospace',
38
+ code: 'monospace',
39
+ script: 'handwriting',
40
+ handwriting: 'handwriting',
41
+ handwritten: 'handwriting',
42
+ calligraphy: 'handwriting',
43
+ display: 'display',
44
+ poster: 'display',
45
+ };
46
+ const NARROW_WORDS = new Set(['condensed', 'narrow', 'compressed']);
47
+
48
+ let cachedCatalog = null;
49
+ let cachedAt = 0;
50
+ let pendingCatalog = null;
51
+
52
+ export async function loadGoogleFontsCatalog(fetchImpl = fetch) {
53
+ if (cachedCatalog && Date.now() - cachedAt < CATALOG_TTL_MS) return cachedCatalog;
54
+ pendingCatalog ||= (async () => {
55
+ const response = await fetchImpl(GOOGLE_FONTS_METADATA_URL, {
56
+ signal: AbortSignal.timeout(CATALOG_TIMEOUT_MS),
57
+ });
58
+ if (!response.ok) throw new Error(`Google Fonts catalog returned HTTP ${response.status}.`);
59
+ const text = await response.text();
60
+ // The feed starts with an anti-JSON-hijacking prefix before the object.
61
+ const families = JSON.parse(text.slice(text.indexOf('{'))).familyMetadataList;
62
+ if (!Array.isArray(families) || families.length === 0) {
63
+ throw new Error('Google Fonts catalog is empty.');
64
+ }
65
+ cachedCatalog = families;
66
+ cachedAt = Date.now();
67
+ return families;
68
+ })().finally(() => {
69
+ pendingCatalog = null;
70
+ });
71
+ return pendingCatalog;
72
+ }
73
+
74
+ function categoryOf(family) {
75
+ if (family.category === 'Monospace') return 'monospace';
76
+ if (family.category === 'Handwriting') return 'handwriting';
77
+ if (family.category === 'Display') return 'display';
78
+ if (family.category === 'Serif') return 'serif';
79
+ return 'sans-serif';
80
+ }
81
+
82
+ function fontFacts(family) {
83
+ const keys = Object.keys(family.fonts || {});
84
+ const weights = [...new Set(keys.map((key) => Number.parseInt(key, 10)))]
85
+ .filter(Number.isFinite)
86
+ .sort((a, b) => a - b);
87
+ const styles = [
88
+ ...(keys.some((key) => !key.endsWith('i')) ? ['normal'] : []),
89
+ ...(keys.some((key) => key.endsWith('i')) ? ['italic'] : []),
90
+ ];
91
+ const axes = (family.axes || []).map((axis) => ({
92
+ tag: axis.tag,
93
+ minValue: axis.min,
94
+ maxValue: axis.max,
95
+ defaultValue: axis.defaultValue,
96
+ }));
97
+ return { weights, styles, axes };
98
+ }
99
+
100
+ function hasDescriptor(family, facts, descriptor) {
101
+ const classifications = family.classifications || [];
102
+ if (descriptor in WEIGHT_DESCRIPTORS) {
103
+ const weight = WEIGHT_DESCRIPTORS[descriptor];
104
+ const wght = facts.axes.find((axis) => axis.tag === 'wght');
105
+ return facts.weights.includes(weight)
106
+ || Boolean(wght && wght.minValue <= weight && wght.maxValue >= weight);
107
+ }
108
+ if (descriptor.startsWith('variation-axis/')) {
109
+ const tag = descriptor.slice('variation-axis/'.length);
110
+ return facts.axes.some((axis) => axis.tag === tag);
111
+ }
112
+ switch (descriptor) {
113
+ case 'serif':
114
+ return family.category === 'Serif' || family.stroke === 'Serif';
115
+ case 'sans-serif':
116
+ return family.category === 'Sans Serif' || family.stroke === 'Sans Serif';
117
+ case 'slab':
118
+ return family.stroke === 'Slab Serif';
119
+ case 'monospace':
120
+ return family.category === 'Monospace' || classifications.includes('Monospace');
121
+ case 'display':
122
+ return family.category === 'Display' || classifications.includes('Display');
123
+ case 'handwriting':
124
+ return family.category === 'Handwriting' || classifications.includes('Handwriting');
125
+ case 'normal':
126
+ case 'italic':
127
+ return facts.styles.includes(descriptor);
128
+ default:
129
+ // Descriptors the catalog cannot judge do not exclude a family.
130
+ return true;
131
+ }
132
+ }
133
+
134
+ function queryScore(family, facts, words) {
135
+ const nameWords = family.family.toLowerCase().split(/\s+/);
136
+ let score = 0;
137
+ for (const word of words) {
138
+ const category = CATEGORY_WORDS[word];
139
+ if (category && hasDescriptor(family, facts, category)) score += 2;
140
+ if (NARROW_WORDS.has(word)) {
141
+ const width = facts.axes.find((axis) => axis.tag === 'wdth');
142
+ if (nameWords.some((part) => NARROW_WORDS.has(part)) || (width && width.minValue < 100)) {
143
+ score += 2;
144
+ }
145
+ }
146
+ if (
147
+ !category
148
+ && word.length >= 4
149
+ && nameWords.some((part) => part.slice(0, 5) === word.slice(0, 5))
150
+ ) {
151
+ score += 3;
152
+ }
153
+ }
154
+ return score;
155
+ }
156
+
157
+ // Ranks catalog families for one font-search query: every `mustHave`
158
+ // descriptor is required, query words raise matching families, and Google
159
+ // Fonts popularity breaks ties. More candidates than `limit` are returned
160
+ // because keyword matching is coarse; the model chooses by how they look.
161
+ export function searchGoogleFonts(catalog, query = {}) {
162
+ const mustHave = (Array.isArray(query.mustHave) ? query.mustHave : [])
163
+ .map((descriptor) => String(descriptor).trim().toLowerCase())
164
+ .filter(Boolean);
165
+ const words = String(query.query || '')
166
+ .toLowerCase()
167
+ .split(/[^a-z0-9-]+/)
168
+ .filter(Boolean);
169
+ const limit = Math.min(
170
+ MAX_RESULTS,
171
+ Math.max(10, (Number.parseInt(query.limit, 10) || 5) * 2),
172
+ );
173
+ return catalog
174
+ .filter((family) =>
175
+ typeof family?.family === 'string'
176
+ && !family.isNoto
177
+ && !family.isBrandFont
178
+ && (family.subsets || []).includes('latin'))
179
+ .map((family) => ({ family, facts: fontFacts(family) }))
180
+ .filter(({ family, facts }) =>
181
+ facts.weights.length > 0
182
+ && mustHave.every((descriptor) => hasDescriptor(family, facts, descriptor)))
183
+ .map((entry) => ({ ...entry, score: queryScore(entry.family, entry.facts, words) }))
184
+ .sort((a, b) =>
185
+ b.score - a.score
186
+ || (a.family.popularity ?? Infinity) - (b.family.popularity ?? Infinity))
187
+ .slice(0, limit)
188
+ .map(({ family, facts }) => ({
189
+ name: family.family,
190
+ weights: facts.weights,
191
+ styles: facts.styles,
192
+ category: categoryOf(family),
193
+ variationAxes: facts.axes,
194
+ reason: 'Google Fonts catalog match',
195
+ }));
196
+ }