@ecopages/ecopages-jsx 0.2.1-beta.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -15,15 +15,10 @@ bun add @ecopages/ecopages-jsx @ecopages/jsx @ecopages/radiant
15
15
  Register the `ecopagesJsxPlugin` in your `eco.config.ts`.
16
16
 
17
17
  ```ts
18
- import { ConfigBuilder } from '@ecopages/core/config-builder';
18
+ import { defineConfig } from '@ecopages/core/config';
19
19
  import { ecopagesJsxPlugin } from '@ecopages/ecopages-jsx';
20
20
 
21
- const config = await new ConfigBuilder()
22
- .setBaseUrl(import.meta.env.ECOPAGES_BASE_URL)
23
- .setIntegrations([ecopagesJsxPlugin()])
24
- .build();
25
-
26
- export default config;
21
+ export default defineConfig({ integrations: [ecopagesJsxPlugin()] });
27
22
  ```
28
23
 
29
24
  ## What This Integration Owns
@@ -42,16 +37,27 @@ ecopagesJsxPlugin({
42
37
  });
43
38
  ```
44
39
 
40
+ ## Development HMR
41
+
42
+ In development, editing a registered Ecopages JSX page refreshes the current page through the navigation runtime. The shared Layout stays mounted where the router can preserve it; elements marked with `data-eco-persist` retain their DOM state. Declared client scripts continue to use script HMR.
43
+
45
44
  ## Radiant Support
46
45
 
47
46
  Radiant support is enabled by default. When `radiant: true`, the plugin keeps the ownership split explicit:
48
47
 
49
48
  - Ecopages JSX owns page-level JSX SSR and container hydration.
50
49
  - Radiant SSR is activated on the server through `@ecopages/radiant/server/render-component`.
50
+ - The server reuses Radiant's complete light-DOM surface, replacing partial globals before Radiant elements are imported.
51
51
  - Radiant host hydration is activated on the client through an explicit head bootstrap that imports `@ecopages/radiant/client/install-hydrator` before intrinsic custom-element modules load.
52
52
 
53
53
  That means server-rendered `RadiantElement` hosts hydrate in place only when both the SSR markers and the explicit client hydrator are present. Without the client hydrator, Radiant intentionally falls back to a fresh client render on first connect.
54
54
 
55
+ ### Hydration Semantics: RadiantElement vs. RadiantController
56
+
57
+ - **`RadiantElement` (DOM Preservation)**: Custom elements extending `RadiantElement` support true in-place hydration. When `@ecopages/radiant/client/install-hydrator` executes before elements connect, existing light-DOM SSR nodes are preserved, retaining DOM node identity, form input state, active focus, and media playback.
58
+ - **`RadiantController` (Activation / Template Re-mount)**: Controllers attached via `[data-controller]` activate when `startControllers(document)` is called. Activation executes the controller's `render()` method and mounts its template fresh into the host container, replacing server-rendered child nodes. Reactive properties and signal bindings remain fully interactive, but individual child DOM nodes are reconstructed.
59
+ - **Guidance**: Use `RadiantElement` when in-place preservation of SSR nodes (e.g., focused inputs, media elements, or CSS transitions) is required. Use `RadiantController` for Stimulus-style progressive enhancement on arbitrary markup where child DOM reconstruction is acceptable.
60
+
55
61
  ```ts
