@octanejs/mdx 0.1.3 → 0.1.7

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
@@ -30,6 +30,14 @@ export default defineConfig({
30
30
  });
31
31
  ```
32
32
 
33
+ Pass `profile: true` to both plugins for a browser profiling build:
34
+
35
+ ```ts
36
+ plugins: [octaneMdx({ profile: true }), octane({ profile: true })];
37
+ ```
38
+
39
+ Profiling metadata is never emitted by the MDX server transform.
40
+
33
41
  `octaneMdx()` claims `.mdx`/`.md` and produces final JS, so it composes with the
34
42
  octane plugin (which claims `.tsrx`/`.tsx`/`.ts`/`.js`) without ordering
35
43
  hazards. SSR target selection matches the octane plugin: per-module
@@ -80,7 +88,9 @@ the special `wrapper` key is the document layout.
80
88
  SSR passes call this during `renderToString`). Same observable mapping.
81
89
  - `@octanejs/mdx/compile` — `compileMdx` / `compileMdxSync` /
82
90
  `defaultRemarkPlugins`, the plugin's pipeline as a library (used by the SSR
83
- tests, usable for static-site tooling).
91
+ tests, usable for static-site tooling). Results include nonfatal compiler
92
+ `diagnostics`; JSX warning ranges are mapped back to the authored `.mdx`
93
+ document. The Vite plugin publishes the same warnings during transforms.
84
94
 
85
95
  ## SSR + hydration
86
96
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@octanejs/mdx",
3
- "version": "0.1.3",
3
+ "version": "0.1.7",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "engines": {
@@ -48,6 +48,7 @@
48
48
  },
49
49
  "dependencies": {
50
50
  "@jridgewell/remapping": "^2.3.5",
51
+ "@jridgewell/trace-mapping": "^0.3.31",
51
52
  "@mdx-js/mdx": "^3.1.1",
52
53
  "remark-frontmatter": "^5.0.0",
53
54
  "remark-gfm": "^4.0.1",
@@ -55,15 +56,14 @@
55
56
  "source-map": "^0.7.6"
56
57
  },
57
58
  "peerDependencies": {
58
- "octane": "0.1.6"
59
+ "octane": "0.1.10"
59
60
  },
60
61
  "devDependencies": {
61
- "@jridgewell/trace-mapping": "^0.3.31",
62
62
  "@shikijs/rehype": "^3.0.0",
63
63
  "vite": "^8.0.16",
64
64
  "vitest": "^4.1.9",
65
- "@octanejs/testing-library": "0.1.3",
66
- "octane": "0.1.6"
65
+ "@octanejs/testing-library": "0.1.7",
66
+ "octane": "0.1.10"
67
67
  },
68
68
  "scripts": {
69
69
  "test": "vitest run"
package/src/compile.js CHANGED
@@ -39,8 +39,14 @@
39
39
  * consuming app's `vite.config.ts` pulls it in through Node's ESM loader.
40
40
  */
41
41
  import remapping from '@jridgewell/remapping';
42
+ import {
43
+ TraceMap,
44
+ originalPositionFor,
45
+ GREATEST_LOWER_BOUND,
46
+ LEAST_UPPER_BOUND,
47
+ } from '@jridgewell/trace-mapping';
42
48
  import { compile as mdxCompile, compileSync as mdxCompileSync } from '@mdx-js/mdx';
43
- import { compile as octaneCompile } from 'octane/compiler';
49
+ import { __analyzeNativeChangeDiagnostics, compile as octaneCompile } from 'octane/compiler';
44
50
  import remarkFrontmatter from 'remark-frontmatter';
45
51
  import remarkGfm from 'remark-gfm';
46
52
  import remarkMdxFrontmatter from 'remark-mdx-frontmatter';
@@ -50,6 +56,36 @@ import { SourceMapGenerator } from 'source-map';
50
56
  * @typedef {object} CompileMdxResult
51
57
  * @property {string} code
52
58
  * @property {unknown} map
59
+ * @property {Array<{
60
+ * code: string,
61
+ * severity: 'warning',
62
+ * message: string,
63
+ * filename: string,
64
+ * start: { offset: number, line: number, column: number },
65
+ * end: { offset: number, line: number, column: number },
66
+ * suggestions: Array<{
67
+ * start: { offset: number, line: number, column: number },
68
+ * end: { offset: number, line: number, column: number },
69
+ * attribute: 'onInput' | 'onInputCapture',
70
+ * }>,
71
+ * }>} diagnostics
72
+ */
73
+
74
+ /**
75
+ * @typedef {object} AuthoredJsxAttributeLocation
76
+ * @property {string} name
77
+ * @property {number} start
78
+ * @property {number} end
79
+ * @property {{ start: { line: number, column: number }, end: { line: number, column: number } }} loc
80
+ * @property {boolean} staticallyWarned
81
+ */
82
+
83
+ /**
84
+ * @typedef {object} DiagnosticMappingContext
85
+ * @property {AuthoredJsxAttributeLocation[]} authoredAttributes
86
+ * @property {WeakMap<object, AuthoredJsxAttributeLocation>} attributeLocations
87
+ * @property {string} source
88
+ * @property {string} id
53
89
  */
54
90
 
55
91
  /**
@@ -57,6 +93,7 @@ import { SourceMapGenerator } from 'source-map';
57
93
  * @property {'client' | 'server'} [mode] octane codegen target: `'client'` (DOM) or `'server'` (SSR HTML strings). Default `'client'`.
58
94
  * @property {boolean} [hmr] octane compiler HMR wrapping (client only; the vite plugin wires this to serve mode).
59
95
  * @property {boolean} [dev] octane compiler dev metadata (client only; same gate as `hmr`).
96
+ * @property {boolean} [profile] octane compiler profiling metadata (client only).
60
97
  * @property {string | null} [providerImportSource]
61
98
  * Module the emitted document reads the provider mapping from
62
99
  * (`useMDXComponents`). Defaults per mode — `'@octanejs/mdx'` (client) /
@@ -88,8 +125,17 @@ export const defaultRemarkPlugins = [remarkGfm, remarkFrontmatter, remarkMdxFron
88
125
  * @returns {Promise<CompileMdxResult>}
89
126
  */
90
127
  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);
128
+ const diagnosticContext = {
129
+ authoredAttributes: [],
130
+ attributeLocations: new WeakMap(),
131
+ source,
132
+ id,
133
+ };
134
+ const out = await mdxCompile(
135
+ { value: source, path: id },
136
+ buildMdxOptions(id, options, diagnosticContext),
137
+ );
138
+ return octaneStage(String(out.value), out.map, source, id, options, diagnosticContext);
93
139
  }
94
140
 
95
141
  /**
@@ -101,16 +147,26 @@ export async function compileMdx(source, id, options = {}) {
101
147
  * @returns {CompileMdxResult}
102
148
  */
103
149
  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);
150
+ const diagnosticContext = {
151
+ authoredAttributes: [],
152
+ attributeLocations: new WeakMap(),
153
+ source,
154
+ id,
155
+ };
156
+ const out = mdxCompileSync(
157
+ { value: source, path: id },
158
+ buildMdxOptions(id, options, diagnosticContext),
159
+ );
160
+ return octaneStage(String(out.value), out.map, source, id, options, diagnosticContext);
106
161
  }
107
162
 
108
163
  /**
109
164
  * @param {string} id
110
165
  * @param {CompileMdxOptions} options
166
+ * @param {DiagnosticMappingContext} diagnosticContext
111
167
  * @returns {import('@mdx-js/mdx').CompileOptions}
112
168
  */
113
- function buildMdxOptions(id, options) {
169
+ function buildMdxOptions(id, options, diagnosticContext) {
114
170
  const format =
115
171
  options.format && options.format !== 'detect'
116
172
  ? options.format
@@ -135,23 +191,186 @@ function buildMdxOptions(id, options) {
135
191
  ...(provider === null ? {} : { providerImportSource: provider }),
136
192
  remarkPlugins: options.remarkPlugins ?? defaultRemarkPlugins,
137
193
  rehypePlugins: options.rehypePlugins,
138
- recmaPlugins: [...(options.recmaPlugins ?? []), recmaOctaneAdapter],
194
+ recmaPlugins: [
195
+ seedAuthoredJsxAttributeLocations(diagnosticContext),
196
+ ...(options.recmaPlugins ?? []),
197
+ restoreAuthoredJsxAttributeLocations(diagnosticContext),
198
+ recmaOctaneAdapter,
199
+ ],
200
+ };
201
+ }
202
+
203
+ /**
204
+ * @param {unknown} tree
205
+ * @param {(attribute: Record<string, any>) => void} callback
206
+ */
207
+ function visitJsxAttributes(tree, callback) {
208
+ const activeObjects = new WeakSet();
209
+ /** @param {unknown} value */
210
+ function visit(value) {
211
+ if (value === null || typeof value !== 'object' || activeObjects.has(value)) return;
212
+ activeObjects.add(value);
213
+ const node = /** @type {Record<string, any>} */ (value);
214
+ if (node.type === 'JSXAttribute') callback(node);
215
+ for (const [key, child] of Object.entries(node)) {
216
+ if (key === 'loc' || key === 'range' || key === 'position') continue;
217
+ if (Array.isArray(child)) child.forEach(visit);
218
+ else visit(child);
219
+ }
220
+ activeObjects.delete(value);
221
+ }
222
+ visit(tree);
223
+ }
224
+
225
+ /**
226
+ * @param {Record<string, any>} node
227
+ * @param {AuthoredJsxAttributeLocation} location
228
+ */
229
+ function applyNodeLocation(node, location) {
230
+ node.start = location.start;
231
+ node.end = location.end;
232
+ node.range = [location.start, location.end];
233
+ node.loc = {
234
+ start: { ...location.loc.start },
235
+ end: { ...location.loc.end },
236
+ };
237
+ }
238
+
239
+ /**
240
+ * Add the exact source location missing from MDX's JSXIdentifier attribute
241
+ * names. Remark and rehype transforms have already established stage-one
242
+ * source provenance here; the parent JSXAttribute carries that location, and
243
+ * seeding the child makes the map point directly at the token the Octane
244
+ * diagnostic covers. Analyze this pre-recma tree once as well: a later
245
+ * transform may preserve or copy source locations, but only attributes that
246
+ * were already statically warned at this boundary may receive an authored fix.
247
+ *
248
+ * @param {DiagnosticMappingContext} context
249
+ */
250
+ function seedAuthoredJsxAttributeLocations(context) {
251
+ return function recmaSeedAuthoredJsxAttributeLocations() {
252
+ /** @param {unknown} tree */
253
+ return (tree) => {
254
+ context.authoredAttributes.length = 0;
255
+ const occurrences = [];
256
+ visitJsxAttributes(tree, (attribute) => occurrences.push(attribute));
257
+ const occurrenceCounts = new Map();
258
+ for (const attribute of occurrences) {
259
+ occurrenceCounts.set(attribute, (occurrenceCounts.get(attribute) ?? 0) + 1);
260
+ }
261
+ for (const attribute of occurrences) {
262
+ if (occurrenceCounts.get(attribute) !== 1) continue;
263
+ const name = attribute.name;
264
+ if (
265
+ name?.type !== 'JSXIdentifier' ||
266
+ typeof name.name !== 'string' ||
267
+ typeof attribute.start !== 'number' ||
268
+ typeof attribute.loc?.start?.line !== 'number' ||
269
+ typeof attribute.loc?.start?.column !== 'number'
270
+ )
271
+ continue;
272
+ const location = {
273
+ name: name.name,
274
+ start: attribute.start,
275
+ end: attribute.start + name.name.length,
276
+ loc: {
277
+ start: {
278
+ line: attribute.loc.start.line,
279
+ column: attribute.loc.start.column,
280
+ },
281
+ end: {
282
+ line: attribute.loc.start.line,
283
+ column: attribute.loc.start.column + name.name.length,
284
+ },
285
+ },
286
+ staticallyWarned: false,
287
+ };
288
+ context.authoredAttributes.push(location);
289
+ context.attributeLocations.set(attribute, location);
290
+ const seededName = { ...name };
291
+ attribute.name = seededName;
292
+ applyNodeLocation(seededName, location);
293
+ }
294
+ const warningRanges = new Set(
295
+ __analyzeNativeChangeDiagnostics(tree, context.source, context.id).diagnostics.flatMap(
296
+ (diagnostic) =>
297
+ diagnostic.suggestions.map(
298
+ (suggestion) => `${suggestion.start.offset}:${suggestion.end.offset}`,
299
+ ),
300
+ ),
301
+ );
302
+ for (const location of context.authoredAttributes) {
303
+ location.staticallyWarned = warningRanges.has(`${location.start}:${location.end}`);
304
+ }
305
+ };
306
+ };
307
+ }
308
+
309
+ /**
310
+ * Restore exact locations on unchanged attribute identities after user recma
311
+ * transforms. Classification was frozen before those transforms, so even a
312
+ * later plugin that copies a location cannot turn a previously-safe authored
313
+ * callback into an actionable replacement.
314
+ *
315
+ * @param {DiagnosticMappingContext} context
316
+ */
317
+ function restoreAuthoredJsxAttributeLocations(context) {
318
+ return function recmaRestoreAuthoredJsxAttributeLocations() {
319
+ /** @param {unknown} tree */
320
+ return (tree) => {
321
+ const occurrences = [];
322
+ visitJsxAttributes(tree, (attribute) => occurrences.push(attribute));
323
+ const occurrenceCounts = new Map();
324
+ for (const attribute of occurrences) {
325
+ occurrenceCounts.set(attribute, (occurrenceCounts.get(attribute) ?? 0) + 1);
326
+ }
327
+ for (const attribute of occurrences) {
328
+ const name = attribute.name;
329
+ const location = context.attributeLocations.get(attribute);
330
+ if (
331
+ location &&
332
+ occurrenceCounts.get(attribute) === 1 &&
333
+ name?.type === 'JSXIdentifier' &&
334
+ name.name === location.name
335
+ ) {
336
+ // Isolate the authored name from shallow attribute clones. Otherwise a
337
+ // generated clone can share this child object and regain its location
338
+ // when the original attribute is restored later in the traversal.
339
+ const restoredName = { ...name };
340
+ attribute.name = restoredName;
341
+ applyNodeLocation(restoredName, location);
342
+ continue;
343
+ }
344
+ // Do not let a plugin-created clone carrying copied positions masquerade
345
+ // as the one authored attribute captured before user transforms.
346
+ for (const node of [attribute, name]) {
347
+ if (node === null || typeof node !== 'object') continue;
348
+ delete node.start;
349
+ delete node.end;
350
+ delete node.range;
351
+ delete node.loc;
352
+ }
353
+ }
354
+ };
139
355
  };
140
356
  }
141
357
 
142
358
  /**
143
359
  * @param {string} jsxSource
144
360
  * @param {unknown} mdxMap
361
+ * @param {string} authoredSource
145
362
  * @param {string} id
146
363
  * @param {CompileMdxOptions} options
364
+ * @param {DiagnosticMappingContext} diagnosticContext
147
365
  * @returns {CompileMdxResult}
148
366
  */
149
- function octaneStage(jsxSource, mdxMap, id, options) {
367
+ function octaneStage(jsxSource, mdxMap, authoredSource, id, options, diagnosticContext) {
150
368
  const mode = options.mode ?? 'client';
151
369
  const out = octaneCompile(jsxSource, id, {
152
370
  mode,
153
371
  hmr: mode === 'client' && !!options.hmr,
154
372
  dev: mode === 'client' && !!options.dev,
373
+ profile: mode === 'client' && !!options.profile,
155
374
  });
156
375
  // Two-stage sourcemap: octane's map targets the INTERMEDIATE JSX text;
157
376
  // @mdx-js/mdx's map (via SourceMapGenerator) targets the original .mdx.
@@ -165,6 +384,19 @@ function octaneStage(jsxSource, mdxMap, id, options) {
165
384
  const chained = remapping([out.map, mdxMap], () => null);
166
385
  if (String(chained.mappings).length > 0) out.map = chained;
167
386
  }
387
+ // Compiler diagnostics are reported against the intermediate JSX emitted by
388
+ // MDX. Trace every available range through stage one's source map so direct
389
+ // callers and Vite warnings point at the JSX the author actually wrote in the
390
+ // document. Markdown generated by headings/lists has no authored event prop,
391
+ // so native-change warnings are expected to map only from literal MDX JSX.
392
+ out.diagnostics = remapDiagnostics(
393
+ out.diagnostics,
394
+ mdxMap,
395
+ jsxSource,
396
+ authoredSource,
397
+ id,
398
+ diagnosticContext.authoredAttributes,
399
+ );
168
400
  // Fast refresh for documents: octane's compiler only auto-wraps EXPORTED
169
401
  // `@{}`-form components in `hmr(...)` — `MDXContent` (a passthrough function
170
402
  // returning a ternary of descriptors) isn't recognized as one, so the octane
@@ -185,9 +417,190 @@ function octaneStage(jsxSource, mdxMap, id, options) {
185
417
  ' });\n' +
186
418
  '}\n';
187
419
  }
420
+ // Register the final public binding after the MDX-specific HMR wrapper. The
421
+ // core compiler may also recognize the generated dispatcher, but its location
422
+ // belongs to intermediate JSX and its registration precedes this wrapper. This
423
+ // document-level registration deliberately overrides both with the authored
424
+ // `.mdx` identity and stable line-one location.
425
+ if (
426
+ mode === 'client' &&
427
+ options.profile &&
428
+ /\bexport default function MDXContent\b/.test(out.code)
429
+ ) {
430
+ const metadata = {
431
+ id: `${id}#MDXContent@1:0`,
432
+ name: 'MDXContent',
433
+ file: id,
434
+ line: 1,
435
+ column: 0,
436
+ kind: 'component',
437
+ };
438
+ out.code +=
439
+ "\nimport { __profileComponent as _$mdxProfile } from 'octane/profiling';\n" +
440
+ `_$mdxProfile(MDXContent, ${JSON.stringify(metadata)});\n`;
441
+ }
188
442
  return out;
