@omriashke/dynamico-core 0.1.8 → 0.1.11
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/dist/bookPreview.d.ts +3 -0
- package/dist/bookPreview.d.ts.map +1 -1
- package/dist/bookPreview.js +5 -0
- package/dist/bookPreview.js.map +1 -1
- package/dist/constants.d.ts +6 -0
- package/dist/constants.d.ts.map +1 -0
- package/dist/constants.js +8 -0
- package/dist/constants.js.map +1 -0
- package/dist/esbuildFlatten.d.ts +15 -0
- package/dist/esbuildFlatten.d.ts.map +1 -0
- package/dist/esbuildFlatten.js +39 -0
- package/dist/esbuildFlatten.js.map +1 -0
- package/dist/index.d.ts +7 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -3
- package/dist/index.js.map +1 -1
- package/dist/loader.d.ts +2 -0
- package/dist/loader.d.ts.map +1 -1
- package/dist/loader.js +62 -19
- package/dist/loader.js.map +1 -1
- package/dist/node/bookConfig.d.ts +13 -0
- package/dist/node/bookConfig.d.ts.map +1 -0
- package/dist/node/bookConfig.js +54 -0
- package/dist/node/bookConfig.js.map +1 -0
- package/dist/node/index.d.ts +2 -0
- package/dist/node/index.d.ts.map +1 -0
- package/dist/node/index.js +2 -0
- package/dist/node/index.js.map +1 -0
- package/dist/packageScope.d.ts.map +1 -1
- package/dist/packageScope.js +15 -7
- package/dist/packageScope.js.map +1 -1
- package/dist/propsSchema.d.ts +2 -0
- package/dist/propsSchema.d.ts.map +1 -1
- package/dist/propsSchema.js +36 -0
- package/dist/propsSchema.js.map +1 -1
- package/dist/react/createRuntime.d.ts.map +1 -1
- package/dist/react/createRuntime.js +3 -13
- package/dist/react/createRuntime.js.map +1 -1
- package/dist/react/useRegistryModule.d.ts +4 -0
- package/dist/react/useRegistryModule.d.ts.map +1 -0
- package/dist/react/useRegistryModule.js +8 -0
- package/dist/react/useRegistryModule.js.map +1 -0
- package/dist/registry.d.ts +5 -0
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +35 -4
- package/dist/registry.js.map +1 -1
- package/dist/registryModule.d.ts.map +1 -1
- package/dist/registryModule.js +13 -2
- package/dist/registryModule.js.map +1 -1
- package/dist/relativeRequires.d.ts +12 -0
- package/dist/relativeRequires.d.ts.map +1 -1
- package/dist/relativeRequires.js +33 -0
- package/dist/relativeRequires.js.map +1 -1
- package/dist/sources/remote.d.ts +7 -8
- package/dist/sources/remote.d.ts.map +1 -1
- package/dist/sources/remote.js +73 -19
- package/dist/sources/remote.js.map +1 -1
- package/dist/types.d.ts +6 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +11 -2
- package/src/bookPreview.ts +7 -0
- package/src/constants.ts +9 -0
- package/src/esbuildFlatten.ts +47 -0
- package/src/index.ts +22 -2
- package/src/loader.ts +52 -19
- package/src/node/bookConfig.ts +63 -0
- package/src/node/index.ts +9 -0
- package/src/packageScope.ts +15 -7
- package/src/propsSchema.ts +35 -0
- package/src/react/createRuntime.tsx +3 -10
- package/src/react/useRegistryModule.ts +15 -0
- package/src/registry.ts +39 -4
- package/src/registryModule.ts +12 -3
- package/src/relativeRequires.ts +48 -0
- package/src/sources/remote.ts +72 -26
- package/src/types.ts +6 -0
package/src/bookPreview.ts
CHANGED
|
@@ -3,6 +3,13 @@ import type { PropsSchema } from "./types.js";
|
|
|
3
3
|
|
|
4
4
|
export type BookPreviewJson = Record<string, unknown>;
|
|
5
5
|
|
|
6
|
+
/** Filenames recognized as Dynamico Book catalog configs on disk. */
|
|
7
|
+
export const BOOK_CONFIG_FILENAMES = ["book.config.json", "storybook.config.json"] as const;
|
|
8
|
+
|
|
9
|
+
export function isBookConfigFilename(name: string): boolean {
|
|
10
|
+
return (BOOK_CONFIG_FILENAMES as readonly string[]).includes(name);
|
|
11
|
+
}
|
|
12
|
+
|
|
6
13
|
export interface BookPreviewConfig {
|
|
7
14
|
fixtures?: Record<string, BookPreviewJson>;
|
|
8
15
|
/** Registry components wrapping every preview (outermost last). */
|
package/src/constants.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** Registry manifest filename at the source directory root. */
|
|
2
|
+
export const MANIFEST_FILENAME = "dynamico.config.json" as const;
|
|
3
|
+
|
|
4
|
+
/** Matches co-located author test files — not registry components. */
|
|
5
|
+
export const COMPONENT_TEST_RE = /\.test\.(tsx|jsx|ts|js)$/;
|
|
6
|
+
|
|
7
|
+
export function isComponentTestFilename(filename: string): boolean {
|
|
8
|
+
return COMPONENT_TEST_RE.test(filename);
|
|
9
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/** Marker appended once so loadModule does not double-flatten esbuild bundles. */
|
|
2
|
+
export const ESBUILD_FLATTEN_MARKER = ";// dynamico-flat-exports";
|
|
3
|
+
|
|
4
|
+
export interface EsbuildExportEntry {
|
|
5
|
+
exportKey: string;
|
|
6
|
+
varName: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** Parse esbuild `__export(mod, { name: () => var, ... })` named export entries. */
|
|
10
|
+
export function parseEsbuildNamedExports(code: string): EsbuildExportEntry[] {
|
|
11
|
+
const exportBlockMatch = code.match(/__export\(\w+,\s*\{([\s\S]*?)\}\s*\)/);
|
|
12
|
+
if (!exportBlockMatch) return [];
|
|
13
|
+
const entries: EsbuildExportEntry[] = [];
|
|
14
|
+
for (const m of exportBlockMatch[1].matchAll(/(\w+):\s*\(\)\s*=>\s*(\w+)/g)) {
|
|
15
|
+
entries.push({ exportKey: m[1], varName: m[2] });
|
|
16
|
+
}
|
|
17
|
+
return entries;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Append a plain `module.exports = { ... }` assignment so Hermes and relative
|
|
22
|
+
* imports (e.g. `require("../Colors").Colors`) see named exports, not only
|
|
23
|
+
* getter-based defaults.
|
|
24
|
+
*/
|
|
25
|
+
export function appendPlainEsbuildExports(code: string): string {
|
|
26
|
+
if (code.includes(ESBUILD_FLATTEN_MARKER)) return code;
|
|
27
|
+
|
|
28
|
+
const entries = parseEsbuildNamedExports(code);
|
|
29
|
+
if (entries.length === 0) {
|
|
30
|
+
const defaultMatch = code.match(/default:\s*\(\)\s*=>\s*(\w+)/);
|
|
31
|
+
if (!defaultMatch) return code;
|
|
32
|
+
const fn = defaultMatch[1];
|
|
33
|
+
const propsMatch = code.match(/propsSchema:\s*\(\)\s*=>\s*(\w+)/);
|
|
34
|
+
const propsPart = propsMatch ? `,propsSchema:${propsMatch[1]}` : "";
|
|
35
|
+
return `${code}${ESBUILD_FLATTEN_MARKER}\n;(function(){try{if(typeof ${fn}==='function'){module.exports={__esModule:true,default:${fn}${propsPart}};}}catch(e){}})();\n`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const parts = entries.map(({ exportKey, varName }) =>
|
|
39
|
+
exportKey === "default" ? `default:${varName}` : `${exportKey}:${varName}`,
|
|
40
|
+
);
|
|
41
|
+
const defaultEntry = entries.find((e) => e.exportKey === "default");
|
|
42
|
+
const guard = defaultEntry
|
|
43
|
+
? `(typeof ${defaultEntry.varName}!=='undefined')`
|
|
44
|
+
: "true";
|
|
45
|
+
|
|
46
|
+
return `${code}${ESBUILD_FLATTEN_MARKER}\n;(function(){try{if(${guard}){module.exports={__esModule:true,${parts.join(",")}};}}catch(e){}})();\n`;
|
|
47
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -17,9 +17,14 @@ export type {
|
|
|
17
17
|
} from "./types.js";
|
|
18
18
|
|
|
19
19
|
export { Registry } from "./registry.js";
|
|
20
|
-
export { loadModule } from "./loader.js";
|
|
20
|
+
export { loadModule, resolveModuleDefault } from "./loader.js";
|
|
21
|
+
export {
|
|
22
|
+
appendPlainEsbuildExports,
|
|
23
|
+
parseEsbuildNamedExports,
|
|
24
|
+
ESBUILD_FLATTEN_MARKER,
|
|
25
|
+
} from "./esbuildFlatten.js";
|
|
21
26
|
export { createRemoteSource, type RemoteSourceOptions } from "./sources/remote.js";
|
|
22
|
-
export { validateProps, type PropsValidationResult } from "./propsSchema.js";
|
|
27
|
+
export { validateProps, extractPropsSchema, type PropsValidationResult } from "./propsSchema.js";
|
|
23
28
|
export { generateDefaultProps } from "./defaultProps.js";
|
|
24
29
|
export {
|
|
25
30
|
collectBookPreviewPropSets,
|
|
@@ -27,12 +32,26 @@ export {
|
|
|
27
32
|
resolveBookFixtures,
|
|
28
33
|
resolveBookPropValues,
|
|
29
34
|
validateBookPreviewsForComponent,
|
|
35
|
+
BOOK_CONFIG_FILENAMES,
|
|
36
|
+
isBookConfigFilename,
|
|
30
37
|
type BookPreviewBlock,
|
|
31
38
|
type BookPreviewConfig,
|
|
32
39
|
type BookPreviewEntry,
|
|
33
40
|
type BookPreviewPropSet,
|
|
34
41
|
type BookPreviewValidationResult,
|
|
35
42
|
} from "./bookPreview.js";
|
|
43
|
+
export {
|
|
44
|
+
resolveRelativeComponentName,
|
|
45
|
+
extractRelativeRequires,
|
|
46
|
+
collectRelativeComponentDeps,
|
|
47
|
+
validateRelativeImports,
|
|
48
|
+
type RelativeImportValidation,
|
|
49
|
+
} from "./relativeRequires.js";
|
|
50
|
+
export {
|
|
51
|
+
MANIFEST_FILENAME,
|
|
52
|
+
COMPONENT_TEST_RE,
|
|
53
|
+
isComponentTestFilename,
|
|
54
|
+
} from "./constants.js";
|
|
36
55
|
export {
|
|
37
56
|
createRuntime,
|
|
38
57
|
type RuntimeAPI,
|
|
@@ -40,6 +59,7 @@ export {
|
|
|
40
59
|
type DynamicoProviderProps,
|
|
41
60
|
type DynamicComponentProps,
|
|
42
61
|
} from "./react/createRuntime.js";
|
|
62
|
+
export { createUseRegistryModule } from "./react/useRegistryModule.js";
|
|
43
63
|
export {
|
|
44
64
|
createPackageScope,
|
|
45
65
|
createPackageScopeFromNames,
|
package/src/loader.ts
CHANGED
|
@@ -1,11 +1,26 @@
|
|
|
1
1
|
import type { Scope } from "./types.js";
|
|
2
|
+
import { appendPlainEsbuildExports } from "./esbuildFlatten.js";
|
|
2
3
|
|
|
3
|
-
/**
|
|
4
|
-
function
|
|
5
|
-
const
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
4
|
+
/** Copy exports to a plain object (no getters) so Hermes can read `.default` reliably. */
|
|
5
|
+
function toPlainExports(exports: Record<string, unknown>): Record<string, unknown> {
|
|
6
|
+
const plain: Record<string, unknown> = {};
|
|
7
|
+
for (const key of Object.getOwnPropertyNames(exports)) {
|
|
8
|
+
const desc = Object.getOwnPropertyDescriptor(exports, key);
|
|
9
|
+
if (desc?.get && !desc.set) {
|
|
10
|
+
try {
|
|
11
|
+
const value = desc.get.call(exports);
|
|
12
|
+
if (value !== undefined) plain[key] = value;
|
|
13
|
+
} catch {
|
|
14
|
+
/* skip broken getter */
|
|
15
|
+
}
|
|
16
|
+
} else if (desc && "value" in desc) {
|
|
17
|
+
plain[key] = desc.value;
|
|
18
|
+
} else {
|
|
19
|
+
plain[key] = exports[key];
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
if (!("__esModule" in plain)) plain.__esModule = true;
|
|
23
|
+
return plain;
|
|
9
24
|
}
|
|
10
25
|
|
|
11
26
|
/** Replace getter-only exports with plain values (Hermes-safe). */
|
|
@@ -16,12 +31,16 @@ function materializeGetterExports(exports: Record<string, unknown>): void {
|
|
|
16
31
|
try {
|
|
17
32
|
const value = desc.get.call(exports);
|
|
18
33
|
if (value !== undefined) {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
34
|
+
try {
|
|
35
|
+
Object.defineProperty(exports, key, {
|
|
36
|
+
value,
|
|
37
|
+
enumerable: true,
|
|
38
|
+
configurable: true,
|
|
39
|
+
writable: true,
|
|
40
|
+
});
|
|
41
|
+
} catch {
|
|
42
|
+
(exports as Record<string, unknown>)[key] = value;
|
|
43
|
+
}
|
|
25
44
|
}
|
|
26
45
|
} catch {
|
|
27
46
|
/* leave getter */
|
|
@@ -29,6 +48,25 @@ function materializeGetterExports(exports: Record<string, unknown>): void {
|
|
|
29
48
|
}
|
|
30
49
|
}
|
|
31
50
|
|
|
51
|
+
/** Resolve the default export from a loaded CJS module object (Hermes-safe). */
|
|
52
|
+
export function resolveModuleDefault(exp: unknown): unknown {
|
|
53
|
+
if (typeof exp === "function") return exp;
|
|
54
|
+
if (!exp || typeof exp !== "object") return undefined;
|
|
55
|
+
const exports = exp as Record<string, unknown>;
|
|
56
|
+
const desc = Object.getOwnPropertyDescriptor(exports, "default");
|
|
57
|
+
if (desc?.get && !desc.set) {
|
|
58
|
+
try {
|
|
59
|
+
const fromGetter = desc.get.call(exports);
|
|
60
|
+
if (typeof fromGetter === "function") return fromGetter;
|
|
61
|
+
} catch {
|
|
62
|
+
/* fall through */
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
const d = exports.default;
|
|
66
|
+
if (typeof d === "function") return d;
|
|
67
|
+
return undefined;
|
|
68
|
+
}
|
|
69
|
+
|
|
32
70
|
/**
|
|
33
71
|
* Execute a CommonJS-style code string in a controlled scope.
|
|
34
72
|
*
|
|
@@ -52,9 +90,6 @@ export function loadModule(
|
|
|
52
90
|
if (name.startsWith("./") || name.startsWith("../") || name.startsWith("/")) {
|
|
53
91
|
return requireRelative(name);
|
|
54
92
|
}
|
|
55
|
-
// Use `in` (triggers Proxy `has` traps) so that hosts can supply scope
|
|
56
|
-
// via a Proxy and resolve module bindings lazily. Falls back to
|
|
57
|
-
// `hasOwnProperty` semantics for plain object scopes.
|
|
58
93
|
if (name in scope) {
|
|
59
94
|
return scope[name];
|
|
60
95
|
}
|
|
@@ -63,11 +98,9 @@ export function loadModule(
|
|
|
63
98
|
);
|
|
64
99
|
};
|
|
65
100
|
|
|
66
|
-
// The compiled body is just the function body; arguments are well-known.
|
|
67
101
|
// eslint-disable-next-line no-new-func
|
|
68
|
-
const fn = new Function("module", "exports", "require",
|
|
102
|
+
const fn = new Function("module", "exports", "require", appendPlainEsbuildExports(code));
|
|
69
103
|
fn(moduleObj, moduleObj.exports, requireFn);
|
|
70
104
|
materializeGetterExports(moduleObj.exports);
|
|
71
|
-
|
|
72
|
-
return moduleObj.exports;
|
|
105
|
+
return toPlainExports(moduleObj.exports);
|
|
73
106
|
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import {
|
|
5
|
+
BOOK_CONFIG_FILENAMES,
|
|
6
|
+
isBookConfigFilename,
|
|
7
|
+
type BookPreviewConfig,
|
|
8
|
+
} from "../bookPreview.js";
|
|
9
|
+
|
|
10
|
+
export type BookConfigFilename = (typeof BOOK_CONFIG_FILENAMES)[number];
|
|
11
|
+
|
|
12
|
+
export interface BookConfigFile {
|
|
13
|
+
filename: BookConfigFilename;
|
|
14
|
+
source: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function bookConfigPathSync(dir: string): string | null {
|
|
18
|
+
for (const name of BOOK_CONFIG_FILENAMES) {
|
|
19
|
+
const filePath = join(dir, name);
|
|
20
|
+
if (existsSync(filePath)) return filePath;
|
|
21
|
+
}
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function readBookPreviewConfigSync(dir: string): BookPreviewConfig | undefined {
|
|
26
|
+
const filePath = bookConfigPathSync(dir);
|
|
27
|
+
if (!filePath) return undefined;
|
|
28
|
+
try {
|
|
29
|
+
return JSON.parse(readFileSync(filePath, "utf8")) as BookPreviewConfig;
|
|
30
|
+
} catch {
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function readBookPreviewConfigAsync(dir: string): Promise<BookPreviewConfig | undefined> {
|
|
36
|
+
for (const name of BOOK_CONFIG_FILENAMES) {
|
|
37
|
+
const filePath = join(dir, name);
|
|
38
|
+
if (!existsSync(filePath)) continue;
|
|
39
|
+
try {
|
|
40
|
+
return JSON.parse(await readFile(filePath, "utf8")) as BookPreviewConfig;
|
|
41
|
+
} catch {
|
|
42
|
+
return undefined;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Raw book catalog file to ship with `dynamico push --dir`. */
|
|
49
|
+
export async function readBookConfigFileAsync(dir: string): Promise<BookConfigFile | undefined> {
|
|
50
|
+
for (const filename of BOOK_CONFIG_FILENAMES) {
|
|
51
|
+
const filePath = join(dir, filename);
|
|
52
|
+
if (!existsSync(filePath)) continue;
|
|
53
|
+
const source = await readFile(filePath, "utf8");
|
|
54
|
+
JSON.parse(source);
|
|
55
|
+
return { filename, source };
|
|
56
|
+
}
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function isBookConfigPath(relPath: string): boolean {
|
|
61
|
+
const base = relPath.split(/[/\\]/).pop() ?? relPath;
|
|
62
|
+
return isBookConfigFilename(base);
|
|
63
|
+
}
|
package/src/packageScope.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createElement, useSyncExternalStore, type ComponentType } from "react";
|
|
2
2
|
import type { CompiledModule, Scope, Source } from "./types.js";
|
|
3
3
|
import { loadModule } from "./loader.js";
|
|
4
|
+
import { resolveRelativeComponentName } from "./relativeRequires.js";
|
|
4
5
|
|
|
5
6
|
export interface PackageScopeOptions {
|
|
6
7
|
/** Registry component names (export name = registry name). */
|
|
@@ -22,6 +23,7 @@ interface ModuleState {
|
|
|
22
23
|
/** Bumped on every ingest so useSyncExternalStore subscribers re-render. */
|
|
23
24
|
revision: number;
|
|
24
25
|
listeners: Set<() => void>;
|
|
26
|
+
watchRelease?: () => void;
|
|
25
27
|
}
|
|
26
28
|
|
|
27
29
|
/**
|
|
@@ -52,9 +54,17 @@ export function createPackageScope(
|
|
|
52
54
|
|
|
53
55
|
const subscribe = (name: string, listener: () => void): (() => void) => {
|
|
54
56
|
const state = getState(name);
|
|
57
|
+
const wasEmpty = state.listeners.size === 0;
|
|
55
58
|
state.listeners.add(listener);
|
|
59
|
+
if (wasEmpty && source.watch) {
|
|
60
|
+
state.watchRelease = source.watch(name);
|
|
61
|
+
}
|
|
56
62
|
return () => {
|
|
57
63
|
state.listeners.delete(listener);
|
|
64
|
+
if (state.listeners.size === 0) {
|
|
65
|
+
state.watchRelease?.();
|
|
66
|
+
state.watchRelease = undefined;
|
|
67
|
+
}
|
|
58
68
|
};
|
|
59
69
|
};
|
|
60
70
|
|
|
@@ -74,11 +84,7 @@ export function createPackageScope(
|
|
|
74
84
|
let makeLazy: (name: string) => LazyComponent;
|
|
75
85
|
|
|
76
86
|
const requireRelative = (specifier: string): unknown => {
|
|
77
|
-
const base = specifier
|
|
78
|
-
.replace(/^\.+\//, "")
|
|
79
|
-
.replace(/\.[tj]sx?$/, "")
|
|
80
|
-
.split("/")
|
|
81
|
-
.pop();
|
|
87
|
+
const base = resolveRelativeComponentName(specifier);
|
|
82
88
|
if (!base) throw new Error(`dynamico: cannot resolve '${specifier}'`);
|
|
83
89
|
ensureLoaded(base);
|
|
84
90
|
const dep = modules.get(base)?.factory;
|
|
@@ -128,14 +134,16 @@ export function createPackageScope(
|
|
|
128
134
|
};
|
|
129
135
|
|
|
130
136
|
source.subscribe(({ module }) => {
|
|
131
|
-
if (componentSet.has(module.name))
|
|
137
|
+
if (!componentSet.has(module.name)) return;
|
|
138
|
+
const state = getState(module.name);
|
|
139
|
+
if (state.factory !== undefined || state.listeners.size > 0 || state.loading) {
|
|
132
140
|
ingest(module.name, module);
|
|
133
141
|
}
|
|
134
142
|
});
|
|
135
143
|
|
|
136
144
|
makeLazy = (name: string): LazyComponent => {
|
|
137
|
-
ensureLoaded(name);
|
|
138
145
|
const Lazy: LazyComponent = (props) => {
|
|
146
|
+
ensureLoaded(name);
|
|
139
147
|
const revision = useSyncExternalStore(
|
|
140
148
|
(cb) => subscribe(name, cb),
|
|
141
149
|
() => getRevision(name),
|
package/src/propsSchema.ts
CHANGED
|
@@ -43,3 +43,38 @@ function describe(v: unknown): string {
|
|
|
43
43
|
if (typeof v === "function") return "function";
|
|
44
44
|
return typeof v;
|
|
45
45
|
}
|
|
46
|
+
|
|
47
|
+
/** Parse `export const propsSchema = { … }` from component source text. */
|
|
48
|
+
export function extractPropsSchema(source: string): PropsSchema | undefined {
|
|
49
|
+
const marker = "export const propsSchema";
|
|
50
|
+
const idx = source.indexOf(marker);
|
|
51
|
+
if (idx < 0) return undefined;
|
|
52
|
+
|
|
53
|
+
const after = source.slice(idx + marker.length);
|
|
54
|
+
const eq = after.indexOf("=");
|
|
55
|
+
if (eq < 0) return undefined;
|
|
56
|
+
|
|
57
|
+
let rest = after.slice(eq + 1).trimStart();
|
|
58
|
+
if (!rest.startsWith("{")) return undefined;
|
|
59
|
+
|
|
60
|
+
let depth = 0;
|
|
61
|
+
let end = 0;
|
|
62
|
+
for (let i = 0; i < rest.length; i++) {
|
|
63
|
+
const ch = rest[i];
|
|
64
|
+
if (ch === "{") depth++;
|
|
65
|
+
else if (ch === "}") {
|
|
66
|
+
depth--;
|
|
67
|
+
if (depth === 0) {
|
|
68
|
+
end = i + 1;
|
|
69
|
+
break;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
if (end === 0) return undefined;
|
|
74
|
+
|
|
75
|
+
try {
|
|
76
|
+
return new Function(`return (${rest.slice(0, end)})`)() as PropsSchema;
|
|
77
|
+
} catch {
|
|
78
|
+
return undefined;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import * as React from "react";
|
|
2
2
|
import { Registry } from "../registry.js";
|
|
3
|
+
import { resolveModuleDefault } from "../loader.js";
|
|
3
4
|
import type {
|
|
4
5
|
ComponentFactory,
|
|
5
6
|
DynamicError,
|
|
@@ -187,16 +188,8 @@ export function createRuntime(
|
|
|
187
188
|
}
|
|
188
189
|
|
|
189
190
|
function pickDefault(factory: ComponentFactory): unknown {
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
const d = factory.default;
|
|
193
|
-
if (typeof d === "function") return d;
|
|
194
|
-
}
|
|
195
|
-
// CommonJS interop: the value itself may be the component
|
|
196
|
-
if (typeof factory === "function") return factory;
|
|
197
|
-
}
|
|
198
|
-
if (typeof factory === "function") return factory;
|
|
199
|
-
return undefined;
|
|
191
|
+
const d = resolveModuleDefault(factory);
|
|
192
|
+
return typeof d === "function" ? d : undefined;
|
|
200
193
|
}
|
|
201
194
|
|
|
202
195
|
function defaultErrorView(error: DynamicError): React.ReactElement {
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { useSyncExternalStore } from "react";
|
|
2
|
+
import type { RegistryModuleSubscription } from "../registryModule.js";
|
|
3
|
+
|
|
4
|
+
/** React hook factory — re-renders when a registry data module (e.g. Colors) is pushed. */
|
|
5
|
+
export function createUseRegistryModule<T extends Record<string, unknown>>(
|
|
6
|
+
subscription: RegistryModuleSubscription<T>,
|
|
7
|
+
): () => T {
|
|
8
|
+
return function useRegistryModule() {
|
|
9
|
+
return useSyncExternalStore(
|
|
10
|
+
subscription.subscribe,
|
|
11
|
+
subscription.getSnapshot,
|
|
12
|
+
subscription.getSnapshot,
|
|
13
|
+
);
|
|
14
|
+
};
|
|
15
|
+
}
|
package/src/registry.ts
CHANGED
|
@@ -23,16 +23,38 @@ export class Registry {
|
|
|
23
23
|
private listeners = new Map<string, Set<RegistryListener>>();
|
|
24
24
|
private anyListeners = new Set<RegistryListener>();
|
|
25
25
|
private inflight = new Map<string, Promise<RegistryEntry>>();
|
|
26
|
+
/** WS push cache — modules are ingested only when ensure() or a subscriber asks. */
|
|
27
|
+
private moduleCache = new Map<string, import("./types.js").CompiledModule>();
|
|
28
|
+
private watchReleases = new Map<string, () => void>();
|
|
26
29
|
|
|
27
30
|
constructor(
|
|
28
31
|
private readonly source: Source,
|
|
29
32
|
private scope: Scope,
|
|
30
33
|
) {
|
|
31
34
|
this.source.subscribe(({ module }) => {
|
|
32
|
-
|
|
35
|
+
if (module.removed) {
|
|
36
|
+
this.moduleCache.delete(module.name);
|
|
37
|
+
if (this.entries.has(module.name) || (this.listeners.get(module.name)?.size ?? 0) > 0) {
|
|
38
|
+
void this.ingestAsync(module.name, module);
|
|
39
|
+
}
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
this.moduleCache.set(module.name, module);
|
|
43
|
+
if (this.shouldIngestOnPush(module.name)) {
|
|
44
|
+
void this.ingestAsync(module.name, module);
|
|
45
|
+
}
|
|
33
46
|
});
|
|
34
47
|
}
|
|
35
48
|
|
|
49
|
+
/** Re-ingest a WS push when the component is already loaded or has active subscribers. */
|
|
50
|
+
private shouldIngestOnPush(name: string): boolean {
|
|
51
|
+
return (
|
|
52
|
+
this.entries.has(name) ||
|
|
53
|
+
(this.listeners.get(name)?.size ?? 0) > 0 ||
|
|
54
|
+
this.inflight.has(name)
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
36
58
|
/** Replace or extend the current scope (rare; typically set once). */
|
|
37
59
|
setScope(scope: Scope): void {
|
|
38
60
|
this.scope = scope;
|
|
@@ -61,8 +83,8 @@ export class Registry {
|
|
|
61
83
|
if (existing) return existing;
|
|
62
84
|
const pending = this.inflight.get(name);
|
|
63
85
|
if (pending) return pending;
|
|
64
|
-
const
|
|
65
|
-
|
|
86
|
+
const cached = this.moduleCache.get(name);
|
|
87
|
+
const p = (cached ? Promise.resolve(cached) : this.source.fetch(name))
|
|
66
88
|
.then((module) => this.ingestAsync(name, module))
|
|
67
89
|
.finally(() => {
|
|
68
90
|
this.inflight.delete(name);
|
|
@@ -78,10 +100,23 @@ export class Registry {
|
|
|
78
100
|
set = new Set();
|
|
79
101
|
this.listeners.set(name, set);
|
|
80
102
|
}
|
|
103
|
+
const wasEmpty = set.size === 0;
|
|
81
104
|
set.add(listener);
|
|
105
|
+
if (wasEmpty && this.source.watch) {
|
|
106
|
+
this.watchReleases.set(name, this.source.watch(name));
|
|
107
|
+
}
|
|
108
|
+
const cached = this.moduleCache.get(name);
|
|
109
|
+
if (cached && !this.entries.has(name) && !this.inflight.has(name)) {
|
|
110
|
+
void this.ingestAsync(name, cached);
|
|
111
|
+
}
|
|
82
112
|
return () => {
|
|
83
113
|
set!.delete(listener);
|
|
84
|
-
if (set!.size === 0)
|
|
114
|
+
if (set!.size === 0) {
|
|
115
|
+
this.listeners.delete(name);
|
|
116
|
+
const release = this.watchReleases.get(name);
|
|
117
|
+
release?.();
|
|
118
|
+
this.watchReleases.delete(name);
|
|
119
|
+
}
|
|
85
120
|
};
|
|
86
121
|
}
|
|
87
122
|
|
package/src/registryModule.ts
CHANGED
|
@@ -43,6 +43,7 @@ export function createRegistryModuleSubscription<T extends Record<string, unknow
|
|
|
43
43
|
): RegistryModuleSubscription<T> {
|
|
44
44
|
let snapshot = { ...defaults } as T;
|
|
45
45
|
const listeners = new Set<() => void>();
|
|
46
|
+
let watchRelease: (() => void) | undefined;
|
|
46
47
|
|
|
47
48
|
const notify = () => {
|
|
48
49
|
for (const listener of listeners) listener();
|
|
@@ -83,12 +84,20 @@ export function createRegistryModuleSubscription<T extends Record<string, unknow
|
|
|
83
84
|
}
|
|
84
85
|
});
|
|
85
86
|
|
|
86
|
-
void reload();
|
|
87
|
-
|
|
88
87
|
return {
|
|
89
88
|
subscribe(listener) {
|
|
90
89
|
listeners.add(listener);
|
|
91
|
-
|
|
90
|
+
if (listeners.size === 1) {
|
|
91
|
+
if (source.watch) watchRelease = source.watch(name);
|
|
92
|
+
void reload();
|
|
93
|
+
}
|
|
94
|
+
return () => {
|
|
95
|
+
listeners.delete(listener);
|
|
96
|
+
if (listeners.size === 0) {
|
|
97
|
+
watchRelease?.();
|
|
98
|
+
watchRelease = undefined;
|
|
99
|
+
}
|
|
100
|
+
};
|
|
92
101
|
},
|
|
93
102
|
getSnapshot: () => snapshot,
|
|
94
103
|
proxy,
|
package/src/relativeRequires.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { Diagnostic } from "./types.js";
|
|
2
|
+
|
|
1
3
|
/** Map a relative require specifier to the flat registry component name (basename). */
|
|
2
4
|
export function resolveRelativeComponentName(specifier: string): string | null {
|
|
3
5
|
if (!specifier.startsWith("./") && !specifier.startsWith("../") && !specifier.startsWith("/")) {
|
|
@@ -37,3 +39,49 @@ export function collectRelativeComponentDeps(code: string, componentName?: strin
|
|
|
37
39
|
}
|
|
38
40
|
return [...deps];
|
|
39
41
|
}
|
|
42
|
+
|
|
43
|
+
export interface RelativeImportValidation {
|
|
44
|
+
ok: boolean;
|
|
45
|
+
message?: string;
|
|
46
|
+
diagnostics?: Diagnostic[];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Reject relative imports that resolve to registry names not in the manifest.
|
|
51
|
+
* Local utility files should be bundled at compile time; any remaining relative
|
|
52
|
+
* require() must target another registered component.
|
|
53
|
+
*/
|
|
54
|
+
export function validateRelativeImports(
|
|
55
|
+
code: string,
|
|
56
|
+
registered: ReadonlySet<string>,
|
|
57
|
+
componentName?: string,
|
|
58
|
+
): RelativeImportValidation {
|
|
59
|
+
const unresolved: string[] = [];
|
|
60
|
+
for (const specifier of extractRelativeRequires(code)) {
|
|
61
|
+
const base = resolveRelativeComponentName(specifier);
|
|
62
|
+
if (!base) continue;
|
|
63
|
+
if (base === componentName) continue;
|
|
64
|
+
if (!registered.has(base)) unresolved.push(specifier);
|
|
65
|
+
}
|
|
66
|
+
if (unresolved.length === 0) return { ok: true };
|
|
67
|
+
|
|
68
|
+
const lines = unresolved.map(
|
|
69
|
+
(spec) =>
|
|
70
|
+
` ${spec} → registry component '${resolveRelativeComponentName(spec)}' is not registered`,
|
|
71
|
+
);
|
|
72
|
+
const message =
|
|
73
|
+
`relative import(s) must target a registered component or a local file bundled into this module:\n` +
|
|
74
|
+
`${lines.join("\n")}\n` +
|
|
75
|
+
`Push the dependency as its own component, move helpers into this file, ` +
|
|
76
|
+
`import from host scope (e.g. @newscast/utils-app-ui), or colocate as ./sibling.ts (auto-bundled).`;
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
ok: false,
|
|
80
|
+
message,
|
|
81
|
+
diagnostics: unresolved.map((spec) => ({
|
|
82
|
+
severity: "error" as const,
|
|
83
|
+
message: `unregistered relative import '${spec}'`,
|
|
84
|
+
code: "RELATIVE_IMPORT",
|
|
85
|
+
})),
|
|
86
|
+
};
|
|
87
|
+
}
|