56
62
  ecopagesJsxPlugin({
57
63
  radiant: true,
@@ -71,26 +77,40 @@ new MyElement().renderHostToString({ mode: 'hydrate' });
71
77
 
72
78
  The legacy `hydrate: true` option remains compatible, but `mode` is the current SSR contract. Registered intrinsic tags that contain a dash are SSR candidates, and framework-specific host rendering should be adapted through the server custom-element render hook instead of hardcoding framework branches into JSX page code.
73
79
 
80
+ The integration loads Radiant host serialization through the public `@ecopages/radiant/server/radiant-element-ssr` export after installing the light-DOM shim. Applications do not need a separate Radiant SSR side-effect import.
81
+
82
+ ### Registered Radiant scripts (`ssr: true`)
83
+
84
+ Declare custom-element registration once in `dependencies.scripts`. Ecopages JSX preloads entries marked `ssr: true` on the server before render so hosts can SSR without a redundant `import './my-element.script.ts'` in the component file. Preload deduplication and invalidation state belongs to the app's `AppRuntime`, so separate apps keep separate preload history.
85
+
86
+ ```ts
87
+ export const ThemeToggle = eco.component({
88
+ dependencies: {
89
+ scripts: [{ src: './theme-toggle.script.ts', ssr: true, lazy: { 'on:idle': true } }],
90
+ },
91
+ render: () => <theme-toggle />,
92
+ });
93
+ ```
94
+
95
+ Keep `import type` when you only need props from the script module. Do not add `ssr: true` to browser-only scripts that touch `window` or `document` without guards.
96
+
74
97
  ## MDX Support
75
98
 
76
- Enable MDX to treat `.mdx` files as JSX routes compiled against the `@ecopages/jsx` runtime.
99
+ Add an `mdx` object to treat `.mdx` files as JSX routes compiled against the `@ecopages/jsx` runtime. Omit the property to disable MDX. Put `remarkPlugins`, `rehypePlugins`, and `recmaPlugins` next to `compilerOptions`, not inside it.
77
100
 
78
101
  ```ts
79
- import { ConfigBuilder } from '@ecopages/core/config-builder';
102
+ import { defineConfig } from '@ecopages/core/config';
80
103
  import { ecopagesJsxPlugin } from '@ecopages/ecopages-jsx';
81
104
 
82
- const config = await new ConfigBuilder()
83
- .setIntegrations([
105
+ export default defineConfig({
106
+ integrations: [
84
107
  ecopagesJsxPlugin({
85
108
  mdx: {
86
- enabled: true,
87
109
  extensions: ['.mdx', '.md'],
88
110
  },
89
111
  }),
90
- ])
91
- .build();
92
-
93
- export default config;
112
+ ],
113
+ });
94
114
  ```
95
115
 
96
116
  ## Mixed Rendering
@@ -99,6 +119,9 @@ Ecopages JSX can own the outer page shell or just a nested foreign subtree. When
99
119
 
100
120
  Important:
101
121
 
122
+ - Cross-integration shell stacks should use `EcoEmbed` from `@ecopages/ecopages-jsx/eco-embed` (or the matching integration adapter). Plain opaque objects fail fast at core foreign-subtree queue boundaries; they are not coerced with `String(object)`. Pass already-serialized HTML when `EcoEmbed` is not needed.
102
123
  - Components that may render foreign children must declare those children in `config.dependencies.components`.
103
124
  - Ecopages validates mixed-renderer ownership from declared dependencies during render preparation. It does not treat rendered HTML alone as the source of truth.
125
+ - Route-level dependency resolvers can supply Components at request time; Ecopages JSX includes those resolved roots when deciding whether a Foreign Subtree needs handoff.
104
126
  - Ecopages JSX keeps raw-markup preservation and asset collection inside the JSX renderer.
127
+ - JSX renderers use the required core `AssetProcessingService` to process declared component dependencies.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ecopages/ecopages-jsx",
3
- "version": "0.2.1-beta.0",
3
+ "version": "0.3.0",
4
4
  "description": "JSX integration plugin for Ecopages",
5
5
  "keywords": [
6
6
  "ecopages",
@@ -11,6 +11,9 @@
11
11
  "main": "./src/ecopages-jsx.plugin.js",
12
12
  "types": "./src/ecopages-jsx.plugin.d.ts",
13
13
  "type": "module",
14
+ "publishConfig": {
15
+ "access": "public"
16
+ },
14
17
  "exports": {
15
18
  ".": {
16
19
  "default": "./src/ecopages-jsx.plugin.js",
@@ -27,16 +30,17 @@
27
30
  },
28
31
  "repository": {
29
32
  "type": "git",
30
- "url": "https://github.com/ecopages/ecopages.git",
33
+ "url": "git+https://github.com/ecopages/ecopages.git",
31
34
  "directory": "packages/integrations/ecopages-jsx"
32
35
  },
33
36
  "dependencies": {
37
+ "@ecopages/mdx": "0.3.0",
34
38
  "@mdx-js/mdx": "^3.1.1",
35
39
  "vfile": "^6.0.3"
36
40
  },
37
41
  "peerDependencies": {
38
- "@ecopages/core": "0.2.1-beta.0",
39
- "@ecopages/jsx": "0.3.0-alpha.25",
40
- "@ecopages/radiant": "0.3.0-alpha.25"
42
+ "@ecopages/core": "0.3.0",
43
+ "@ecopages/jsx": "^0.4.0",
44
+ "@ecopages/radiant": "^0.4.0"
41
45
  }
42
- }
46
+ }
@@ -0,0 +1,17 @@
1
+ import type { EcoBuildPlugin } from '@ecopages/core/integration';
2
+ import type { DevTransformBundleContributor } from '@ecopages/core/integration';
3
+ import type { EcopagesJsxHmrStrategy } from '../ecopages-jsx-hmr-strategy.js';
4
+ export type EcopagesJsxDevTransformContributorOptions = {
5
+ strategy: EcopagesJsxHmrStrategy;
6
+ getMdxLoaderPlugin: () => EcoBuildPlugin | undefined;
7
+ };
8
+ /**
9
+ * Supplies Ecopages JSX module plugins for the core dev transform bundler.
10
+ */
11
+ export declare class EcopagesJsxDevTransformContributor implements DevTransformBundleContributor {
12
+ private readonly strategy;
13
+ private readonly getMdxLoaderPlugin;
14
+ constructor(options: EcopagesJsxDevTransformContributorOptions);
15
+ ownsModule(sourcePath: string): boolean;
16
+ getModulePlugins(sourcePath: string): Promise<readonly EcoBuildPlugin[]>;
17
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Supplies Ecopages JSX module plugins for the core dev transform bundler.
3
+ */
4
+ export class EcopagesJsxDevTransformContributor {
5
+ strategy;
6
+ getMdxLoaderPlugin;
7
+ constructor(options) {
8
+ this.strategy = options.strategy;
9
+ this.getMdxLoaderPlugin = options.getMdxLoaderPlugin;
10
+ }
11
+ ownsModule(sourcePath) {
12
+ return this.strategy.ownsDevTransformEntrypoint(sourcePath);
13
+ }
14
+ async getModulePlugins(sourcePath) {
15
+ if (!sourcePath.endsWith('.mdx')) {
16
+ return [];
17
+ }
18
+ const mdxLoader = this.getMdxLoaderPlugin();
19
+ return mdxLoader ? [mdxLoader] : [];
20
+ }
21
+ }
package/src/eco-embed.js CHANGED
@@ -1,11 +1,9 @@
1
- import { eco } from "@ecopages/core";
2
- function EcoEmbed({
3
- component,
4
- props,
5
- children
6
- }) {
7
- return eco.embed(component, props, children);
1
+ /** @jsxImportSource @ecopages/jsx */
2
+ import { eco } from '@ecopages/core';
3
+ /**
4
+ * Renders a foreign or same-integration eco component from an Ecopages JSX
5
+ * file without forcing inline mixed-JSX authoring.
6
+ */
7
+ export function EcoEmbed({ component, props, children, }) {
8
+ return eco.embed(component, props, children);
8
9
  }
9
- export {
10
- EcoEmbed
11
- };
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Tracks the source files that produced the most recent JSX render.
3
+ *
4
+ * @remarks
5
+ * The JSX renderer runs each page, component, or view render inside
6
+ * {@link withEjsxHmrOwnershipScope} and records its components with
7
+ * {@link recordEjsxHmrOwnership}. `EcopagesJsxHmrStrategy` reads the result to
8
+ * decide whether a changed file affects SSR HTML without walking the component
9
+ * tree on every watcher event.
10
+ *
11
+ * The state is module-scoped because the renderer and the HMR strategy share no
12
+ * other lifecycle anchor. Publishing replaces it only when the file set changes,
13
+ * so repeated renders of the same page keep the existing state.
14
+ *
15
+ * Only the latest top-level render is kept. With several pages open, a file
16
+ * used only by a page that rendered earlier is not matched until that page
17
+ * renders again.
18
+ */
19
+ import type { EcoComponent } from '@ecopages/core';
20
+ export type EjsxHmrOwnershipState = {
21
+ fileOwners: ReadonlySet<string>;
22
+ hash: string;
23
+ };
24
+ /**
25
+ * Returns the most recently recorded active render tree.
26
+ */
27
+ export declare function getEjsxHmrOwnership(): EjsxHmrOwnershipState;
28
+ /**
29
+ * Runs one render and publishes the files it recorded once the outermost scope ends.
30
+ *
31
+ * @remarks
32
+ * Nested renders, such as components and foreign subtrees resolved inside a
33
+ * page, join the outer scope, so the published set covers the whole render tree
34
+ * instead of the last nested render.
35
+ */
36
+ export declare function withEjsxHmrOwnershipScope<T>(render: () => T | Promise<T>): Promise<T>;
37
+ /**
38
+ * Adds the source files of `components`, their declared dependencies and their
39
+ * layouts to the active render scope.
40
+ *
41
+ * @throws Error when called outside {@link withEjsxHmrOwnershipScope}.
42
+ */
43
+ export declare function recordEjsxHmrOwnership(components: ReadonlyArray<EcoComponent | undefined>): void;
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Tracks the source files that produced the most recent JSX render.
3
+ *
4
+ * @remarks
5
+ * The JSX renderer runs each page, component, or view render inside
6
+ * {@link withEjsxHmrOwnershipScope} and records its components with
7
+ * {@link recordEjsxHmrOwnership}. `EcopagesJsxHmrStrategy` reads the result to
8
+ * decide whether a changed file affects SSR HTML without walking the component
9
+ * tree on every watcher event.
10
+ *
11
+ * The state is module-scoped because the renderer and the HMR strategy share no
12
+ * other lifecycle anchor. Publishing replaces it only when the file set changes,
13
+ * so repeated renders of the same page keep the existing state.
14
+ *
15
+ * Only the latest top-level render is kept. With several pages open, a file
16
+ * used only by a page that rendered earlier is not matched until that page
17
+ * renders again.
18
+ */
19
+ import { AsyncLocalStorage } from 'node:async_hooks';
20
+ import { contentHash } from '@ecopages/core/build';
21
+ import { collectComponentConfigFilePaths } from '@ecopages/core/integration';
22
+ const EMPTY_STATE = {
23
+ fileOwners: new Set(),
24
+ hash: '',
25
+ };
26
+ let currentState = EMPTY_STATE;
27
+ const pendingFileOwners = new AsyncLocalStorage();
28
+ /**
29
+ * Returns the most recently recorded active render tree.
30
+ */
31
+ export function getEjsxHmrOwnership() {
32
+ return currentState;
33
+ }
34
+ /**
35
+ * Runs one render and publishes the files it recorded once the outermost scope ends.
36
+ *
37
+ * @remarks
38
+ * Nested renders, such as components and foreign subtrees resolved inside a
39
+ * page, join the outer scope, so the published set covers the whole render tree
40
+ * instead of the last nested render.
41
+ */
42
+ export async function withEjsxHmrOwnershipScope(render) {
43
+ if (pendingFileOwners.getStore()) {
44
+ return await render();
45
+ }
46
+ const fileOwners = new Set();
47
+ try {
48
+ return await pendingFileOwners.run(fileOwners, render);
49
+ }
50
+ finally {
51
+ if (fileOwners.size > 0) {
52
+ publishEjsxHmrOwnership(fileOwners);
53
+ }
54
+ }
55
+ }
56
+ /**
57
+ * Adds the source files of `components`, their declared dependencies and their
58
+ * layouts to the active render scope.
59
+ *
60
+ * @throws Error when called outside {@link withEjsxHmrOwnershipScope}.
61
+ */
62
+ export function recordEjsxHmrOwnership(components) {
63
+ const fileOwners = pendingFileOwners.getStore();
64
+ if (!fileOwners) {
65
+ throw new Error('Ecopages JSX HMR ownership can only be recorded inside an active render scope.');
66
+ }
67
+ for (const file of collectFileOwners(components)) {
68
+ fileOwners.add(file);
69
+ }
70
+ }
71
+ function publishEjsxHmrOwnership(files) {
72
+ const hash = hashFileSet(files);
73
+ if (hash === currentState.hash) {
74
+ return;
75
+ }
76
+ currentState = {
77
+ fileOwners: new Set(files),
78
+ hash,
79
+ };
80
+ }
81
+ function collectFileOwners(components) {
82
+ return collectComponentConfigFilePaths(components, { includeLayouts: true });
83
+ }
84
+ function hashFileSet(files) {
85
+ if (files.size === 0) {
86
+ return '';
87
+ }
88
+ const sorted = Array.from(files).sort();
89
+ const joined = sorted.join('\n');
90
+ return BigInt(`0x${contentHash(joined, 16)}`).toString(36);
91
+ }
@@ -0,0 +1,19 @@
1
+ import { HmrStrategy, type HmrAction } from '@ecopages/core/integration';
2
+ import type { HmrRegisteredEntrypointsContext } from '@ecopages/core/integration';
3
+ export type EcopagesJsxHmrStrategyContext = HmrRegisteredEntrypointsContext & {
4
+ getSrcDir(): string;
5
+ getPagesDir(): string;
6
+ getLayoutsDir(): string;
7
+ getIncludesDir(): string;
8
+ getTemplateExtensions(): ReadonlyArray<string>;
9
+ };
10
+ export declare class EcopagesJsxHmrStrategy extends HmrStrategy {
11
+ readonly type: 100;
12
+ private readonly context;
13
+ constructor(context: EcopagesJsxHmrStrategyContext);
14
+ matches(filePath: string): boolean;
15
+ ownsDevTransformEntrypoint(filePath: string): boolean;
16
+ process(filePath: string): Promise<HmrAction>;
17
+ private isPageOrLayoutSource;
18
+ private hasJsxTemplateExtension;
19
+ }
@@ -0,0 +1,67 @@
1
+ import path from 'node:path';
2
+ import { HmrStrategy, HmrStrategyType } from '@ecopages/core/integration';
3
+ import { isRegisteredDevTransformEntrypoint, isRegisteredScriptEntrypoint } from '@ecopages/core/integration';
4
+ import { getEjsxHmrOwnership } from './ecopages-jsx-hmr-ownership.js';
5
+ export class EcopagesJsxHmrStrategy extends HmrStrategy {
6
+ type = HmrStrategyType.INTEGRATION;
7
+ context;
8
+ constructor(context) {
9
+ super();
10
+ this.context = context;
11
+ }
12
+ matches(filePath) {
13
+ const resolvedPath = path.resolve(filePath);
14
+ if (isRegisteredScriptEntrypoint(this.context.getRegisteredEntrypoints(), resolvedPath)) {
15
+ return false;
16
+ }
17
+ const srcDir = path.resolve(this.context.getSrcDir());
18
+ if (!resolvedPath.startsWith(`${srcDir}${path.sep}`) && resolvedPath !== srcDir) {
19
+ return false;
20
+ }
21
+ const includesDir = path.resolve(this.context.getIncludesDir());
22
+ if (includesDir && (resolvedPath === includesDir || resolvedPath.startsWith(`${includesDir}${path.sep}`))) {
23
+ return false;
24
+ }
25
+ if (this.isPageOrLayoutSource(resolvedPath)) {
26
+ return this.hasJsxTemplateExtension(resolvedPath);
27
+ }
28
+ const ownership = getEjsxHmrOwnership();
29
+ if (ownership.fileOwners.has(resolvedPath)) {
30
+ return true;
31
+ }
32
+ return false;
33
+ }
34
+ ownsDevTransformEntrypoint(filePath) {
35
+ const resolvedPath = path.resolve(filePath);
36
+ if (!isRegisteredDevTransformEntrypoint(this.context.getRegisteredEntrypoints(), resolvedPath)) {
37
+ return false;
38
+ }
39
+ return this.isPageOrLayoutSource(resolvedPath) && this.hasJsxTemplateExtension(resolvedPath);
40
+ }
41
+ async process(filePath) {
42
+ return {
43
+ type: 'broadcast',
44
+ events: [
45
+ {
46
+ type: 'layout-update',
47
+ path: filePath,
48
+ timestamp: Date.now(),
49
+ },
50
+ ],
51
+ };
52
+ }
53
+ isPageOrLayoutSource(resolvedPath) {
54
+ const pagesDir = path.resolve(this.context.getPagesDir());
55
+ const layoutsDir = path.resolve(this.context.getLayoutsDir());
56
+ if (pagesDir && (resolvedPath === pagesDir || resolvedPath.startsWith(`${pagesDir}${path.sep}`))) {
57
+ return true;
58
+ }
59
+ if (layoutsDir && (resolvedPath === layoutsDir || resolvedPath.startsWith(`${layoutsDir}${path.sep}`))) {
60
+ return true;
61
+ }
62
+ return false;
63
+ }
64
+ hasJsxTemplateExtension(resolvedPath) {
65
+ return this.context.getTemplateExtensions().some((extension) => resolvedPath.endsWith(extension));
66
+ }
67
+ }
@@ -1,21 +1,32 @@
1
1
  import type { CompileOptions } from '@mdx-js/mdx';
2
2
  import type { EcoComponent, EcoComponentConfig, EcoFunctionComponent, EcoPageFile, GetMetadata } from '@ecopages/core';
3
- import type { EcoBuildPlugin } from '@ecopages/core/plugins/integration-plugin';
3
+ import type { EcoBuildPlugin } from '@ecopages/core/integration';
4
+ import { appendMdxExtensions, createMdxExtensionFilter } from '@ecopages/mdx/core';
4
5
  import type { JsxRenderable } from '@ecopages/jsx';
5
- import type { EcopagesJsxMdxCompileOptions, EcopagesJsxMdxOptions } from './ecopages-jsx.types.js';
6
- export type ResolvedMdxCompileOptions = EcopagesJsxMdxCompileOptions & Pick<CompileOptions, 'jsxImportSource' | 'jsxRuntime'>;
6
+ import type { EcopagesJsxMdxOptions } from './ecopages-jsx.types.js';
7
+ import type { RequestPipelineMetricsState } from '@ecopages/core/build';
8
+ export type ResolvedMdxCompileOptions = CompileOptions & {
9
+ jsxImportSource: '@ecopages/jsx';
10
+ };
7
11
  export type AsyncEcoComponent<P = Record<string, unknown>, R = JsxRenderable> = EcoFunctionComponent<P, R | Promise<R>>;
8
- export type EcopagesJsxMdxPageModule = EcoPageFile<{
12
+ type AsyncEcoPageComponent = AsyncEcoComponent<Record<string, unknown>> & {
13
+ metadata?: GetMetadata;
14
+ };
15
+ export type EcopagesJsxMdxPageModule = Omit<EcoPageFile<{
9
16
  config?: EcoComponentConfig;
10
17
  layout?: EcoComponent;
11
18
  getMetadata?: GetMetadata;
12
- }>;
13
- export declare const createMdxExtensionFilter: (extensions: string[], options?: {
14
- allowQueryString?: boolean;
15
- }) => RegExp;
16
- export declare const appendMdxExtensions: (target: string[], mdxExtensions: string[]) => void;
19
+ }>, 'default'> & {
20
+ default: AsyncEcoPageComponent;
21
+ };
22
+ export { appendMdxExtensions, createMdxExtensionFilter };
17
23
  export declare const resolveMdxCompilerOptions: (mdxOptions: EcopagesJsxMdxOptions) => ResolvedMdxCompileOptions;
18
- export declare const createMdxLoaderPlugin: (compilerOptions: ResolvedMdxCompileOptions, extensions: string[]) => EcoBuildPlugin;
19
- export declare const registerBunMdxPlugin: (compilerOptions: ResolvedMdxCompileOptions, extensions: string[]) => Promise<void>;
24
+ export interface CreateEcopagesJsxMdxLoaderPluginOptions {
25
+ compilerOptions: ResolvedMdxCompileOptions;
26
+ extensions: string[];
27
+ projectRoot: string;
28
+ requestPipelineMetrics: RequestPipelineMetricsState;
29
+ }
30
+ export declare const createMdxLoaderPlugin: (options: CreateEcopagesJsxMdxLoaderPluginOptions) => EcoBuildPlugin;
20
31
  export declare const isMdxFile: (filePath: string, extensions: string[]) => boolean;
21
32
  export declare const normalizeMdxPageModule: (file: string, module: EcopagesJsxMdxPageModule) => EcopagesJsxMdxPageModule;
@@ -1,113 +1,48 @@
1
- import { readFile } from "node:fs/promises";
2
- import path from "node:path";
3
- import { rapidhash } from "@ecopages/core/hash";
4
- import { VFile } from "vfile";
5
- import { ECOPAGES_JSX_PLUGIN_NAME } from "./ecopages-jsx.constants.js";
6
- const escapeRegex = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
7
- const mergePluginLists = (...lists) => {
8
- const merged = lists.flatMap((list) => list ? [...list] : []);
9
- return merged.length > 0 ? merged : void 0;
10
- };
11
- const createMdxExtensionFilter = (extensions, options) => {
12
- const escaped = extensions.map(escapeRegex);
13
- const suffix = options?.allowQueryString ? "(\\?.*)?$" : "$";
14
- return new RegExp(`(${escaped.join("|")})${suffix}`);
15
- };
16
- const appendMdxExtensions = (target, mdxExtensions) => {
17
- for (const ext of mdxExtensions) {
18
- if (!target.includes(ext)) {
19
- target.push(ext);
1
+ import { ensurePageConfigLayouts } from '@ecopages/core/integration';
2
+ import { appendMdxExtensions, createMdxExtensionFilter, createMdxLoaderPlugin as createMdxLoaderPluginCore, resolveMdxCompilerOptions as resolveMdxCompilerOptionsCore, } from '@ecopages/mdx/core';
3
+ import { ECOPAGES_JSX_PLUGIN_NAME } from './ecopages-jsx.constants.js';
4
+ export { appendMdxExtensions, createMdxExtensionFilter };
5
+ export const resolveMdxCompilerOptions = (mdxOptions) => resolveMdxCompilerOptionsCore(mdxOptions, {
6
+ jsxImportSource: '@ecopages/jsx',
7
+ defaults: {
8
+ format: 'detect',
9
+ outputFormat: 'program',
10
+ },
11
+ });
12
+ export const createMdxLoaderPlugin = (options) => createMdxLoaderPluginCore({
13
+ name: 'ecopages-jsx-mdx-loader',
14
+ integrationName: ECOPAGES_JSX_PLUGIN_NAME,
15
+ compilerOptions: options.compilerOptions,
16
+ extensions: options.extensions,
17
+ includeSourceMap: false,
18
+ loader: 'js',
19
+ projectRoot: options.projectRoot,
20
+ requestPipelineMetrics: options.requestPipelineMetrics,
21
+ });
22
+ export const isMdxFile = (filePath, extensions) => {
23
+ return extensions.some((ext) => filePath.endsWith(ext));
24
+ };
25
+ export const normalizeMdxPageModule = (file, module) => {
26
+ if (typeof module.default !== 'function') {
27
+ throw new TypeError('MDX file must export a callable default component.');
20
28
  }
21
- }
22
- };
23
- const resolveMdxCompilerOptions = (mdxOptions) => {
24
- const { compilerOptions, remarkPlugins, rehypePlugins, recmaPlugins } = mdxOptions;
25
- const resolved = {
26
- format: "detect",
27
- outputFormat: "program",
28
- ...compilerOptions,
29
- jsxImportSource: "@ecopages/jsx",
30
- jsxRuntime: "automatic",
31
- development: process.env.NODE_ENV === "development"
32
- };
33
- const mergedRemark = mergePluginLists(compilerOptions?.remarkPlugins, remarkPlugins);
34
- const mergedRehype = mergePluginLists(compilerOptions?.rehypePlugins, rehypePlugins);
35
- const mergedRecma = mergePluginLists(compilerOptions?.recmaPlugins, recmaPlugins);
36
- if (mergedRemark) resolved.remarkPlugins = mergedRemark;
37
- if (mergedRehype) resolved.rehypePlugins = mergedRehype;
38
- if (mergedRecma) resolved.recmaPlugins = mergedRecma;
39
- return resolved;
40
- };
41
- const createMdxLoaderPlugin = (compilerOptions, extensions) => {
42
- const filter = createMdxExtensionFilter(extensions, { allowQueryString: true });
43
- return {
44
- name: "ecopages-jsx-mdx-loader",
45
- setup(build) {
46
- build.onLoad({ filter }, async (args) => {
47
- const { compile } = await import("@mdx-js/mdx");
48
- const filePath = args.path.includes("?") ? args.path.split("?")[0] : args.path;
49
- const source = await readFile(filePath, "utf-8");
50
- const compiled = await compile(new VFile({ value: source, path: filePath }), compilerOptions);
51
- return {
52
- contents: String(compiled.value),
53
- loader: "js",
54
- resolveDir: path.dirname(filePath)
55
- };
56
- });
29
+ const Page = module.default;
30
+ const attributedConfig = module.config ?? Page.config;
31
+ if (!attributedConfig?.identity) {
32
+ throw new Error(`[ecopages] MDX page "${file}" is missing component identity; the MDX loader must attribute it.`);
57
33
  }
58
- };
59
- };
60
- const registerBunMdxPlugin = async (compilerOptions, extensions) => {
61
- if (typeof Bun === "undefined") {
62
- return;
63
- }
64
- const filter = createMdxExtensionFilter(extensions);
65
- Bun.plugin({
66
- name: "ecopages-jsx-mdx",
67
- setup(build) {
68
- build.onLoad({ filter }, async (args) => {
69
- const { compile } = await import("@mdx-js/mdx");
70
- const source = await readFile(args.path, "utf-8");
71
- const compiled = await compile(new VFile({ value: source, path: args.path }), compilerOptions);
72
- return { contents: String(compiled.value), loader: "js" };
73
- });
34
+ if (module.layout && !(attributedConfig.layouts && attributedConfig.layouts.length > 0)) {
35
+ attributedConfig.layout = module.layout;
74
36
  }
75
- });
76
- };
77
- const isMdxFile = (filePath, extensions) => {
78
- return extensions.some((ext) => filePath.endsWith(ext));
79
- };
80
- const normalizeMdxPageModule = (file, module) => {
81
- if (typeof module.default !== "function") {
82
- throw new TypeError("MDX file must export a callable default component.");
83
- }
84
- const Page = module.default;
85
- const normalizedConfig = {
86
- ...module.config ?? Page.config ?? {},
87
- ...module.layout ? { layout: module.layout } : {},
88
- __eco: module.config?.__eco ?? Page.config?.__eco ?? {
89
- id: String(rapidhash(file)),
90
- file,
91
- integration: ECOPAGES_JSX_PLUGIN_NAME
37
+ ensurePageConfigLayouts(attributedConfig);
38
+ const wrappedPage = async (props) => await Page(props);
39
+ wrappedPage.config = attributedConfig;
40
+ if (module.getMetadata ?? Page.metadata) {
41
+ wrappedPage.metadata = module.getMetadata ?? Page.metadata;
92
42
  }
93
- };
94
- const wrappedPage = async (props) => await Page(props);
95
- wrappedPage.config = normalizedConfig;
96
- if (module.getMetadata ?? Page.metadata) {
97
- wrappedPage.metadata = module.getMetadata ?? Page.metadata;
98
- }
99
- return {
100
- ...module,
101
- default: wrappedPage,
102
- config: normalizedConfig
103
- };
104
- };
105
- export {
106
- appendMdxExtensions,
107
- createMdxExtensionFilter,
108
- createMdxLoaderPlugin,
109
- isMdxFile,
110
- normalizeMdxPageModule,
111
- registerBunMdxPlugin,
112
- resolveMdxCompilerOptions
43
+ return {
44
+ ...module,
45
+ default: wrappedPage,
46
+ config: attributedConfig,
47
+ };
113
48
  };