189
443
  }
190
444
 
445
+ /** @param {string} source */
446
+ function sourceLineStarts(source) {
447
+ const starts = [0];
448
+ for (let index = 0; index < source.length; index++) {
449
+ if (source.charCodeAt(index) === 10) starts.push(index + 1);
450
+ }
451
+ return starts;
452
+ }
453
+
454
+ /**
455
+ * @param {string} source
456
+ * @param {number[]} starts
457
+ * @param {number} offset
458
+ */
459
+ function positionForOffset(source, starts, offset) {
460
+ const clamped = Math.max(0, Math.min(source.length, offset));
461
+ let low = 0;
462
+ let high = starts.length;
463
+ while (low + 1 < high) {
464
+ const mid = (low + high) >> 1;
465
+ if (starts[mid] <= clamped) low = mid;
466
+ else high = mid;
467
+ }
468
+ return { offset: clamped, line: low + 1, column: clamped - starts[low] };
469
+ }
470
+
471
+ /**
472
+ * @param {TraceMap} trace
473
+ * @param {string} source
474
+ * @param {number[]} starts
475
+ * @param {{ offset: number, line: number, column: number }} position
476
+ * @param {number} bias
477
+ */
478
+ function mapDiagnosticPosition(trace, source, starts, position, bias) {
479
+ const mapped = originalPositionFor(trace, {
480
+ line: position.line,
481
+ column: position.column,
482
+ bias,
483
+ });
484
+ if (mapped.line == null || mapped.column == null) return null;
485
+ const lineStart = starts[mapped.line - 1];
486
+ if (lineStart === undefined) return null;
487
+ return positionForOffset(source, starts, lineStart + mapped.column);
488
+ }
489
+
490
+ /**
491
+ * Match an Octane diagnostic to an exact authored JSX attribute source-map
492
+ * segment. Both source-map biases must resolve to the same seeded token: when
493
+ * they differ, the generated position sits between mappings and belongs to
494
+ * transformed/generated code rather than an authored attribute.
495
+ *
496
+ * @param {TraceMap} trace
497
+ * @param {string} jsxSource
498
+ * @param {string} source
499
+ * @param {number[]} starts
500
+ * @param {{ start: { offset: number, line: number, column: number }, end: { offset: number, line: number, column: number } }} range
501
+ * @param {AuthoredJsxAttributeLocation[]} authoredAttributes
502
+ * @returns {AuthoredJsxAttributeLocation | null}
503
+ */
504
+ function exactAuthoredAttributeForDiagnostic(
505
+ trace,
506
+ jsxSource,
507
+ source,
508
+ starts,
509
+ range,
510
+ authoredAttributes,
511
+ ) {
512
+ const name = jsxSource.slice(range.start.offset, range.end.offset);
513
+ if (name.length === 0) return null;
514
+ const lower = mapDiagnosticPosition(trace, source, starts, range.start, GREATEST_LOWER_BOUND);
515
+ const upper = mapDiagnosticPosition(trace, source, starts, range.start, LEAST_UPPER_BOUND);
516
+ if (lower === null || upper === null || lower.offset !== upper.offset) return null;
517
+ return (
518
+ authoredAttributes.find(
519
+ (attribute) =>
520
+ attribute.staticallyWarned && attribute.name === name && attribute.start === lower.offset,
521
+ ) ?? null
522
+ );
523
+ }
524
+
525
+ /**
526
+ * @param {string} source
527
+ * @param {number[]} starts
528
+ * @param {AuthoredJsxAttributeLocation} attribute
529
+ */
530
+ function rangeForAuthoredAttribute(source, starts, attribute) {
531
+ return {
532
+ start: positionForOffset(source, starts, attribute.start),
533
+ end: positionForOffset(source, starts, attribute.end),
534
+ };
535
+ }
536
+
537
+ /**
538
+ * @param {CompileMdxResult['diagnostics'] | undefined} diagnostics
539
+ * @param {unknown} mdxMap
540
+ * @param {string} jsxSource
541
+ * @param {string} source
542
+ * @param {string} id
543
+ * @param {AuthoredJsxAttributeLocation[]} authoredAttributes
544
+ * @returns {CompileMdxResult['diagnostics']}
545
+ */
546
+ function remapDiagnostics(diagnostics, mdxMap, jsxSource, source, id, authoredAttributes) {
547
+ if (!Array.isArray(diagnostics) || diagnostics.length === 0) return [];
548
+ const starts = sourceLineStarts(source);
549
+ const fileStart = positionForOffset(source, starts, 0);
550
+ const trace = mdxMap ? new TraceMap(JSON.parse(JSON.stringify(mdxMap))) : null;
551
+ const claimedAttributes = new Set();
552
+ return diagnostics.map((diagnostic) => {
553
+ const attribute = trace
554
+ ? exactAuthoredAttributeForDiagnostic(
555
+ trace,
556
+ jsxSource,
557
+ source,
558
+ starts,
559
+ diagnostic,
560
+ authoredAttributes,
561
+ )
562
+ : null;
563
+ const key = attribute ? `${attribute.name}:${attribute.start}:${attribute.end}` : null;
564
+ if (attribute === null || key === null || claimedAttributes.has(key)) {
565
+ return {
566
+ ...diagnostic,
567
+ filename: id,
568
+ start: fileStart,
569
+ end: fileStart,
570
+ suggestions: [],
571
+ };
572
+ }
573
+ claimedAttributes.add(key);
574
+ const range = rangeForAuthoredAttribute(source, starts, attribute);
575
+ const claimedSuggestions = new Set();
576
+ return {
577
+ ...diagnostic,
578
+ filename: id,
579
+ ...range,
580
+ suggestions: diagnostic.suggestions.flatMap((suggestion) => {
581
+ const suggestionAttribute = exactAuthoredAttributeForDiagnostic(
582
+ trace,
583
+ jsxSource,
584
+ source,
585
+ starts,
586
+ suggestion,
587
+ authoredAttributes,
588
+ );
589
+ if (suggestionAttribute === null) return [];
590
+ const suggestionKey = `${suggestionAttribute.name}:${suggestionAttribute.start}:${suggestionAttribute.end}`;
591
+ if (claimedSuggestions.has(suggestionKey)) return [];
592
+ claimedSuggestions.add(suggestionKey);
593
+ return [
594
+ {
595
+ ...suggestion,
596
+ ...rangeForAuthoredAttribute(source, starts, suggestionAttribute),
597
+ },
598
+ ];
599
+ }),
600
+ };
601
+ });
602
+ }
603
+
191
604
  // ─────────────────────────────────────────────────────────────────────────────
