@octanejs/mdx 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Dominic Gannaway
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,129 @@
1
+ # @octanejs/mdx
2
+
3
+ [MDX](https://mdxjs.com) for the [octane](https://github.com/octanejs/octane)
4
+ UI framework — documentation stays in `.mdx`/`.md` and renders as **compiled octane
5
+ components**.
6
+
7
+ The split mirrors `docs/react-library-compat-plan.md` §2: **@mdx-js/mdx's
8
+ compiler is framework-agnostic and reused verbatim** — with `jsx: true` it
9
+ emits the compiled document as classic JSX *source*, which is exactly the
10
+ React-style `.tsx` dialect octane's own compiler handles. The pipeline is
11
+
12
+ ```
13
+ .mdx / .md → @mdx-js/mdx (JSX/ESM source) → octane/compiler → compiled octane module
14
+ ```
15
+
16
+ — compile, don't interpret: no MDX runtime, no `_jsx` shims, the document
17
+ becomes an ordinary octane component module (client codegen or SSR HTML-string
18
+ codegen). Only @mdx-js/react's ~50-line provider layer is ported here.
19
+
20
+ ## Vite
21
+
22
+ ```ts
23
+ // vite.config.ts
24
+ import { defineConfig } from 'vite';
25
+ import { octane } from 'octane/compiler/vite'; // or @octanejs/vite-plugin
26
+ import { octaneMdx } from '@octanejs/mdx/vite';
27
+
28
+ export default defineConfig({
29
+ plugins: [octaneMdx(), octane()],
30
+ });
31
+ ```
32
+
33
+ `octaneMdx()` claims `.mdx`/`.md` and produces final JS, so it composes with the
34
+ octane plugin (which claims `.tsrx`/`.tsx`/`.ts`/`.js`) without ordering
35
+ hazards. SSR target selection matches the octane plugin: per-module
36
+ auto-detection, `ssr: true|false` to force.
37
+
38
+ Options: `md: false` (leave `.md` alone), `providerImportSource: null` (disable
39
+ the provider wiring), `remarkPlugins` / `rehypePlugins` / `recmaPlugins`,
40
+ `format`, `mdxOptions` (escape hatch). The default remark set is
41
+ `defaultRemarkPlugins` = GFM + frontmatter + `export const frontmatter`.
42
+
43
+ ## Usage
44
+
45
+ ```mdx
46
+ ---
47
+ title: Getting started
48
+ ---
49
+
50
+ import Counter from './Counter.tsrx';
51
+
52
+ # {frontmatter.title}
53
+
54
+ Octane components just work: <Counter start={2} />
55
+ ```
56
+
57
+ ```tsrx
58
+ import Doc, { frontmatter } from './getting-started.mdx';
59
+ import { MDXProvider } from '@octanejs/mdx';
60
+
61
+ export function Page() @{
62
+ <MDXProvider components={{ h1: FancyHeading, code: Snippet }}>
63
+ <Doc />
64
+ </MDXProvider>
65
+ }
66
+ ```
67
+
68
+ A mapping can also be passed per-document: `<Doc components={{ h1: FancyHeading }} />`.
69
+ Mapping values are octane components or replacement host tag names (`{ em: 'i' }`);
70
+ the special `wrapper` key is the document layout.
71
+
72
+ ## API (vs @mdx-js/react)
73
+
74
+ - `MDXProvider({ components, disableParentContext, children })` — ported;
75
+ nested providers merge, function-form `components` receives the inherited
76
+ mapping.
77
+ - `useMDXComponents(components?)` — ported, with one deliberate divergence:
78
+ the `useMemo` referential-stability wrapper is dropped so the call is valid
79
+ in BOTH runtimes (octane's client `useMemo` needs a live client render scope;
80
+ SSR passes call this during `renderToString`). Same observable mapping.
81
+ - `@octanejs/mdx/compile` — `compileMdx` / `compileMdxSync` /
82
+ `defaultRemarkPlugins`, the plugin's pipeline as a library (used by the SSR
83
+ tests, usable for static-site tooling).
84
+
85
+ ## SSR + hydration
86
+
87
+ A document compiled with `mode: 'server'` renders through `octane/server`'s
88
+ `renderToString`, and the resulting HTML **hydrates byte-for-byte** into the
89
+ client-compiled module via `hydrateRoot` (embedded `.tsrx` components adopt
90
+ their server DOM and stay interactive).
91
+
92
+ Both mapping routes work on the server:
93
+
94
+ - the `components` **prop** — `renderToString(Doc, { components })`;
95
+ - **`MDXProvider` from `@octanejs/mdx/server`** — the same provider layer
96
+ mirrored onto `octane/server` context (the client and server runtimes have
97
+ disjoint context stores, so each side ships its own provider; server-mode
98
+ documents read `useMDXComponents` from `@octanejs/mdx/server`
99
+ automatically). A document rendered under the server provider hydrates
100
+ byte-for-byte into the client `MDXProvider` with the same mapping.
101
+
102
+ ## Syntax highlighting (Shiki)
103
+
104
+ Highlighting is a rehype concern, so it hooks in through `rehypePlugins` — no
105
+ integration code and nothing bundled (add `@shikijs/rehype` yourself):
106
+
107
+ ```ts
108
+ import { octaneMdx } from '@octanejs/mdx/vite';
109
+ import rehypeShiki from '@shikijs/rehype';
110
+
111
+ octaneMdx({
112
+ rehypePlugins: [[rehypeShiki, { theme: 'github-light' }]],
113
+ });
114
+ ```
115
+
116
+ Shiki's hast output serializes through the same pipeline as any other content:
117
+ highlighted tokens render on the client, serialize identically on the server,
118
+ and hydrate cleanly (see `tests/shiki.test.ts`). Note `@shikijs/rehype` is
119
+ async — it works in the vite plugin and `compileMdx`, not `compileMdxSync`.
120
+
121
+ ## Notes
122
+
123
+ - Markdown-generated elements ride octane's value-position (`createElement`
124
+ descriptor) path — documents are static content, and embedded `.tsrx`
125
+ components keep their full compiled fast path.
126
+ - `.mdx` edits fast-refresh in dev: the pipeline wraps the document's default
127
+ export in octane's runtime `hmr()` and self-accepts, so live mounts re-render
128
+ the new body in place (no page reload; the vite plugin enables this in serve
129
+ mode automatically).
package/package.json ADDED
@@ -0,0 +1,58 @@
1
+ {
2
+ "name": "@octanejs/mdx",
3
+ "version": "0.1.0",
4
+ "license": "MIT",
5
+ "type": "module",
6
+ "description": "MDX for the octane renderer — compiles .mdx/.md through @mdx-js/mdx to JSX source and then through octane/compiler, so documents render as real compiled octane components (templates, not runtime descriptors); ships the @mdx-js/react provider layer (MDXProvider/useMDXComponents) ported onto octane context.",
7
+ "author": {
8
+ "name": "Dominic Gannaway",
9
+ "email": "dg@domgan.com"
10
+ },
11
+ "publishConfig": {
12
+ "access": "public"
13
+ },
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "git+https://github.com/octanejs/octane.git",
17
+ "directory": "packages/mdx"
18
+ },
19
+ "main": "src/index.ts",
20
+ "module": "src/index.ts",
21
+ "types": "src/index.ts",
22
+ "files": [
23
+ "src",
24
+ "types",
25
+ "README.md"
26
+ ],
27
+ "exports": {
28
+ ".": "./src/index.ts",
29
+ "./server": "./src/server.ts",
30
+ "./compile": {
31
+ "types": "./types/compile.d.ts",
32
+ "default": "./src/compile.js"
33
+ },
34
+ "./vite": {
35
+ "types": "./types/vite.d.ts",
36
+ "default": "./src/vite.js"
37
+ }
38
+ },
39
+ "dependencies": {
40
+ "@jridgewell/remapping": "^2.3.5",
41
+ "@mdx-js/mdx": "^3.1.1",
42
+ "remark-frontmatter": "^5.0.0",
43
+ "remark-gfm": "^4.0.1",
44
+ "remark-mdx-frontmatter": "^5.2.0",
45
+ "source-map": "^0.7.6",
46
+ "octane": "0.1.3"
47
+ },
48
+ "devDependencies": {
49
+ "@jridgewell/trace-mapping": "^0.3.31",
50
+ "@shikijs/rehype": "^3.0.0",
51
+ "vite": "^8.0.16",
52
+ "vitest": "^4.1.9",
53
+ "@octanejs/testing-library": "0.1.0"
54
+ },
55
+ "scripts": {
56
+ "test": "vitest run"
57
+ }
58
+ }
package/src/compile.js ADDED
@@ -0,0 +1,281 @@
1
+ /**
2
+ * @octanejs/mdx — the compile pipeline (`…/compile` entry).
3
+ *
4
+ * Strategy (docs/react-library-compat-plan.md §2): @mdx-js/mdx's compiler is
5
+ * framework-agnostic — with `jsx: true` it emits the compiled document as
6
+ * CLASSIC JSX SOURCE instead of framework runtime calls. That emitted program
7
+ * is exactly the React-style `.tsx` dialect octane's compiler already handles,
8
+ * so the pipeline is
9
+ *
10
+ * .mdx/.md → @mdx-js/mdx (JSX/ESM source) → octane/compiler → compiled octane
11
+ *
12
+ * i.e. an MDX document becomes a REAL compiled octane component module (client
13
+ * descriptor/template codegen, or server HTML-string codegen when
14
+ * `mode: 'server'`) — no MDX runtime, no interpretation. `providerImportSource`
15
+ * defaults to `@octanejs/mdx`, wiring `_provideComponents()` in the emitted
16
+ * code to this package's `useMDXComponents` (the octane port of
17
+ * @mdx-js/react's provider).
18
+ *
19
+ * The only adaptation between the two compilers is `recmaOctaneAdapter`, a tiny
20
+ * ESTree pass over MDX's output: MDX's no-layout branch CALLS
21
+ * `_createMdxContent(props)` directly, which bypasses octane's
22
+ * `(props, __s, __extra)` component ABI (the server body would run with
23
+ * `__s === undefined` and lean on scope-recovery). The pass rewrites the bare
24
+ * call to `<_createMdxContent {...props}/>` so both branches mount through the
25
+ * component machinery on client AND server. (The layout branch's
26
+ * `<_createMdxContent {...props}/>` tag needs no help: octane classifies
27
+ * `_`-starting identifier tags as component references, per JSX semantics.)
28
+ *
29
+ * The two former SERVER-mode fixups are gone — their octane gaps are fixed:
30
+ * `ssrComponent` renders a host-tag-STRING comp as a
31
+ * `<!--[--><tag>…</tag><!--]-->` block (the shape the client's componentSlot /
32
+ * de-opt host renderer adopts on hydration), and the server compiler
33
+ * value-lowers a returned fragment through `ssrChild([...])` exactly like the
34
+ * client's descriptor array — so `<_components.h1>` member tags and the
35
+ * document's fragment body take the SAME shape on both sides (hydration-safe).
36
+ *
37
+ * Authored in `.js` (like octane's `compiler/vite.js` and @octanejs/stylex's
38
+ * vite entry) so the `…/vite` plugin — which imports this module — loads when a
39
+ * consuming app's `vite.config.ts` pulls it in through Node's ESM loader.
40
+ */
41
+ import remapping from '@jridgewell/remapping';
42
+ import { compile as mdxCompile, compileSync as mdxCompileSync } from '@mdx-js/mdx';
43
+ import { compile as octaneCompile } from 'octane/compiler';
44
+ import remarkFrontmatter from 'remark-frontmatter';
45
+ import remarkGfm from 'remark-gfm';
46
+ import remarkMdxFrontmatter from 'remark-mdx-frontmatter';
47
+ import { SourceMapGenerator } from 'source-map';
48
+
49
+ /**
50
+ * @typedef {object} CompileMdxResult
51
+ * @property {string} code
52
+ * @property {unknown} map
53
+ */
54
+
55
+ /**
56
+ * @typedef {object} CompileMdxOptions
57
+ * @property {'client' | 'server'} [mode] octane codegen target: `'client'` (DOM) or `'server'` (SSR HTML strings). Default `'client'`.
58
+ * @property {boolean} [hmr] octane compiler HMR wrapping (client only; the vite plugin wires this to serve mode).
59
+ * @property {boolean} [dev] octane compiler dev metadata (client only; same gate as `hmr`).
60
+ * @property {string | null} [providerImportSource]
61
+ * Module the emitted document reads the provider mapping from
62
+ * (`useMDXComponents`). Defaults per mode — `'@octanejs/mdx'` (client) /
63
+ * `'@octanejs/mdx/server'` (server), so each runtime reads ITS OWN context
64
+ * store (they are disjoint; see src/server.ts). Pass `null` to disable the
65
+ * provider wiring entirely (only `props.components` applies).
66
+ * @property {import('@mdx-js/mdx').CompileOptions['remarkPlugins']} [remarkPlugins] remark plugins. Defaults to `defaultRemarkPlugins` (GFM + frontmatter + frontmatter-export).
67
+ * @property {import('@mdx-js/mdx').CompileOptions['rehypePlugins']} [rehypePlugins]
68
+ * @property {import('@mdx-js/mdx').CompileOptions['recmaPlugins']} [recmaPlugins] Extra recma (ESTree) plugins, run before the octane adapter pass.
69
+ * @property {'mdx' | 'md' | 'detect'} [format] Source syntax: `'mdx'`, plain `'md'` (no JSX/ESM/expressions), or `'detect'` by file extension. Default `'detect'`.
70
+ * @property {Omit<import('@mdx-js/mdx').CompileOptions, 'jsx' | 'jsxRuntime' | 'jsxImportSource' | 'outputFormat' | 'providerImportSource' | 'remarkPlugins' | 'rehypePlugins' | 'recmaPlugins' | 'format' | 'SourceMapGenerator'>} [mdxOptions] Escape hatch: other @mdx-js/mdx options. The pipeline owns `jsx`/`outputFormat`/the options above.
71
+ */
72
+
73
+ /**
74
+ * The default remark plugin set: GitHub-flavored markdown, YAML/TOML
75
+ * frontmatter parsing, and the `export const frontmatter = {…}` export.
76
+ * Exported so a custom `remarkPlugins` list can extend rather than replace it.
77
+ *
78
+ * @type {import('@mdx-js/mdx').CompileOptions['remarkPlugins']}
79
+ */
80
+ export const defaultRemarkPlugins = [remarkGfm, remarkFrontmatter, remarkMdxFrontmatter];
81
+
82
+ /**
83
+ * Compile MDX/markdown source to a compiled octane module (async — supports async plugins).
84
+ *
85
+ * @param {string} source
86
+ * @param {string} id
87
+ * @param {CompileMdxOptions} [options]
88
+ * @returns {Promise<CompileMdxResult>}
89
+ */
90
+ export async function compileMdx(source, id, options = {}) {
91
+ const out = await mdxCompile({ value: source, path: id }, buildMdxOptions(id, options));
92
+ return octaneStage(String(out.value), out.map, id, options);
93
+ }
94
+
95
+ /**
96
+ * Synchronous {@link compileMdx} (the default plugin set is fully sync).
97
+ *
98
+ * @param {string} source
99
+ * @param {string} id
100
+ * @param {CompileMdxOptions} [options]
101
+ * @returns {CompileMdxResult}
102
+ */
103
+ export function compileMdxSync(source, id, options = {}) {
104
+ const out = mdxCompileSync({ value: source, path: id }, buildMdxOptions(id, options));
105
+ return octaneStage(String(out.value), out.map, id, options);
106
+ }
107
+
108
+ /**
109
+ * @param {string} id
110
+ * @param {CompileMdxOptions} options
111
+ * @returns {import('@mdx-js/mdx').CompileOptions}
112
+ */
113
+ function buildMdxOptions(id, options) {
114
+ const format =
115
+ options.format && options.format !== 'detect'
116
+ ? options.format
117
+ : id.endsWith('.md')
118
+ ? 'md'
119
+ : 'mdx';
120
+ const provider =
121
+ options.providerImportSource === undefined
122
+ ? options.mode === 'server'
123
+ ? '@octanejs/mdx/server'
124
+ : '@octanejs/mdx'
125
+ : options.providerImportSource;
126
+ return {
127
+ ...options.mdxOptions,
128
+ format,
129
+ // The load-bearing switch: emit JSX SOURCE (no jsx-runtime calls), which
130
+ // octane's compiler lowers to its own codegen.
131
+ jsx: true,
132
+ // Map the intermediate JSX back to the .mdx source — stage one of the
133
+ // chained map octaneStage composes (stage two is octane's own map).
134
+ SourceMapGenerator,
135
+ ...(provider === null ? {} : { providerImportSource: provider }),
136
+ remarkPlugins: options.remarkPlugins ?? defaultRemarkPlugins,
137
+ rehypePlugins: options.rehypePlugins,
138
+ recmaPlugins: [...(options.recmaPlugins ?? []), recmaOctaneAdapter],
139
+ };
140
+ }
141
+
142
+ /**
143
+ * @param {string} jsxSource
144
+ * @param {unknown} mdxMap
145
+ * @param {string} id
146
+ * @param {CompileMdxOptions} options
147
+ * @returns {CompileMdxResult}
148
+ */
149
+ function octaneStage(jsxSource, mdxMap, id, options) {
150
+ const mode = options.mode ?? 'client';
151
+ const out = octaneCompile(jsxSource, id, {
152
+ mode,
153
+ hmr: mode === 'client' && !!options.hmr,
154
+ dev: mode === 'client' && !!options.dev,
155
+ });
156
+ // Two-stage sourcemap: octane's map targets the INTERMEDIATE JSX text;
157
+ // @mdx-js/mdx's map (via SourceMapGenerator) targets the original .mdx.
158
+ // Compose them (most-recent-first) so generated positions trace all the way
159
+ // back to the document. The non-empty guard is defensive: if a compile shape
160
+ // ever yields no overlapping segments, keep octane's intermediate map (its
161
+ // `sourcesContent` is the intermediate JSX — still steppable in devtools,
162
+ // unlike a blank map). The octane SERVER compile emits an empty-mappings map
163
+ // by design (SSR maps are a later octane refinement).
164
+ if (out.map && mdxMap) {
165
+ const chained = remapping([out.map, mdxMap], () => null);
166
+ if (String(chained.mappings).length > 0) out.map = chained;
167
+ }
168
+ // Fast refresh for documents: octane's compiler only auto-wraps EXPORTED
169
+ // `@{}`-form components in `hmr(...)` — `MDXContent` (a passthrough function
170
+ // returning a ternary of descriptors) isn't recognized as one, so the octane
171
+ // `hmr` flag alone leaves `.mdx` edits as full module invalidations. The
172
+ // PIPELINE knows the emitted shape, so it appends the exact registration the
173
+ // octane compiler emits for `.tsrx` exports: wrap the default export in the
174
+ // runtime `hmr()` (identity-stable across edits — parents keep their mounted
175
+ // wrapper) + a self-accepting `import.meta.hot` block that swaps the body
176
+ // and re-renders live blocks in place. Appending after the fact keeps the
177
+ // source map's earlier segments valid (ESM imports hoist).
178
+ if (mode === 'client' && options.hmr && /\bexport default function MDXContent\b/.test(out.code)) {
179
+ out.code +=
180
+ "\nimport { hmr as _$mdxHmr, HMR as _$mdxHMR } from 'octane';\n" +
181
+ 'MDXContent = _$mdxHmr(MDXContent);\n' +
182
+ 'if (import.meta.hot) {\n' +
183
+ ' import.meta.hot.accept((module) => {\n' +
184
+ ' module && MDXContent[_$mdxHMR].update(module.default);\n' +
185
+ ' });\n' +
186
+ '}\n';
187
+ }
188
+ return out;
189
+ }
190
+
191
+ // ─────────────────────────────────────────────────────────────────────────────
192
+ // recmaOctaneAdapter — the ESTree pass described in the module doc.
193
+ // ─────────────────────────────────────────────────────────────────────────────
194
+
195
+ const MDX_BODY_NAME = '_createMdxContent';
196
+
197
+ function recmaOctaneAdapter() {
198
+ /** @param {unknown} tree */
199
+ return (tree) => {
200
+ walkReplace(/** @type {EstreeNode} */ (tree), adaptNode);
201
+ };
202
+ }
203
+
204
+ /** @typedef {{ type: string, [key: string]: unknown }} EstreeNode */
205
+
206
+ /**
207
+ * @param {unknown} value
208
+ * @returns {value is EstreeNode}
209
+ */
210
+ function isNode(value) {
211
+ return value !== null && typeof value === 'object' && typeof value.type === 'string';
212
+ }
213
+
214
+ /**
215
+ * Visit `node` (post-decision, pre-recursion): return a replacement node to
216
+ * swap in (recursed into by the caller), or null to keep the node and recurse.
217
+ *
218
+ * @param {EstreeNode} node
219
+ * @returns {EstreeNode | null}
220
+ */
221
+ function adaptNode(node) {
222
+ // `_createMdxContent(props)` → `<_createMdxContent {...props}/>`.
223
+ if (
224
+ node.type === 'CallExpression' &&
225
+ isNode(node.callee) &&
226
+ node.callee.type === 'Identifier' &&
227
+ node.callee.name === MDX_BODY_NAME &&
228
+ Array.isArray(node.arguments) &&
229
+ node.arguments.length <= 1 &&
230
+ (node.arguments.length === 0 || isNode(node.arguments[0]))
231
+ ) {
232
+ return jsxSelfClosing(MDX_BODY_NAME, node.arguments[0] ?? null);
233
+ }
234
+ return null;
235
+ }
236
+
237
+ /**
238
+ * Depth-first walk that can REPLACE child nodes in place (arrays and single
239
+ * node-valued keys). Skips location metadata keys.
240
+ *
241
+ * @param {EstreeNode} node
242
+ * @param {(n: EstreeNode) => EstreeNode | null} visit
243
+ */
244
+ function walkReplace(node, visit) {
245
+ for (const key of Object.keys(node)) {
246
+ if (key === 'loc' || key === 'range' || key === 'position' || key === 'data') continue;
247
+ const value = node[key];
248
+ if (Array.isArray(value)) {
249
+ for (let i = 0; i < value.length; i++) {
250
+ const child = value[i];
251
+ if (!isNode(child)) continue;
252
+ const next = visit(child);
253
+ if (next !== null) value[i] = next;
254
+ else walkReplace(child, visit);
255
+ }
256
+ } else if (isNode(value)) {
257
+ const next = visit(value);
258
+ if (next !== null) node[key] = next;
259
+ else walkReplace(value, visit);
260
+ }
261
+ }
262
+ }
263
+
264
+ /**
265
+ * @param {string} name
266
+ * @param {EstreeNode | null} spreadArgument
267
+ * @returns {EstreeNode}
268
+ */
269
+ function jsxSelfClosing(name, spreadArgument) {
270
+ return {
271
+ type: 'JSXElement',
272
+ openingElement: {
273
+ type: 'JSXOpeningElement',
274
+ name: { type: 'JSXIdentifier', name },
275
+ attributes: spreadArgument ? [{ type: 'JSXSpreadAttribute', argument: spreadArgument }] : [],
276
+ selfClosing: true,
277
+ },
278
+ closingElement: null,
279
+ children: [],
280
+ };
281
+ }
package/src/env.d.ts ADDED
@@ -0,0 +1,16 @@
1
+ // @types/mdx (pulled in via @mdx-js/mdx's public types) requires a GLOBAL
2
+ // `JSX` namespace, which octane deliberately does not declare in its runtime
3
+ // types (JSX typing lives in the compiler's volar layer, per-`.tsrx` file).
4
+ // Minimal ambient declarations so this package typechecks standalone — no
5
+ // octane code reads these.
6
+ declare namespace JSX {
7
+ type Element = unknown;
8
+
9
+ interface ElementClass {
10
+ [name: string]: unknown;
11
+ }
12
+
13
+ interface IntrinsicElements {
14
+ [name: string]: Record<string, unknown>;
15
+ }
16
+ }
package/src/index.ts ADDED
@@ -0,0 +1,109 @@
1
+ /**
2
+ * @octanejs/mdx — the runtime provider layer.
3
+ *
4
+ * Strategy (docs/react-library-compat-plan.md §2): @mdx-js/mdx's core is
5
+ * framework-agnostic (the compile pipeline lives in `./compile` + `./vite`);
6
+ * only @mdx-js/react's thin React layer is ported here, onto octane context.
7
+ * `MDXProvider` / `useMDXComponents` are a port of @mdx-js/react/lib/index.js
8
+ * (v3): the same context, the same function-vs-object `components` merge, the
9
+ * same `disableParentContext` behavior.
10
+ *
11
+ * Compiled `.mdx` modules import `useMDXComponents` from here (the pipeline's
12
+ * default `providerImportSource`) and merge its result under `props.components`
13
+ * — so a components mapping can come from either the provider context or the
14
+ * `components` prop, exactly like MDX + React.
15
+ *
16
+ * ONE deliberate divergence from the React source: @mdx-js/react wraps the
17
+ * merge in `useMemo([contextComponents, components])` — a referential-stability
18
+ * optimization only. Octane's `useMemo` is the CLIENT runtime's (the `octane`
19
+ * entry has no server condition), and it requires a live render scope — a
20
+ * server-compiled document calls `useMDXComponents()` during `renderToString`,
21
+ * where the client scope is null and `useMemo` would crash. `useContext`, by
22
+ * contrast, is null-scope safe (it returns the context default). So the merge
23
+ * runs unmemoized: same observable mapping every render, valid in both
24
+ * runtimes (on the server the client context yields its default `{}`, and the
25
+ * `props.components` route still applies).
26
+ *
27
+ * SSR provider support lives in `@octanejs/mdx/server` — the same layer
28
+ * mirrored onto `octane/server` context (the two runtimes' context stores are
29
+ * disjoint; server-mode documents import `useMDXComponents` from there). Keep
30
+ * the merge semantics here and in src/server.ts in lockstep.
31
+ */
32
+ import { createContext, createElement, useContext, type ComponentBody } from 'octane';
33
+
34
+ /**
35
+ * A components mapping: markdown element name (`h1`, `p`, `code`, …) or
36
+ * embedded-component name → the octane component (or replacement host tag
37
+ * name) that renders it. The special `wrapper` key is the document layout.
38
+ */
39
+ export interface MDXComponents {
40
+ [name: string]: ComponentBody<any> | string | MDXComponents;
41
+ }
42
+
43
+ /** `components` as accepted by MDXProvider/useMDXComponents: a mapping, or a function of the inherited mapping (@mdx-js/react parity). */
44
+ export type MDXComponentsProp = MDXComponents | ((inherited: MDXComponents) => MDXComponents);
45
+
46
+ // Per @mdx-js/react lib/index.js: `const emptyComponents = {}` +
47
+ // `const MDXContext = React.createContext(emptyComponents)`.
48
+ const emptyComponents: MDXComponents = {};
49
+ const MDXContext = createContext<MDXComponents>(emptyComponents);
50
+
51
+ /**
52
+ * Get the current components mapping: the provider context merged with (or
53
+ * mapped through) `components`. Port of @mdx-js/react's `useMDXComponents`
54
+ * (see the module doc for the deliberately-dropped `useMemo`).
55
+ *
56
+ * `use*`-named, so a compiled caller appends its call-site slot symbol as the
57
+ * trailing arg — tolerated and ignored (no hook state lives here; `useContext`
58
+ * is not slot-threaded in octane).
59
+ */
60
+ export function useMDXComponents(
61
+ components?: MDXComponentsProp | null | symbol,
62
+ _slot?: symbol,
63
+ ): MDXComponents {
64
+ // The compiler-injected slot is arg 0 for the common no-`components` call
65
+ // (`useMDXComponents()` compiles to `useMDXComponents(SLOT)`). A symbol is
66
+ // never a components value — normalize it away.
67
+ if (typeof components === 'symbol') components = undefined;
68
+ const contextComponents = useContext(MDXContext);
69
+ // Custom merge via a function (@mdx-js/react parity).
70
+ if (typeof components === 'function') return components(contextComponents);
71
+ return { ...contextComponents, ...components };
72
+ }
73
+
74
+ export interface MDXProviderProps {
75
+ /** MDX content (and anything else that reads the mapping) rendered under this provider. */
76
+ children?: unknown;
77
+ /** Mapping to merge over (or function of) the inherited mapping. */
78
+ components?: MDXComponentsProp | null;
79
+ /** Ignore any parent MDXProvider and use exactly `components` (@mdx-js/react parity). */
80
+ disableParentContext?: boolean;
81
+ }
82
+
83
+ /**
84
+ * Provider of the MDX components context. Port of @mdx-js/react's
85
+ * `MDXProvider`: nested providers MERGE by default (`components` over the
86
+ * inherited mapping); `disableParentContext` starts from scratch.
87
+ */
88
+ export function MDXProvider(props: MDXProviderProps): unknown {
89
+ let allComponents: MDXComponents;
90
+ if (props.disableParentContext) {
91
+ allComponents =
92
+ typeof props.components === 'function'
93
+ ? props.components(emptyComponents)
94
+ : (props.components ?? emptyComponents);
95
+ } else {
96
+ allComponents = useMDXComponents(props.components);
97
+ }
98
+ return createElement(MDXContext.Provider as ComponentBody<any>, {
99
+ value: allComponents,
100
+ children: props.children,
101
+ });
102
+ }
103
+
104
+ /** The props every compiled MDX document component accepts. */
105
+ export interface MDXProps {
106
+ /** Per-render components mapping, merged over the provider context. */
107
+ components?: MDXComponents;
108
+ [key: string]: unknown;
109
+ }
@@ -0,0 +1,13 @@
1
+ // `octane/compiler` is authored in JSDoc'd JS with no shipped declarations —
2
+ // a minimal ambient surface for the one entry point this package consumes.
3
+ declare module 'octane/compiler' {
4
+ export function compile(
5
+ source: string,
6
+ id: string,
7
+ options?: {
8
+ mode?: 'client' | 'server';
9
+ hmr?: boolean;
10
+ dev?: boolean;
11
+ },
12
+ ): { code: string; map: unknown };
13
+ }
package/src/server.ts ADDED
@@ -0,0 +1,65 @@
1
+ /**
2
+ * @octanejs/mdx/server — the provider layer for SERVER renders.
3
+ *
4
+ * octane's client and server runtimes are separate modules with disjoint
5
+ * context stores, so the client `MDXProvider` (./index.ts, built on `octane`
6
+ * context) cannot thread a mapping through `renderToString` — its `useContext`
7
+ * runs null-scope on the server and yields the `{}` default. The server
8
+ * runtime has its own full context implementation (`scope.$$ctxValues`,
9
+ * top-down within one render pass), so this entry mirrors the provider layer
10
+ * onto `octane/server` context: same merge, same function-form `components`,
11
+ * same `disableParentContext` — see ./index.ts for the @mdx-js/react port
12
+ * notes. KEEP THE MERGE SEMANTICS IN LOCKSTEP with ./index.ts (both are the
13
+ * same ~40-line port; only the runtime import differs).
14
+ *
15
+ * The compile pipeline points a server-mode document's `providerImportSource`
16
+ * here (client mode keeps `@octanejs/mdx`), so `useMDXComponents()` inside a
17
+ * server-compiled document reads THIS context — and a document rendered under
18
+ * this `MDXProvider` serializes with the same mapping the client provider
19
+ * produces at hydration. Client bundles never import this module.
20
+ */
21
+ import { createContext, createElement, useContext } from 'octane/server';
22
+ import type { MDXComponents, MDXComponentsProp, MDXProviderProps } from './index.js';
23
+
24
+ export type { MDXComponents, MDXComponentsProp, MDXProviderProps, MDXProps } from './index.js';
25
+
26
+ const emptyComponents: MDXComponents = {};
27
+ const MDXContext = createContext<MDXComponents>(emptyComponents);
28
+
29
+ /**
30
+ * Server `useMDXComponents`: the provider context merged with (or mapped
31
+ * through) `components`. Same contract as the client export (./index.ts).
32
+ */
33
+ export function useMDXComponents(
34
+ components?: MDXComponentsProp | null | symbol,
35
+ _slot?: symbol,
36
+ ): MDXComponents {
37
+ // Tolerate a compiler-injected trailing slot symbol, like the client export.
38
+ if (typeof components === 'symbol') components = undefined;
39
+ const contextComponents = useContext(MDXContext);
40
+ if (typeof components === 'function') return components(contextComponents);
41
+ return { ...contextComponents, ...components };
42
+ }
43
+
44
+ /**
45
+ * Server `MDXProvider`: provides the mapping for every document rendered
46
+ * below it in this `renderToString` pass. Mount shape mirrors the client
47
+ * provider exactly (one provider component frame + one context frame), so a
48
+ * document server-rendered under this provider hydrates byte-for-byte into
49
+ * the client `MDXProvider`.
50
+ */
51
+ export function MDXProvider(props: MDXProviderProps): unknown {
52
+ let allComponents: MDXComponents;
53
+ if (props.disableParentContext) {
54
+ allComponents =
55
+ typeof props.components === 'function'
56
+ ? props.components(emptyComponents)
57
+ : (props.components ?? emptyComponents);
58
+ } else {
59
+ allComponents = useMDXComponents(props.components);
60
+ }
61
+ return createElement(MDXContext.Provider as any, {
62
+ value: allComponents,
63
+ children: props.children,
64
+ });
65
+ }
package/src/vite.js ADDED
@@ -0,0 +1,87 @@
1
+ /**
2
+ * @octanejs/mdx — the Vite plugin (`…/vite` entry).
3
+ *
4
+ * `octaneMdx()` transforms `.mdx` (and by default `.md`) modules through the
5
+ * full pipeline in `./compile` — @mdx-js/mdx (JSX source) → octane/compiler —
6
+ * and returns FINAL JS, so it composes with `@octanejs/vite-plugin` /
7
+ * `octane/compiler/vite` without ordering hazards: both are `enforce: 'pre'`,
8
+ * but the octane plugin only claims `.tsrx`/`.tsx`/`.ts`/`.js` ids and this one
9
+ * only claims `.mdx`/`.md`, so the two transforms never see the same module.
10
+ *
11
+ * SSR target selection mirrors `octane/compiler/vite` exactly: an explicit
12
+ * `ssr` option wins; otherwise Vite's transform-level SSR flag OR the
13
+ * environment consumer marks a server build (`mode: 'server'` codegen), so the
14
+ * SAME document renders to HTML strings on the server and hydratable DOM code
15
+ * on the client.
16
+ *
17
+ * Authored in `.js` (like octane's `compiler/vite.js` and @octanejs/stylex's
18
+ * vite entry) so the plugin loads when a consuming app's `vite.config.ts`
19
+ * pulls it in through Node's ESM loader — which resolves the on-disk file
20
+ * exactly as written and never applies TS-style `.js` → `.ts` mapping.
21
+ */
22
+ import { compileMdx } from './compile.js';
23
+
24
+ /**
25
+ * @typedef {Omit<import('./compile.js').CompileMdxOptions, 'mode' | 'hmr' | 'dev'> & {
26
+ * ssr?: boolean,
27
+ * md?: boolean,
28
+ * hmr?: boolean,
29
+ * }} OctaneMdxPluginOptions
30
+ *
31
+ * `ssr` forces the codegen target for EVERY module — `true` always server,
32
+ * `false` always client. Leave unset for per-module auto-detection (standard
33
+ * Vite SSR setups). Mirrors `octane/compiler/vite`'s `ssr` option.
34
+ * `md` also transforms `.md` modules (plain-markdown format). Default `true`.
35
+ * `hmr` is the octane HMR/dev metadata override; defaults to on in serve mode
36
+ * (client only).
37
+ */
38
+
39
+ /**
40
+ * Structural Vite plugin type — avoids a hard type dependency on vite (this
41
+ * package's published surface is source; see @octanejs/stylex's vite entry for
42
+ * the same choice).
43
+ *
44
+ * @typedef {{
45
+ * name: string,
46
+ * enforce: 'pre',
47
+ * configResolved(config: { command: string }): void,
48
+ * transform(code: string, id: string, options?: { ssr?: boolean }): Promise<{ code: string, map: unknown } | null>,
49
+ * }} OctaneMdxPlugin
50
+ */
51
+
52
+ /**
53
+ * @param {OctaneMdxPluginOptions} [options]
54
+ * @returns {OctaneMdxPlugin}
55
+ */
56
+ export function octaneMdx(options = {}) {
57
+ const { ssr: forceSsr, md, hmr, ...compileOptions } = options;
58
+ let hmrEnabled = hmr;
59
+ const includeMd = md !== false;
60
+ return {
61
+ name: 'octane-mdx',
62
+ enforce: 'pre',
63
+ configResolved(config) {
64
+ if (hmrEnabled === undefined) hmrEnabled = config.command === 'serve';
65
+ },
66
+ async transform(code, id, transformOptions) {
67
+ const [file, query = ''] = id.split('?'); // Vite ids carry ?v=/?used/?raw/… suffixes
68
+ if (!(file.endsWith('.mdx') || (includeMd && file.endsWith('.md')))) return null;
69
+ // An ASSET-query import (`import text from './doc.md?raw'`, ?url, ?inline)
70
+ // is vite's territory: its asset plugin already LOADED the module as JS
71
+ // (`export default "…"`), so compiling here would mangle it — and the
72
+ // author explicitly asked for the file, not the document. Internal
73
+ // bookkeeping queries (?v=hash, ?used, ?import) still transform.
74
+ if (/(^|&)(raw|url|inline|worker|sharedworker)(=|&|$)/.test(query)) return null;
75
+ const ssr =
76
+ forceSsr !== undefined
77
+ ? forceSsr
78
+ : transformOptions?.ssr === true || this.environment?.config?.consumer === 'server';
79
+ return compileMdx(code, file, {
80
+ ...compileOptions,
81
+ mode: ssr ? 'server' : 'client',
82
+ hmr: !ssr && !!hmrEnabled,
83
+ dev: !ssr && !!hmrEnabled,
84
+ });
85
+ },
86
+ };
87
+ }
@@ -0,0 +1,68 @@
1
+ // Hand-written declarations for src/compile.js (authored in `.js` so the vite
2
+ // entry's import chain loads under Node's native ESM loader — same convention
3
+ // as @octanejs/vite-plugin's types/). Keep in lockstep with the implementation.
4
+ import type { CompileOptions } from '@mdx-js/mdx';
5
+
6
+ export interface CompileMdxResult {
7
+ code: string;
8
+ map: unknown;
9
+ }
10
+
11
+ export interface CompileMdxOptions {
12
+ /** octane codegen target: `'client'` (DOM) or `'server'` (SSR HTML strings). Default `'client'`. */
13
+ mode?: 'client' | 'server';
14
+ /** octane compiler HMR wrapping (client only; the vite plugin wires this to serve mode). */
15
+ hmr?: boolean;
16
+ /** octane compiler dev metadata (client only; same gate as `hmr`). */
17
+ dev?: boolean;
18
+ /**
19
+ * Module the emitted document reads the provider mapping from
20
+ * (`useMDXComponents`). Defaults per mode — `'@octanejs/mdx'` (client) /
21
+ * `'@octanejs/mdx/server'` (server), so each runtime reads ITS OWN context
22
+ * store (they are disjoint; see src/server.ts). Pass `null` to disable the
23
+ * provider wiring entirely (only `props.components` applies).
24
+ */
25
+ providerImportSource?: string | null;
26
+ /** remark plugins. Defaults to `defaultRemarkPlugins` (GFM + frontmatter + frontmatter-export). */
27
+ remarkPlugins?: CompileOptions['remarkPlugins'];
28
+ rehypePlugins?: CompileOptions['rehypePlugins'];
29
+ /** Extra recma (ESTree) plugins, run before the octane adapter pass. */
30
+ recmaPlugins?: CompileOptions['recmaPlugins'];
31
+ /** Source syntax: `'mdx'`, plain `'md'` (no JSX/ESM/expressions), or `'detect'` by file extension. Default `'detect'`. */
32
+ format?: 'mdx' | 'md' | 'detect';
33
+ /** Escape hatch: other @mdx-js/mdx options. The pipeline owns `jsx`/`outputFormat`/the options above. */
34
+ mdxOptions?: Omit<
35
+ CompileOptions,
36
+ | 'jsx'
37
+ | 'jsxRuntime'
38
+ | 'jsxImportSource'
39
+ | 'outputFormat'
40
+ | 'providerImportSource'
41
+ | 'remarkPlugins'
42
+ | 'rehypePlugins'
43
+ | 'recmaPlugins'
44
+ | 'format'
45
+ | 'SourceMapGenerator'
46
+ >;
47
+ }
48
+
49
+ /**
50
+ * The default remark plugin set: GitHub-flavored markdown, YAML/TOML
51
+ * frontmatter parsing, and the `export const frontmatter = {…}` export.
52
+ * Exported so a custom `remarkPlugins` list can extend rather than replace it.
53
+ */
54
+ export declare const defaultRemarkPlugins: CompileOptions['remarkPlugins'];
55
+
56
+ /** Compile MDX/markdown source to a compiled octane module (async — supports async plugins). */
57
+ export declare function compileMdx(
58
+ source: string,
59
+ id: string,
60
+ options?: CompileMdxOptions,
61
+ ): Promise<CompileMdxResult>;
62
+
63
+ /** Synchronous {@link compileMdx} (the default plugin set is fully sync). */
64
+ export declare function compileMdxSync(
65
+ source: string,
66
+ id: string,
67
+ options?: CompileMdxOptions,
68
+ ): CompileMdxResult;
@@ -0,0 +1,35 @@
1
+ // Hand-written declarations for src/vite.js (authored in `.js` so it loads
2
+ // from a consumer's vite.config.ts under Node's native ESM loader — same
3
+ // convention as @octanejs/vite-plugin's types/). Keep in lockstep.
4
+ import type { CompileMdxOptions } from './compile.js';
5
+
6
+ export interface OctaneMdxPluginOptions extends Omit<CompileMdxOptions, 'mode' | 'hmr' | 'dev'> {
7
+ /**
8
+ * Force the codegen target for EVERY module — `true` always server, `false`
9
+ * always client. Leave unset for per-module auto-detection (standard Vite
10
+ * SSR setups). Mirrors `octane/compiler/vite`'s `ssr` option.
11
+ */
12
+ ssr?: boolean;
13
+ /** Also transform `.md` modules (plain-markdown format). Default `true`. */
14
+ md?: boolean;
15
+ /** octane HMR/dev metadata override; defaults to on in serve mode (client only). */
16
+ hmr?: boolean;
17
+ }
18
+
19
+ /**
20
+ * Structural Vite plugin type — avoids a hard type dependency on vite (see
21
+ * @octanejs/stylex's vite entry for the same choice).
22
+ */
23
+ export interface OctaneMdxPlugin {
24
+ name: string;
25
+ enforce: 'pre';
26
+ configResolved(config: { command: string }): void;
27
+ transform(
28
+ this: unknown,
29
+ code: string,
30
+ id: string,
31
+ options?: { ssr?: boolean },
32
+ ): Promise<{ code: string; map: unknown } | null>;
33
+ }
34
+
35
+ export declare function octaneMdx(options?: OctaneMdxPluginOptions): OctaneMdxPlugin;