@webmcp-auto-ui/agent 2.5.31 → 2.5.33
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/package.json +1 -1
- package/src/autoui-server.ts +37 -90
- package/src/loop.ts +12 -6
- package/src/prompts/index.ts +6 -1
- package/src/recipes/index.ts +76 -4
- package/src/recipes/parser.ts +0 -182
package/package.json
CHANGED
package/src/autoui-server.ts
CHANGED
|
@@ -12,7 +12,6 @@ import notebookRecipe from '@webmcp-auto-ui/ui/widgets/notebook/recipes/notebook
|
|
|
12
12
|
// Notebook widget renderer (vanilla JS) — import via subpath to avoid pulling
|
|
13
13
|
// the .svelte exports of the ui package root through tsc.
|
|
14
14
|
import { render as renderNotebook } from '@webmcp-auto-ui/ui/widgets/notebook/notebook.js';
|
|
15
|
-
import { render as renderRecipeBrowser } from '@webmcp-auto-ui/ui/widgets/notebook/recipe-browser.js';
|
|
16
15
|
|
|
17
16
|
// Inline recipe for recipe-browser (real vanilla widget)
|
|
18
17
|
const recipeBrowserRecipe = `---
|
|
@@ -143,7 +142,7 @@ Call widget_display({name: "list", params: {items: ["A", "B", "C"]}}).
|
|
|
143
142
|
// ── chart ────────────────────────────────────────────────────────────────
|
|
144
143
|
`---
|
|
145
144
|
widget: chart
|
|
146
|
-
description: Simple bar chart.
|
|
145
|
+
description: Simple bar chart. bars is an array of tuples [label: string, value: number]. Each entry MUST be an array of length exactly 2, NOT an object.
|
|
147
146
|
schema:
|
|
148
147
|
type: object
|
|
149
148
|
required:
|
|
@@ -153,8 +152,11 @@ schema:
|
|
|
153
152
|
type: string
|
|
154
153
|
bars:
|
|
155
154
|
type: array
|
|
155
|
+
description: Array of tuples [label, value]. Each item is an array [string, number] of length exactly 2. Do NOT use objects like {label, value}.
|
|
156
156
|
items:
|
|
157
157
|
type: array
|
|
158
|
+
minItems: 2
|
|
159
|
+
maxItems: 2
|
|
158
160
|
---
|
|
159
161
|
|
|
160
162
|
## When to use
|
|
@@ -162,6 +164,7 @@ Pour un graphique a barres simple avec des labels et valeurs numeriques.
|
|
|
162
164
|
|
|
163
165
|
## How to use
|
|
164
166
|
Call widget_display({name: "chart", params: {bars: [["Jan", 10], ["Fev", 20]]}}).
|
|
167
|
+
Each bar is a tuple array of exactly 2 elements: [label: string, value: number]. NEVER use objects.
|
|
165
168
|
`,
|
|
166
169
|
|
|
167
170
|
// ── alert ────────────────────────────────────────────────────────────────
|
|
@@ -765,57 +768,6 @@ Call widget_display({name: "carousel", params: {slides: [{src: "https://...", ti
|
|
|
765
768
|
|
|
766
769
|
## Common mistakes
|
|
767
770
|
- NEVER fabricate image URLs for src — only use those returned by MCP tools
|
|
768
|
-
`,
|
|
769
|
-
|
|
770
|
-
// ── map ──────────────────────────────────────────────────────────────────
|
|
771
|
-
`---
|
|
772
|
-
widget: map
|
|
773
|
-
description: Interactive Leaflet map with markers. Dark CARTO basemap.
|
|
774
|
-
schema:
|
|
775
|
-
type: object
|
|
776
|
-
properties:
|
|
777
|
-
title:
|
|
778
|
-
type: string
|
|
779
|
-
center:
|
|
780
|
-
type: object
|
|
781
|
-
description: Centre de la carte
|
|
782
|
-
required:
|
|
783
|
-
- lat
|
|
784
|
-
- lng
|
|
785
|
-
properties:
|
|
786
|
-
lat:
|
|
787
|
-
type: number
|
|
788
|
-
lng:
|
|
789
|
-
type: number
|
|
790
|
-
zoom:
|
|
791
|
-
type: number
|
|
792
|
-
description: Niveau de zoom (1-18)
|
|
793
|
-
height:
|
|
794
|
-
type: string
|
|
795
|
-
description: Hauteur CSS de la carte (ex "400px")
|
|
796
|
-
markers:
|
|
797
|
-
type: array
|
|
798
|
-
items:
|
|
799
|
-
type: object
|
|
800
|
-
required:
|
|
801
|
-
- lat
|
|
802
|
-
- lng
|
|
803
|
-
properties:
|
|
804
|
-
lat:
|
|
805
|
-
type: number
|
|
806
|
-
lng:
|
|
807
|
-
type: number
|
|
808
|
-
label:
|
|
809
|
-
type: string
|
|
810
|
-
color:
|
|
811
|
-
type: string
|
|
812
|
-
---
|
|
813
|
-
|
|
814
|
-
## When to use
|
|
815
|
-
Display a geographic map with markers.
|
|
816
|
-
|
|
817
|
-
## How to use
|
|
818
|
-
Call widget_display({name: "map", params: {center: {lat: 48.8, lng: 2.3}, zoom: 12, markers: [{lat: 48.8, lng: 2.3, label: "Paris"}]}}).
|
|
819
771
|
`,
|
|
820
772
|
|
|
821
773
|
// ── stat-card ────────────────────────────────────────────────────────────
|
|
@@ -882,9 +834,10 @@ schema:
|
|
|
882
834
|
type: string
|
|
883
835
|
rows:
|
|
884
836
|
type: array
|
|
885
|
-
description:
|
|
837
|
+
description: Array of rows, row-major. Each row is an array of primitive values (string/number/boolean) with length equal to the number of columns. Do NOT use objects as rows.
|
|
886
838
|
items:
|
|
887
839
|
type: array
|
|
840
|
+
minItems: 1
|
|
888
841
|
highlights:
|
|
889
842
|
type: array
|
|
890
843
|
description: Cellules a coloriser
|
|
@@ -907,34 +860,6 @@ Pour des grilles de donnees avec mise en valeur de cellules (heatmap, comparaiso
|
|
|
907
860
|
|
|
908
861
|
## How to use
|
|
909
862
|
Call widget_display({name: "grid-data", params: {columns: [{key:"a",label:"A"}], rows: [[1,2],[3,4]], highlights: [{row:0,col:1,color:"#ff0"}]}}).
|
|
910
|
-
`,
|
|
911
|
-
|
|
912
|
-
// ── d3 ───────────────────────────────────────────────────────────────────
|
|
913
|
-
`---
|
|
914
|
-
widget: d3
|
|
915
|
-
description: D3.js visualization (hex-heatmap, radial, treemap, force graph).
|
|
916
|
-
schema:
|
|
917
|
-
type: object
|
|
918
|
-
required:
|
|
919
|
-
- preset
|
|
920
|
-
- data
|
|
921
|
-
properties:
|
|
922
|
-
title:
|
|
923
|
-
type: string
|
|
924
|
-
preset:
|
|
925
|
-
type: string
|
|
926
|
-
enum: [hex-heatmap, radial, treemap, force]
|
|
927
|
-
data:
|
|
928
|
-
type: object
|
|
929
|
-
config:
|
|
930
|
-
type: object
|
|
931
|
-
---
|
|
932
|
-
|
|
933
|
-
## When to use
|
|
934
|
-
Pour des visualisations avancees D3.js (heatmap hexagonale, radial, treemap, graphe de force).
|
|
935
|
-
|
|
936
|
-
## How to use
|
|
937
|
-
Call widget_display({name: "d3", params: {preset: "treemap", data: {name: "root", children: [...]}}}).
|
|
938
863
|
`,
|
|
939
864
|
|
|
940
865
|
// ── js-sandbox ───────────────────────────────────────────────────────────
|
|
@@ -970,6 +895,31 @@ Pour des visualisations custom, animations, ou prototypes interactifs en JS pur.
|
|
|
970
895
|
Call widget_display({name: "js-sandbox", params: {code: "document.getElementById('root').innerHTML = '<h1>Hello</h1>'"}}).
|
|
971
896
|
`,
|
|
972
897
|
|
|
898
|
+
`---
|
|
899
|
+
widget: chat-input
|
|
900
|
+
description: Minimal inline chat bar with a text input and stop button. Use when the agent needs to solicit a short free-form user reply inside the current canvas (e.g. "explain this result").
|
|
901
|
+
group: rich
|
|
902
|
+
schema:
|
|
903
|
+
type: object
|
|
904
|
+
properties:
|
|
905
|
+
placeholder:
|
|
906
|
+
type: string
|
|
907
|
+
description: Placeholder text shown in the input (default "Your reply...").
|
|
908
|
+
value:
|
|
909
|
+
type: string
|
|
910
|
+
description: Optional initial value.
|
|
911
|
+
disabled:
|
|
912
|
+
type: boolean
|
|
913
|
+
description: Disable the input (while the agent is processing).
|
|
914
|
+
---
|
|
915
|
+
|
|
916
|
+
## When to use
|
|
917
|
+
When you need the user to reply with a short text — clarifying questions, follow-ups, free-form input mid-canvas. Prefer this over a modal for lightweight conversation inside a widget layout.
|
|
918
|
+
|
|
919
|
+
## How to use
|
|
920
|
+
Call widget_display({name: "chat-input", params: {placeholder: "Your reply..."}}). The widget emits a bubbling 'widget:interact' CustomEvent with detail={action: "submit", payload: {text}} when the user submits, and detail={action: "stop"} when the stop button is pressed.
|
|
921
|
+
`,
|
|
922
|
+
|
|
973
923
|
];
|
|
974
924
|
|
|
975
925
|
// ---------------------------------------------------------------------------
|
|
@@ -999,14 +949,11 @@ for (const recipe of RECIPES) {
|
|
|
999
949
|
autoui.registerWidget(recipe, undefined);
|
|
1000
950
|
}
|
|
1001
951
|
|
|
1002
|
-
// Notebook widget — vanilla renderer (
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
for (const [recipe, renderer] of NOTEBOOK_WIDGETS) {
|
|
1008
|
-
autoui.registerWidget(recipe, renderer as any);
|
|
1009
|
-
}
|
|
952
|
+
// Notebook widget — vanilla renderer (wrapped by <auto-notebook> custom element)
|
|
953
|
+
autoui.registerWidget(notebookRecipe as string, renderNotebook as any);
|
|
954
|
+
|
|
955
|
+
// Recipe browser — resolved by WidgetRenderer as <auto-recipe-browser> custom element
|
|
956
|
+
autoui.registerWidget(recipeBrowserRecipe, undefined);
|
|
1010
957
|
|
|
1011
958
|
// Register flow recipes (multi-step procedures) from the global recipe registry
|
|
1012
959
|
// that declare this server (autoui) in their frontmatter.
|
package/src/loop.ts
CHANGED
|
@@ -10,7 +10,7 @@ import type {
|
|
|
10
10
|
} from './types.js';
|
|
11
11
|
import type { ToolLayer, SchemaTransformOptions } from './tool-layers.js';
|
|
12
12
|
import { buildToolsFromLayers, buildDiscoveryToolsWithAliases, activateServerTools, toProviderTools, sanitizeServerName } from './tool-layers.js';
|
|
13
|
-
import { buildSystemPromptWithAliases
|
|
13
|
+
import { buildSystemPromptWithAliases } from './prompts/index.js';
|
|
14
14
|
import type { DiscoveryCache } from './discovery-cache.js';
|
|
15
15
|
import { unflattenParams, validateJsonSchema } from '@webmcp-auto-ui/core';
|
|
16
16
|
import type { JsonSchema } from '@webmcp-auto-ui/core';
|
|
@@ -395,13 +395,19 @@ export async function runAgentLoop(
|
|
|
395
395
|
const protocol = tokenToProtocol(token);
|
|
396
396
|
const serverKey = `${serverName}_${token}`;
|
|
397
397
|
if (!activatedServers.has(serverKey)) {
|
|
398
|
-
activatedServers.add(serverKey);
|
|
399
398
|
const layer = (options.layers ?? []).find(l => sanitizeServerName(l.serverName) === serverName && l.protocol === protocol);
|
|
400
399
|
if (layer) {
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
400
|
+
try {
|
|
401
|
+
const act = activateServerTools(activeTools, layer, schemaOptions, trace);
|
|
402
|
+
activeTools = act.tools;
|
|
403
|
+
// Merge new pathMaps so unflattenParams works for lazily-activated tools.
|
|
404
|
+
for (const [k, v] of act.pathMaps) localPathMaps.set(k, v);
|
|
405
|
+
activatedServers.add(serverKey);
|
|
406
|
+
} catch (e) {
|
|
407
|
+
trace.push('activate', name, `activation failed for ${serverKey}: ${e instanceof Error ? e.message : String(e)}`, 'error');
|
|
408
|
+
}
|
|
409
|
+
} else {
|
|
410
|
+
trace.push('activate', name, `layer not found for server="${serverName}" protocol="${protocol}"`, 'warn');
|
|
405
411
|
}
|
|
406
412
|
}
|
|
407
413
|
}
|
package/src/prompts/index.ts
CHANGED
|
@@ -43,7 +43,12 @@ export function buildSystemPromptWithAliases(
|
|
|
43
43
|
return { prompt, aliasMap: refs.aliasMap };
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
-
/**
|
|
46
|
+
/**
|
|
47
|
+
* @deprecated Use `buildSystemPromptWithAliases()` instead and pass the returned
|
|
48
|
+
* `aliasMap` to the agent loop explicitly. This wrapper still populates the
|
|
49
|
+
* deprecated global `toolAliasMap` singleton as a side-effect, which is not
|
|
50
|
+
* parallel-safe across concurrent agent loops.
|
|
51
|
+
*/
|
|
47
52
|
export function buildSystemPrompt(
|
|
48
53
|
layers: ToolLayer[],
|
|
49
54
|
options?: { providerKind?: ProviderKind },
|
package/src/recipes/index.ts
CHANGED
|
@@ -1,12 +1,84 @@
|
|
|
1
1
|
// Recipe loader — imports auto-generated .md strings, parses them, exports ready-to-use recipes
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
import { parseFrontmatter } from '@webmcp-auto-ui/core';
|
|
4
|
+
import type { Recipe } from './types.js';
|
|
6
5
|
import { RAW_RECIPES } from './_generated.js';
|
|
7
|
-
import { parseRecipes } from './parser.js';
|
|
8
6
|
import { registerRecipes } from '../recipe-registry.js';
|
|
9
7
|
|
|
8
|
+
export type { Recipe, McpRecipe } from './types.js';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Parse a single recipe from its raw markdown string.
|
|
12
|
+
*
|
|
13
|
+
* Supports two formats:
|
|
14
|
+
* - **Structured**: YAML frontmatter between `---` delimiters + markdown body
|
|
15
|
+
* - **Freeform**: plain markdown without frontmatter (id derived from fileKey)
|
|
16
|
+
*
|
|
17
|
+
* @param raw - The raw markdown string
|
|
18
|
+
* @param fileKey - Optional file key (e.g. "gallery-images") used as fallback id for freeform recipes
|
|
19
|
+
*/
|
|
20
|
+
export function parseRecipe(raw: string, fileKey?: string): Recipe {
|
|
21
|
+
const { frontmatter, body } = parseFrontmatter(raw);
|
|
22
|
+
|
|
23
|
+
// No frontmatter found → freeform recipe
|
|
24
|
+
if (Object.keys(frontmatter).length === 0) {
|
|
25
|
+
return parseRecipeFreeform(raw, fileKey);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return {
|
|
29
|
+
id: (frontmatter.id as string) ?? fileKey ?? '',
|
|
30
|
+
name: (frontmatter.name as string) ?? (frontmatter.id as string) ?? fileKey ?? '',
|
|
31
|
+
description: frontmatter.description as string | undefined,
|
|
32
|
+
components_used: parseStringArray(frontmatter.components_used),
|
|
33
|
+
layout: frontmatter.layout as Recipe['layout'] | undefined,
|
|
34
|
+
interactions: parseInteractions(frontmatter.interactions),
|
|
35
|
+
when: (frontmatter.when as string) ?? '',
|
|
36
|
+
servers: parseStringArray(frontmatter.servers),
|
|
37
|
+
body: body.trim(),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Parse a freeform .md recipe (no frontmatter). Extracts id from fileKey, name from first heading. */
|
|
42
|
+
function parseRecipeFreeform(raw: string, fileKey?: string): Recipe {
|
|
43
|
+
const body = raw.trim();
|
|
44
|
+
const headingMatch = body.match(/^#+ +(.+)$/m);
|
|
45
|
+
const name = headingMatch?.[1] ?? fileKey ?? 'untitled';
|
|
46
|
+
const id = fileKey ?? name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
|
|
47
|
+
|
|
48
|
+
const whenMatch = body.match(/##\s*Quand[^\n]*\n([\s\S]*?)(?=\n##|\n$|$)/i);
|
|
49
|
+
const when = whenMatch?.[1]?.trim().split('\n')[0] ?? '';
|
|
50
|
+
|
|
51
|
+
return { id, name, when, body };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Parse all raw recipe strings into Recipe objects. Skips invalid ones with a warning. */
|
|
55
|
+
export function parseRecipes(raws: Record<string, string>): Recipe[] {
|
|
56
|
+
const recipes: Recipe[] = [];
|
|
57
|
+
for (const [key, raw] of Object.entries(raws)) {
|
|
58
|
+
try {
|
|
59
|
+
recipes.push(parseRecipe(raw, key));
|
|
60
|
+
} catch (e) {
|
|
61
|
+
console.warn(`[recipes] Failed to parse "${key}":`, e);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return recipes;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function parseStringArray(val: unknown): string[] | undefined {
|
|
68
|
+
if (!val) return undefined;
|
|
69
|
+
if (Array.isArray(val)) return val.map(String);
|
|
70
|
+
if (typeof val === 'string') return val.split(',').map(s => s.trim()).filter(Boolean);
|
|
71
|
+
return undefined;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function parseInteractions(val: unknown): Recipe['interactions'] | undefined {
|
|
75
|
+
if (!Array.isArray(val)) return undefined;
|
|
76
|
+
return val.filter(
|
|
77
|
+
(v): v is { source: string; target: string; event: string; action: string } =>
|
|
78
|
+
typeof v === 'object' && v !== null && 'source' in v && 'target' in v,
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
10
82
|
/** All built-in WebMCP UI recipes, parsed and ready to use */
|
|
11
83
|
export const WEBMCP_RECIPES = parseRecipes(RAW_RECIPES);
|
|
12
84
|
|
package/src/recipes/parser.ts
DELETED
|
@@ -1,182 +0,0 @@
|
|
|
1
|
-
// Frontmatter parser for recipe .md files
|
|
2
|
-
// Parses YAML-like frontmatter + markdown body into a Recipe object
|
|
3
|
-
|
|
4
|
-
import type { Recipe } from './types.js';
|
|
5
|
-
|
|
6
|
-
/**
|
|
7
|
-
* Parse a single recipe from its raw markdown string.
|
|
8
|
-
*
|
|
9
|
-
* Supports two formats:
|
|
10
|
-
* - **Structured**: YAML-like frontmatter between `---` delimiters + markdown body
|
|
11
|
-
* - **Freeform**: plain markdown without frontmatter (id derived from fileKey)
|
|
12
|
-
*
|
|
13
|
-
* @param raw - The raw markdown string
|
|
14
|
-
* @param fileKey - Optional file key (e.g. "gallery-images") used as fallback id for freeform recipes
|
|
15
|
-
*/
|
|
16
|
-
export function parseRecipe(raw: string, fileKey?: string): Recipe {
|
|
17
|
-
const match = raw.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
|
|
18
|
-
|
|
19
|
-
if (!match) {
|
|
20
|
-
// Freeform recipe: no frontmatter — derive metadata from content
|
|
21
|
-
return parseRecipeFreeform(raw, fileKey);
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
const frontmatter = parseFrontmatter(match[1]);
|
|
25
|
-
const body = match[2].trim();
|
|
26
|
-
|
|
27
|
-
return {
|
|
28
|
-
id: frontmatter.id as string ?? fileKey ?? '',
|
|
29
|
-
name: frontmatter.name as string ?? frontmatter.id as string ?? fileKey ?? '',
|
|
30
|
-
description: frontmatter.description as string | undefined,
|
|
31
|
-
components_used: parseStringArray(frontmatter.components_used),
|
|
32
|
-
layout: frontmatter.layout as Recipe['layout'] | undefined,
|
|
33
|
-
interactions: parseInteractions(frontmatter.interactions),
|
|
34
|
-
when: frontmatter.when as string ?? '',
|
|
35
|
-
servers: parseStringArray(frontmatter.servers),
|
|
36
|
-
body,
|
|
37
|
-
};
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
/** Parse a freeform .md recipe (no frontmatter). Extracts id from fileKey, name from first heading. */
|
|
41
|
-
function parseRecipeFreeform(raw: string, fileKey?: string): Recipe {
|
|
42
|
-
const body = raw.trim();
|
|
43
|
-
// Try to extract name from first markdown heading
|
|
44
|
-
const headingMatch = body.match(/^#+ +(.+)$/m);
|
|
45
|
-
const name = headingMatch?.[1] ?? fileKey ?? 'untitled';
|
|
46
|
-
const id = fileKey ?? name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
|
|
47
|
-
|
|
48
|
-
// Try to extract a "when" hint from the first paragraph or a "## Quand" section
|
|
49
|
-
const whenMatch = body.match(/##\s*Quand[^\n]*\n([\s\S]*?)(?=\n##|\n$|$)/i);
|
|
50
|
-
const when = whenMatch?.[1]?.trim().split('\n')[0] ?? '';
|
|
51
|
-
|
|
52
|
-
return { id, name, when, body };
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
/** Parse all raw recipe strings into Recipe objects. Skips invalid ones with a warning. */
|
|
56
|
-
export function parseRecipes(raws: Record<string, string>): Recipe[] {
|
|
57
|
-
const recipes: Recipe[] = [];
|
|
58
|
-
for (const [key, raw] of Object.entries(raws)) {
|
|
59
|
-
try {
|
|
60
|
-
recipes.push(parseRecipe(raw, key));
|
|
61
|
-
} catch (e) {
|
|
62
|
-
console.warn(`[recipes] Failed to parse "${key}":`, e);
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
return recipes;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
// ── Internal helpers ──────────────────────────────────────────────────────────
|
|
69
|
-
|
|
70
|
-
function parseFrontmatter(raw: string): Record<string, unknown> {
|
|
71
|
-
const result: Record<string, unknown> = {};
|
|
72
|
-
let currentKey = '';
|
|
73
|
-
let currentArray: unknown[] | null = null;
|
|
74
|
-
let currentObj: Record<string, unknown> | null = null;
|
|
75
|
-
let inObjectArray = false;
|
|
76
|
-
|
|
77
|
-
for (const line of raw.split('\n')) {
|
|
78
|
-
// Array item: " - value" or " - key: value" (under a key)
|
|
79
|
-
if (/^\s+-\s/.test(line) && currentKey) {
|
|
80
|
-
const itemRaw = line.replace(/^\s+-\s*/, '').trim();
|
|
81
|
-
if (inObjectArray && currentArray) {
|
|
82
|
-
// Object item in array: " - source: gallery"
|
|
83
|
-
// Collect key: value pairs into a single object until next " -" or new top-level key
|
|
84
|
-
const obj = parseInlineObject(itemRaw);
|
|
85
|
-
if (obj) {
|
|
86
|
-
currentArray.push(obj);
|
|
87
|
-
} else {
|
|
88
|
-
// Simple string in array
|
|
89
|
-
if (!currentArray) currentArray = [];
|
|
90
|
-
currentArray.push(itemRaw);
|
|
91
|
-
}
|
|
92
|
-
} else {
|
|
93
|
-
if (!currentArray) currentArray = [];
|
|
94
|
-
// Check if it looks like "key: value" (object item)
|
|
95
|
-
if (itemRaw.includes(': ')) {
|
|
96
|
-
inObjectArray = true;
|
|
97
|
-
currentArray.push(parseInlineObject(itemRaw) ?? itemRaw);
|
|
98
|
-
} else {
|
|
99
|
-
currentArray.push(itemRaw);
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
continue;
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
// Nested key: " key: value" (under a parent key with object value)
|
|
106
|
-
if (/^\s+\w/.test(line) && currentKey && !currentArray && currentObj !== null) {
|
|
107
|
-
const m = line.match(/^\s+(\w+):\s*(.*)$/);
|
|
108
|
-
if (m) {
|
|
109
|
-
const val = m[2].trim();
|
|
110
|
-
currentObj[m[1]] = isNumeric(val) ? Number(val) : val;
|
|
111
|
-
continue;
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
// Flush pending array/object
|
|
116
|
-
if (currentKey && (currentArray || currentObj)) {
|
|
117
|
-
result[currentKey] = currentArray ?? currentObj;
|
|
118
|
-
currentArray = null;
|
|
119
|
-
currentObj = null;
|
|
120
|
-
inObjectArray = false;
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
// Top-level key: "key: value" or "key:"
|
|
124
|
-
const topMatch = line.match(/^(\w[\w_]*)\s*:\s*(.*)$/);
|
|
125
|
-
if (topMatch) {
|
|
126
|
-
currentKey = topMatch[1];
|
|
127
|
-
const val = topMatch[2].trim();
|
|
128
|
-
|
|
129
|
-
if (val === '' || val === '|') {
|
|
130
|
-
// Next lines are nested (object or array)
|
|
131
|
-
currentObj = {};
|
|
132
|
-
continue;
|
|
133
|
-
}
|
|
134
|
-
if (val.startsWith('[') && val.endsWith(']')) {
|
|
135
|
-
// Inline array: [gallery, carousel]
|
|
136
|
-
result[currentKey] = val.slice(1, -1).split(',').map(s => s.trim()).filter(Boolean);
|
|
137
|
-
currentKey = '';
|
|
138
|
-
continue;
|
|
139
|
-
}
|
|
140
|
-
// Simple scalar
|
|
141
|
-
result[currentKey] = isNumeric(val) ? Number(val) : val;
|
|
142
|
-
currentKey = '';
|
|
143
|
-
}
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
// Flush last pending
|
|
147
|
-
if (currentKey && (currentArray || currentObj)) {
|
|
148
|
-
result[currentKey] = currentArray ?? currentObj;
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
return result;
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
function parseStringArray(val: unknown): string[] | undefined {
|
|
155
|
-
if (!val) return undefined;
|
|
156
|
-
if (Array.isArray(val)) return val.map(String);
|
|
157
|
-
if (typeof val === 'string') return val.split(',').map(s => s.trim()).filter(Boolean);
|
|
158
|
-
return undefined;
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
function parseInteractions(val: unknown): Recipe['interactions'] | undefined {
|
|
162
|
-
if (!Array.isArray(val)) return undefined;
|
|
163
|
-
return val.filter(
|
|
164
|
-
(v): v is { source: string; target: string; event: string; action: string } =>
|
|
165
|
-
typeof v === 'object' && v !== null && 'source' in v && 'target' in v
|
|
166
|
-
);
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
function parseInlineObject(raw: string): Record<string, unknown> | null {
|
|
170
|
-
if (!raw.includes(': ')) return null;
|
|
171
|
-
const obj: Record<string, unknown> = {};
|
|
172
|
-
// Split on ", " but not inside values — simple heuristic
|
|
173
|
-
for (const part of raw.split(/,\s+/)) {
|
|
174
|
-
const m = part.match(/^(\w+)\s*:\s*(.+)$/);
|
|
175
|
-
if (m) obj[m[1]] = isNumeric(m[2].trim()) ? Number(m[2].trim()) : m[2].trim();
|
|
176
|
-
}
|
|
177
|
-
return Object.keys(obj).length > 0 ? obj : null;
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
function isNumeric(val: string): boolean {
|
|
181
|
-
return val !== '' && !isNaN(Number(val));
|
|
182
|
-
}
|