192
605
  // recmaOctaneAdapter — the ESTree pass described in the module doc.
193
606
  // ─────────────────────────────────────────────────────────────────────────────
@@ -1,6 +1,24 @@
1
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.
2
+ // a minimal ambient surface for the entry points this package consumes.
3
3
  declare module 'octane/compiler' {
4
+ export interface CompileDiagnosticPosition {
5
+ offset: number;
6
+ line: number;
7
+ column: number;
8
+ }
9
+ export interface CompileDiagnostic {
10
+ code: string;
11
+ severity: 'warning';
12
+ message: string;
13
+ filename: string;
14
+ start: CompileDiagnosticPosition;
15
+ end: CompileDiagnosticPosition;
16
+ suggestions: Array<{
17
+ start: CompileDiagnosticPosition;
18
+ end: CompileDiagnosticPosition;
19
+ attribute: 'onInput' | 'onInputCapture';
20
+ }>;
21
+ }
4
22
  export function compile(
5
23
  source: string,
6
24
  id: string,
@@ -8,6 +26,12 @@ declare module 'octane/compiler' {
8
26
  mode?: 'client' | 'server';
9
27
  hmr?: boolean;
10
28
  dev?: boolean;
29
+ profile?: boolean;
11
30
  },
12
- ): { code: string; map: unknown };
31
+ ): { code: string; map: unknown; diagnostics: CompileDiagnostic[] };
32
+ export function __analyzeNativeChangeDiagnostics(
33
+ ast: unknown,
34
+ source: string,
35
+ filename: string,
36
+ ): { diagnostics: CompileDiagnostic[]; classifications: Map<number, string> };
13
37
  }
