@norskvideo/ctl-sdk 0.1.0 → 0.1.2
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/components/ProductTemplateBuildForm.js +15 -9
- package/frontend-spec.d.ts +17 -0
- package/frontend-spec.js +53 -0
- package/manifest-schema.d.ts +12 -5
- package/manifest-schema.js +14 -5
- package/package.json +5 -1
- package/product-template-error.d.ts +2 -2
- package/workflow.d.ts +40 -0
- package/workflow.js +70 -0
|
@@ -31,16 +31,22 @@ export function ProductTemplateBuildForm({ productName, configScreenUrl, onClose
|
|
|
31
31
|
// caller can hand it off to its launch flow without waiting for a refetch),
|
|
32
32
|
// null on failure.
|
|
33
33
|
const saveProductTemplate = async () => {
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
34
|
+
// No config screen declared: there is nothing to collect, so the build
|
|
35
|
+
// request carries empty form values rather than requiring an iframe.
|
|
36
|
+
let formValues = {};
|
|
37
|
+
if (configScreenUrl) {
|
|
38
|
+
if (!iframeRef.current) {
|
|
39
|
+
toast.error("Configuration not ready");
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
const result = await iframeRef.current.submit();
|
|
43
|
+
if (!result.ok) {
|
|
44
|
+
toast.error(result.error ?? "Configuration submission failed");
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
formValues = result.formValues;
|
|
42
48
|
}
|
|
43
|
-
const outcome = await onSubmit({ productName, productTemplateName, formValues
|
|
49
|
+
const outcome = await onSubmit({ productName, productTemplateName, formValues });
|
|
44
50
|
if (!outcome.ok) {
|
|
45
51
|
toast.error(`Save product template failed: ${outcome.error}`);
|
|
46
52
|
return null;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ways a frontend's `index.css` drifts from deferring to the shared base. Empty
|
|
3
|
+
* means compliant. Each string is human-readable for a test failure message.
|
|
4
|
+
*/
|
|
5
|
+
export declare function stylingViolations(indexCss: string): string[];
|
|
6
|
+
/** The sdk's vendored fonts directory, located relative to this module so it
|
|
7
|
+
* resolves whether the sdk is a workspace sibling or an installed dependency —
|
|
8
|
+
* the product no longer has to guess a `packages/sdk` monorepo path. */
|
|
9
|
+
export declare function sharedFontsDir(): string;
|
|
10
|
+
/**
|
|
11
|
+
* Whether a Vite dev server's `server.fs.allow` reaches the shared fonts. base.css
|
|
12
|
+
* lives outside a product's own Vite root, so without an allow entry covering it
|
|
13
|
+
* the dev server 403s the sibling font and the browser silently falls back to a
|
|
14
|
+
* system font (the guide-screenshot mismatch). True iff some allow base is an
|
|
15
|
+
* ancestor of the fonts dir. Allow bases that don't resolve are ignored.
|
|
16
|
+
*/
|
|
17
|
+
export declare function fsAllowCoversFonts(allow: readonly string[]): boolean;
|
package/frontend-spec.js
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// The shared styling-deference contract for product frontends: an app states no
|
|
2
|
+
// styling opinion of its own beyond embed-specific rules — font, theme tokens and
|
|
3
|
+
// resets all come from @norskvideo/ctl-sdk/base.css, whose @font-face url()s
|
|
4
|
+
// resolve to the fonts vendored here. Three products carried a byte-identical
|
|
5
|
+
// theme.test.ts asserting this; the judgment (what counts as drift, where the
|
|
6
|
+
// shared fonts live) is centralised here so a fourth product can't fork it. The
|
|
7
|
+
// vitest wrapper stays in each product's own theme.test.ts — it reads that
|
|
8
|
+
// product's index.css and resolves that product's vite config — but delegates
|
|
9
|
+
// every assertion to these pure helpers.
|
|
10
|
+
import { realpathSync } from "node:fs";
|
|
11
|
+
import { dirname, join } from "node:path";
|
|
12
|
+
import { fileURLToPath } from "node:url";
|
|
13
|
+
const BASE_IMPORT = /@import\s+["']@norskvideo\/ctl-sdk\/base\.css["']/;
|
|
14
|
+
/**
|
|
15
|
+
* Ways a frontend's `index.css` drifts from deferring to the shared base. Empty
|
|
16
|
+
* means compliant. Each string is human-readable for a test failure message.
|
|
17
|
+
*/
|
|
18
|
+
export function stylingViolations(indexCss) {
|
|
19
|
+
const out = [];
|
|
20
|
+
if (!BASE_IMPORT.test(indexCss))
|
|
21
|
+
out.push("does not @import @norskvideo/ctl-sdk/base.css");
|
|
22
|
+
if (/@font-face/.test(indexCss))
|
|
23
|
+
out.push("declares a local @font-face (fonts belong to the shared base)");
|
|
24
|
+
if (/--font-sans/.test(indexCss))
|
|
25
|
+
out.push("declares a local --font-sans (font tokens belong to the shared base)");
|
|
26
|
+
if (/fonts\.(googleapis|gstatic)\.com/.test(indexCss))
|
|
27
|
+
out.push("pulls a font from a CDN instead of the shared base");
|
|
28
|
+
return out;
|
|
29
|
+
}
|
|
30
|
+
/** The sdk's vendored fonts directory, located relative to this module so it
|
|
31
|
+
* resolves whether the sdk is a workspace sibling or an installed dependency —
|
|
32
|
+
* the product no longer has to guess a `packages/sdk` monorepo path. */
|
|
33
|
+
export function sharedFontsDir() {
|
|
34
|
+
return realpathSync(join(dirname(fileURLToPath(import.meta.url)), "fonts"));
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Whether a Vite dev server's `server.fs.allow` reaches the shared fonts. base.css
|
|
38
|
+
* lives outside a product's own Vite root, so without an allow entry covering it
|
|
39
|
+
* the dev server 403s the sibling font and the browser silently falls back to a
|
|
40
|
+
* system font (the guide-screenshot mismatch). True iff some allow base is an
|
|
41
|
+
* ancestor of the fonts dir. Allow bases that don't resolve are ignored.
|
|
42
|
+
*/
|
|
43
|
+
export function fsAllowCoversFonts(allow) {
|
|
44
|
+
const fonts = sharedFontsDir();
|
|
45
|
+
return allow.some((base) => {
|
|
46
|
+
try {
|
|
47
|
+
return fonts.startsWith(realpathSync(base));
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
}
|
package/manifest-schema.d.ts
CHANGED
|
@@ -22,8 +22,8 @@ export declare const ManifestSchema: z.ZodObject<{
|
|
|
22
22
|
healthCheckPath: z.ZodDefault<z.ZodString>;
|
|
23
23
|
productMcpPath: z.ZodOptional<z.ZodString>;
|
|
24
24
|
}, z.core.$strip>;
|
|
25
|
-
ui: z.ZodObject<{
|
|
26
|
-
configScreenUrl: z.ZodString
|
|
25
|
+
ui: z.ZodDefault<z.ZodObject<{
|
|
26
|
+
configScreenUrl: z.ZodOptional<z.ZodString>;
|
|
27
27
|
instanceConfigScreenUrl: z.ZodOptional<z.ZodString>;
|
|
28
28
|
sidebarEntries: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
|
29
29
|
label: z.ZodString;
|
|
@@ -39,8 +39,8 @@ export declare const ManifestSchema: z.ZodObject<{
|
|
|
39
39
|
label: z.ZodString;
|
|
40
40
|
configScreenUrl: z.ZodString;
|
|
41
41
|
}, z.core.$strip>>>;
|
|
42
|
-
}, z.core.$strip
|
|
43
|
-
cli: z.ZodObject<{
|
|
42
|
+
}, z.core.$strip>>;
|
|
43
|
+
cli: z.ZodDefault<z.ZodObject<{
|
|
44
44
|
subcommandTree: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
|
45
45
|
name: z.ZodString;
|
|
46
46
|
description: z.ZodString;
|
|
@@ -53,6 +53,7 @@ export declare const ManifestSchema: z.ZodObject<{
|
|
|
53
53
|
number: "number";
|
|
54
54
|
boolean: "boolean";
|
|
55
55
|
}>>;
|
|
56
|
+
choices: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
56
57
|
}, z.core.$strip>>>;
|
|
57
58
|
subcommands: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
|
58
59
|
name: z.ZodString;
|
|
@@ -66,6 +67,7 @@ export declare const ManifestSchema: z.ZodObject<{
|
|
|
66
67
|
number: "number";
|
|
67
68
|
boolean: "boolean";
|
|
68
69
|
}>>;
|
|
70
|
+
choices: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
69
71
|
}, z.core.$strip>>>;
|
|
70
72
|
request: z.ZodOptional<z.ZodObject<{
|
|
71
73
|
method: z.ZodEnum<{
|
|
@@ -87,7 +89,7 @@ export declare const ManifestSchema: z.ZodObject<{
|
|
|
87
89
|
path: z.ZodString;
|
|
88
90
|
}, z.core.$strip>>;
|
|
89
91
|
}, z.core.$strip>>>;
|
|
90
|
-
}, z.core.$strip
|
|
92
|
+
}, z.core.$strip>>;
|
|
91
93
|
targets: z.ZodArray<z.ZodEnum<{
|
|
92
94
|
"norsk-ctl": "norsk-ctl";
|
|
93
95
|
"docker-compose": "docker-compose";
|
|
@@ -110,4 +112,9 @@ export declare const ManifestSchema: z.ZodObject<{
|
|
|
110
112
|
}, z.core.$strip>>;
|
|
111
113
|
}, z.core.$strip>;
|
|
112
114
|
export type Manifest = z.infer<typeof ManifestSchema>;
|
|
115
|
+
/** Producer-facing shape: everything the reader defaults is optional, so a
|
|
116
|
+
* product's buildManifest() sends only what it actually declares instead of
|
|
117
|
+
* `sidebarEntries: []`-style boilerplate. The parsed {@link Manifest} keeps
|
|
118
|
+
* those fields present, so runner code needs no null-guards. */
|
|
119
|
+
export type ManifestInput = z.input<typeof ManifestSchema>;
|
|
113
120
|
export {};
|
package/manifest-schema.js
CHANGED
|
@@ -31,6 +31,9 @@ const CliFlagSchema = z.object({
|
|
|
31
31
|
description: z.string(),
|
|
32
32
|
required: z.boolean().optional(),
|
|
33
33
|
type: z.enum(["string", "number", "boolean"]).optional(),
|
|
34
|
+
choices: z.array(z.string()).optional().meta({
|
|
35
|
+
description: "Allowed values for the flag. The runner's CLI rejects anything else and lists them in help output. Omit for free-form flags.",
|
|
36
|
+
}),
|
|
34
37
|
});
|
|
35
38
|
/** How a CLI leaf forwards to the product's HTTP surface. The runner registers
|
|
36
39
|
* a yargs command that issues `method` against `/products/<name><path>` (the
|
|
@@ -106,8 +109,11 @@ export const ManifestSchema = z.object({
|
|
|
106
109
|
description: "Path on the product control-plane's HTTP surface where it serves an always-on, instance-independent MCP endpoint (e.g. plugin scaffolding). Proxied as `<product>_<tool>`. Distinct from the per-instance `mcpPath`. Omit for products without one.",
|
|
107
110
|
}),
|
|
108
111
|
}),
|
|
109
|
-
ui: z
|
|
110
|
-
|
|
112
|
+
ui: z
|
|
113
|
+
.object({
|
|
114
|
+
configScreenUrl: z.string().optional().meta({
|
|
115
|
+
description: "URL (on the product's HTTP surface) of the configure screen the runner iframes to build product templates. Omit for backend-only products with no configure UI — the runner skips its registration probe and the UI offers a config-free build path instead of an iframe.",
|
|
116
|
+
}),
|
|
111
117
|
// Optional rich instance-launch config screen. When present, the runner's
|
|
112
118
|
// launch flow probes it (a GET carrying product-template context); the
|
|
113
119
|
// product returns 200 to have the runner iframe it in place of the
|
|
@@ -121,10 +127,13 @@ export const ManifestSchema = z.object({
|
|
|
121
127
|
sidebarEntries: z.array(SidebarEntrySchema).default([]),
|
|
122
128
|
dashboardWidgets: z.array(DashboardWidgetSchema).default([]),
|
|
123
129
|
productTemplateActions: z.array(ProductTemplateActionSchema).default([]),
|
|
124
|
-
})
|
|
125
|
-
|
|
130
|
+
})
|
|
131
|
+
.default({ sidebarEntries: [], dashboardWidgets: [], productTemplateActions: [] }),
|
|
132
|
+
cli: z
|
|
133
|
+
.object({
|
|
126
134
|
subcommandTree: z.array(CliCommandSchema).default([]),
|
|
127
|
-
})
|
|
135
|
+
})
|
|
136
|
+
.default({ subcommandTree: [] }),
|
|
128
137
|
targets: z.array(TargetSchema),
|
|
129
138
|
components: z.array(ComponentDescSchema).default([]),
|
|
130
139
|
runtime: RuntimeHintsSchema,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@norskvideo/ctl-sdk",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"exports": {
|
|
6
6
|
".": {
|
|
@@ -15,6 +15,10 @@
|
|
|
15
15
|
"types": "./components/index.d.ts",
|
|
16
16
|
"default": "./components/index.js"
|
|
17
17
|
},
|
|
18
|
+
"./frontend-spec": {
|
|
19
|
+
"types": "./frontend-spec.d.ts",
|
|
20
|
+
"default": "./frontend-spec.js"
|
|
21
|
+
},
|
|
18
22
|
"./manifest-seed": {
|
|
19
23
|
"types": "./manifest-seed.d.ts",
|
|
20
24
|
"default": "./manifest-seed.js"
|
|
@@ -5,6 +5,6 @@
|
|
|
5
5
|
* path (used by mgr — name validation + bytes persistence).
|
|
6
6
|
*/
|
|
7
7
|
export declare class ProductTemplateError extends Error {
|
|
8
|
-
code: "INVALID_NAME" | "NAME_CONFLICT" | "FILE_NOT_FOUND" | "EXTRACTION_FAILED" | "MANIFEST_MISSING" | "MANIFEST_INVALID" | "COMPOSE_MISSING" | "PARAMETERS_INVALID" | "NOT_FOUND" | "IN_USE";
|
|
9
|
-
constructor(code: "INVALID_NAME" | "NAME_CONFLICT" | "FILE_NOT_FOUND" | "EXTRACTION_FAILED" | "MANIFEST_MISSING" | "MANIFEST_INVALID" | "COMPOSE_MISSING" | "PARAMETERS_INVALID" | "NOT_FOUND" | "IN_USE", message: string);
|
|
8
|
+
code: "INVALID_NAME" | "NAME_CONFLICT" | "FILE_NOT_FOUND" | "EXTRACTION_FAILED" | "MANIFEST_MISSING" | "MANIFEST_INVALID" | "COMPOSE_MISSING" | "PARAMETERS_INVALID" | "NOT_FOUND" | "IN_USE" | "PRODUCT_MISMATCH";
|
|
9
|
+
constructor(code: "INVALID_NAME" | "NAME_CONFLICT" | "FILE_NOT_FOUND" | "EXTRACTION_FAILED" | "MANIFEST_MISSING" | "MANIFEST_INVALID" | "COMPOSE_MISSING" | "PARAMETERS_INVALID" | "NOT_FOUND" | "IN_USE" | "PRODUCT_MISMATCH", message: string);
|
|
10
10
|
}
|
package/workflow.d.ts
CHANGED
|
@@ -39,6 +39,14 @@ export declare const VIDEO_ONLY: SubscriptionStreams;
|
|
|
39
39
|
export declare const AUDIO_ONLY: SubscriptionStreams;
|
|
40
40
|
export declare const FIRST_VIDEO: SubscriptionStreams;
|
|
41
41
|
export declare const FIRST_AUDIO: SubscriptionStreams;
|
|
42
|
+
export type ComponentAdder<Handle> = {
|
|
43
|
+
addNode(identifier: string, config: never): Handle;
|
|
44
|
+
};
|
|
45
|
+
export declare function addComponent<Handle, C extends object>(builder: ComponentAdder<Handle>, component: WorkflowComponent<C>): Handle;
|
|
46
|
+
export type DocumentSource = {
|
|
47
|
+
toDocument(): unknown;
|
|
48
|
+
};
|
|
49
|
+
export declare function toWorkflowDoc<D extends WorkflowDoc = WorkflowDoc>(builder: DocumentSource): D;
|
|
42
50
|
export type SyntheticNodeInfo = {
|
|
43
51
|
identifier: string;
|
|
44
52
|
subscription: {
|
|
@@ -57,4 +65,36 @@ export type SyntheticNodeInfo = {
|
|
|
57
65
|
};
|
|
58
66
|
};
|
|
59
67
|
};
|
|
68
|
+
export type ComponentStubMedia = "video" | "audio" | "subtitle" | "ancillary" | "playlist";
|
|
69
|
+
export type ComponentStubSpec = {
|
|
70
|
+
identifier: string;
|
|
71
|
+
accepts?: ComponentStubMedia[];
|
|
72
|
+
produces?: ComponentStubMedia[] | "passthrough";
|
|
73
|
+
acceptsTransient?: boolean;
|
|
74
|
+
validateConfig?: (config: Record<string, unknown>) => string[] | undefined;
|
|
75
|
+
};
|
|
76
|
+
type StubMediaFlags = {
|
|
77
|
+
[M in ComponentStubMedia]?: true;
|
|
78
|
+
};
|
|
79
|
+
export type StubNodeInfo = {
|
|
80
|
+
identifier: string;
|
|
81
|
+
subscription: {
|
|
82
|
+
accepts?: {
|
|
83
|
+
type: "simple-stream";
|
|
84
|
+
acceptsTransient: boolean;
|
|
85
|
+
} & StubMediaFlags;
|
|
86
|
+
produces?: ({
|
|
87
|
+
type: "simple-stream";
|
|
88
|
+
} & StubMediaFlags) | {
|
|
89
|
+
type: "dynamic-streams";
|
|
90
|
+
streams: <S>(cfg: unknown, inputStreams: S) => S;
|
|
91
|
+
};
|
|
92
|
+
};
|
|
93
|
+
validateConfig?: (config: Record<string, unknown>) => string[] | undefined;
|
|
94
|
+
};
|
|
95
|
+
export declare function stubNodeInfo<T = StubNodeInfo>(spec: ComponentStubSpec): T;
|
|
96
|
+
export declare function stubbedLibrary<Info = StubNodeInfo>(find: (identifier: string) => Info | undefined, stubs: ComponentStubSpec[]): {
|
|
97
|
+
find(identifier: string): Info | undefined;
|
|
98
|
+
};
|
|
60
99
|
export declare function syntheticInfo<T = SyntheticNodeInfo>(identifier: string): T;
|
|
100
|
+
export {};
|
package/workflow.js
CHANGED
|
@@ -26,6 +26,76 @@ export const FIRST_AUDIO = {
|
|
|
26
26
|
type: "take-first-stream",
|
|
27
27
|
filter: [{ media: "audio" }],
|
|
28
28
|
};
|
|
29
|
+
// Typed `addNode` boundary: adds a factory-built component to a WorkflowBuilder
|
|
30
|
+
// with the factory's config typing intact, so composers need no `config as
|
|
31
|
+
// never` at the add boundary. Subscriptions are NOT applied — wiring goes
|
|
32
|
+
// through the builder's connect()/pick* helpers, exactly as every product's
|
|
33
|
+
// local add() helper behaved. The single cast below is the one audited erasure:
|
|
34
|
+
// the builder's `BaseConfig` constraint wants an index signature that interface-
|
|
35
|
+
// declared product configs may lack, while the real gates stay intact — the
|
|
36
|
+
// factory return type checks the config shape at construction, and
|
|
37
|
+
// builder.validate() runs each node's real validateConfig at compose time.
|
|
38
|
+
export function addComponent(builder, component) {
|
|
39
|
+
return builder.addNode(component.type, component.config);
|
|
40
|
+
}
|
|
41
|
+
// Typed `toDocument` boundary: the builder returns its all-optional YamlDocument
|
|
42
|
+
// shape, which products immediately re-assert as their WorkflowDoc (previously
|
|
43
|
+
// via `as unknown as WorkflowDoc` at every call site). Sound by construction:
|
|
44
|
+
// the builder always emits a components array, and each entry carries the
|
|
45
|
+
// type/config/subscriptions the composer added. Pass a product-extended doc
|
|
46
|
+
// type (e.g. probe's signed document) as `D` when the product adds fields.
|
|
47
|
+
export function toWorkflowDoc(builder) {
|
|
48
|
+
return builder.toDocument();
|
|
49
|
+
}
|
|
50
|
+
function mediaFlags(media) {
|
|
51
|
+
return Object.fromEntries(media.map((m) => [m, true]));
|
|
52
|
+
}
|
|
53
|
+
// A builder NodeInfo from a metadata-only stub declaration. Unlike
|
|
54
|
+
// syntheticInfo's accept-anything fallback, the resulting info carries the
|
|
55
|
+
// component's REAL media contract, so edges into and out of the stub
|
|
56
|
+
// stream-validate like any built-in (only its config stays unvalidated unless
|
|
57
|
+
// the spec supplies validateConfig).
|
|
58
|
+
export function stubNodeInfo(spec) {
|
|
59
|
+
const accepts = spec.accepts
|
|
60
|
+
? {
|
|
61
|
+
accepts: {
|
|
62
|
+
type: "simple-stream",
|
|
63
|
+
...mediaFlags(spec.accepts),
|
|
64
|
+
acceptsTransient: spec.acceptsTransient ?? true,
|
|
65
|
+
},
|
|
66
|
+
}
|
|
67
|
+
: {};
|
|
68
|
+
const produces = spec.produces === "passthrough"
|
|
69
|
+
? {
|
|
70
|
+
produces: {
|
|
71
|
+
type: "dynamic-streams",
|
|
72
|
+
streams: (_cfg, inputStreams) => inputStreams,
|
|
73
|
+
},
|
|
74
|
+
}
|
|
75
|
+
: spec.produces && spec.produces.length > 0
|
|
76
|
+
? { produces: { type: "simple-stream", ...mediaFlags(spec.produces) } }
|
|
77
|
+
: {};
|
|
78
|
+
return {
|
|
79
|
+
identifier: spec.identifier,
|
|
80
|
+
subscription: { ...accepts, ...produces },
|
|
81
|
+
...(spec.validateConfig ? { validateConfig: spec.validateConfig } : {}),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
// A ComponentLibrary that resolves the real library first and declared stubs
|
|
85
|
+
// second — and, crucially, NOTHING else: an identifier that is neither real nor
|
|
86
|
+
// declared stays unresolved, so the builder's addNode throws on it instead of
|
|
87
|
+
// silently composing against a permissive synthetic. This is what lets
|
|
88
|
+
// builder.validate() be a throwing gate fleet-wide — every node is either a
|
|
89
|
+
// real NodeInfo or an explicitly declared stub, and a typo fails the compose.
|
|
90
|
+
export function stubbedLibrary(find, stubs) {
|
|
91
|
+
const stubInfos = new Map();
|
|
92
|
+
for (const spec of stubs) {
|
|
93
|
+
if (stubInfos.has(spec.identifier))
|
|
94
|
+
throw new Error(`duplicate component stub: ${spec.identifier}`);
|
|
95
|
+
stubInfos.set(spec.identifier, stubNodeInfo(spec));
|
|
96
|
+
}
|
|
97
|
+
return { find: (identifier) => find(identifier) ?? stubInfos.get(identifier) };
|
|
98
|
+
}
|
|
29
99
|
// A permissive builder NodeInfo for an identifier the real component library
|
|
30
100
|
// does not know (a product's own component, or an alpha/custom node that lives
|
|
31
101
|
// only in the running studio image). It accepts any media and PASSES INPUT
|