package/src/vite.js CHANGED
@@ -20,12 +20,14 @@
20
20
  * exactly as written and never applies TS-style `.js` → `.ts` mapping.
21
21
  */
22
22
  import { compileMdx } from './compile.js';
23
+ import { createOctaneCompiler } from 'octane/compiler/bundler';
23
24
 
24
25
  /**
25
26
  * @typedef {Omit<import('./compile.js').CompileMdxOptions, 'mode' | 'hmr' | 'dev'> & {
26
27
  * ssr?: boolean,
27
28
  * md?: boolean,
28
29
  * hmr?: boolean,
30
+ * profile?: boolean,
29
31
  * }} OctaneMdxPluginOptions
30
32
  *
31
33
  * `ssr` forces the codegen target for EVERY module — `true` always server,
@@ -34,6 +36,7 @@ import { compileMdx } from './compile.js';
34
36
  * `md` also transforms `.md` modules (plain-markdown format). Default `true`.
35
37
  * `hmr` is the octane HMR/dev metadata override; defaults to on in serve mode
36
38
  * (client only).
39
+ * `profile` enables profiling metadata in client modules only.
37
40
  */
38
41
 
39
42
  /**
@@ -44,8 +47,9 @@ import { compileMdx } from './compile.js';
44
47
  * @typedef {{
45
48
  * name: string,
46
49
  * enforce: 'pre',
47
- * configResolved(config: { command: string }): void,
48
- * transform(code: string, id: string, options?: { ssr?: boolean }): Promise<{ code: string, map: unknown } | null>,
50
+ * configResolved(config: { command: string, root?: string }): void,
51
+ * watchChange(id: string): void,
52
+ * transform(this: { addWatchFile?(id: string): void, warn?(warning: { code: string, message: string, id: string, loc: { file: string, line: number, column: number } }): void, environment?: { config?: { consumer?: string } } }, code: string, id: string, options?: { ssr?: boolean }): Promise<import('./compile.js').CompileMdxResult | null>,
49
53
  * }} OctaneMdxPlugin
50
54
  */
51
55
 
@@ -54,15 +58,24 @@ import { compileMdx } from './compile.js';
54
58
  * @returns {OctaneMdxPlugin}
55
59
  */
56
60
  export function octaneMdx(options = {}) {
57
- const { ssr: forceSsr, md, hmr, ...compileOptions } = options;
61
+ const { ssr: forceSsr, md, hmr, profile, ...compileOptions } = options;
58
62
  let hmrEnabled = hmr;
63
+ let projectRoot = process.cwd();
64
+ let profileIds = createOctaneCompiler({ root: projectRoot });
65
+ const warnedByFile = new Map();
59
66
  const includeMd = md !== false;
60
67
  return {
61
68
  name: 'octane-mdx',
62
69
  enforce: 'pre',
63
70
  configResolved(config) {
71
+ projectRoot = config.root ?? projectRoot;
72
+ profileIds = createOctaneCompiler({ root: projectRoot });
64
73
  if (hmrEnabled === undefined) hmrEnabled = config.command === 'serve';
65
74
  },
75
+ watchChange(id) {
76
+ profileIds.invalidate(id);
77
+ warnedByFile.delete(id.split('?')[0]);
78
+ },
66
79
  async transform(code, id, transformOptions) {
67
80
  const [file, query = ''] = id.split('?'); // Vite ids carry ?v=/?used/?raw/… suffixes
68
81
  if (!(file.endsWith('.mdx') || (includeMd && file.endsWith('.md')))) return null;
@@ -76,12 +89,43 @@ export function octaneMdx(options = {}) {
76
89
  forceSsr !== undefined
77
90
  ? forceSsr
78
91
  : transformOptions?.ssr === true || this.environment?.config?.consumer === 'server';
79
- return compileMdx(code, file, {
92
+ const profiling = !ssr && profile === true;
93
+ // Preserve the historical filename (and therefore output/source maps) in
94
+ // ordinary and server builds. Profile metadata needs the portable ID, so
95
+ // only the explicit client profiling specialization canonicalizes it.
96
+ const profileIdentity = profiling ? profileIds.resolveProfileModuleId(file) : null;
97
+ for (const dependency of profileIdentity?.dependencies ?? []) {
98
+ this.addWatchFile?.(dependency);
99
+ }
100
+ const compilerId = profileIdentity?.id ?? file;
101
+ const result = await compileMdx(code, compilerId, {
80
102
  ...compileOptions,
81
103
  mode: ssr ? 'server' : 'client',
82
104
  hmr: !ssr && !!hmrEnabled,
83
105
  dev: !ssr && !!hmrEnabled,
106
+ profile: profiling,
84
107
  });
108
+ let warned = warnedByFile.get(file);
109
+ if (warned === undefined) {
110
+ warned = new Set();
111
+ warnedByFile.set(file, warned);
112
+ }
113
+ for (const diagnostic of result.diagnostics) {
114
+ const key = `${diagnostic.code}:${diagnostic.start.offset}:${diagnostic.end.offset}:${diagnostic.message}`;
115
+ if (warned.has(key)) continue;
116
+ warned.add(key);
117
+ this.warn?.({
118
+ code: diagnostic.code,
119
+ message: diagnostic.message,
120
+ id: diagnostic.filename,
121
+ loc: {
122
+ file: diagnostic.filename,
123
+ line: diagnostic.start.line,
124
+ column: diagnostic.start.column,
125
+ },
126
+ });
127
+ }
128
+ return result;
85
129
  },
86
130
  };
87
131
  }
@@ -6,6 +6,27 @@ import type { CompileOptions } from '@mdx-js/mdx';
6
6
  export interface CompileMdxResult {
7
7
  code: string;
8
8
  map: unknown;
9
+ diagnostics: CompileMdxDiagnostic[];
10
+ }
11
+
12
+ export interface CompileMdxDiagnosticPosition {
13
+ offset: number;
14
+ line: number;
15
+ column: number;
16
+ }
17
+
18
+ export interface CompileMdxDiagnostic {
19
+ code: string;
20
+ severity: 'warning';
21
+ message: string;
22
+ filename: string;
23
+ start: CompileMdxDiagnosticPosition;
24
+ end: CompileMdxDiagnosticPosition;
25
+ suggestions: Array<{
26
+ start: CompileMdxDiagnosticPosition;
27
+ end: CompileMdxDiagnosticPosition;
28
+ attribute: 'onInput' | 'onInputCapture';
29
+ }>;
9
30
  }
10
31
 
11
32
  export interface CompileMdxOptions {
@@ -15,6 +36,8 @@ export interface CompileMdxOptions {
15
36
  hmr?: boolean;
16
37
  /** octane compiler dev metadata (client only; same gate as `hmr`). */
17
38
  dev?: boolean;
39
+ /** octane compiler profiling metadata (client only). */
40
+ profile?: boolean;
18
41
  /**
19
42
  * Module the emitted document reads the provider mapping from
20
43
  * (`useMDXComponents`). Defaults per mode — `'@octanejs/mdx'` (client) /
package/types/vite.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  // Hand-written declarations for src/vite.js (authored in `.js` so it loads
2
2
  // from a consumer's vite.config.ts under Node's native ESM loader — same
3
3
  // convention as @octanejs/vite-plugin's types/). Keep in lockstep.
4
- import type { CompileMdxOptions } from './compile.js';
4
+ import type { CompileMdxOptions, CompileMdxResult } from './compile.js';
5
5
 
6
6
  export interface OctaneMdxPluginOptions extends Omit<CompileMdxOptions, 'mode' | 'hmr' | 'dev'> {
7
7
  /**
@@ -14,6 +14,8 @@ export interface OctaneMdxPluginOptions extends Omit<CompileMdxOptions, 'mode' |
14
14
  md?: boolean;
15
15
  /** octane HMR/dev metadata override; defaults to on in serve mode (client only). */
16
16
  hmr?: boolean;
17
+ /** Enable component profiling metadata in client modules. */
18
+ profile?: boolean;
17
19
  }
18
20
 
19
21
  /**
@@ -23,13 +25,23 @@ export interface OctaneMdxPluginOptions extends Omit<CompileMdxOptions, 'mode' |
23
25
  export interface OctaneMdxPlugin {
24
26
  name: string;
25
27
  enforce: 'pre';
26
- configResolved(config: { command: string }): void;
28
+ configResolved(config: { command: string; root?: string }): void;
29
+ watchChange(id: string): void;
27
30
  transform(
28
- this: unknown,
31
+ this: {
32
+ addWatchFile?(id: string): void;
33
+ warn?(warning: {
34
+ code: string;
35
+ message: string;
36
+ id: string;
37
+ loc: { file: string; line: number; column: number };
38
+ }): void;
39
+ environment?: { config?: { consumer?: string } };
40
+ },
29
41
  code: string,
30
42
  id: string,
31
43
  options?: { ssr?: boolean },
32
- ): Promise<{ code: string; map: unknown } | null>;
44
+ ): Promise<CompileMdxResult | null>;
33
45
  }
34
46
 
35
47
  export declare function octaneMdx(options?: OctaneMdxPluginOptions): OctaneMdxPlugin;