@tsrx/core 0.1.42 → 0.1.44

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/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "description": "Core compiler infrastructure for TSRX syntax",
4
4
  "license": "MIT",
5
5
  "author": "Dominic Gannaway",
6
- "version": "0.1.42",
6
+ "version": "0.1.44",
7
7
  "type": "module",
8
8
  "repository": {
9
9
  "type": "git",
@@ -58,6 +58,18 @@ export function is_jsdoc_ts_annotation(comment) {
58
58
  return tsAnnotations.some((annotation) => comment.value.includes(annotation));
59
59
  }
60
60
 
61
+ /**
62
+ * Check if a comment is a TypeScript JSX pragma (`@jsxImportSource`,
63
+ * `@jsxRuntime`, `@jsxFrag`, `@jsx`). TS reads these from a file's LEADING
64
+ * comments to pick that file's JSX type source/factory, overriding tsconfig —
65
+ * dropping one silently retypes every JSX expression in the file.
66
+ * @param {AST.CommentWithLocation} comment
67
+ * @returns {boolean}
68
+ */
69
+ export function is_jsx_pragma(comment) {
70
+ return /@jsx(ImportSource|Runtime|Frag)?\b/.test(comment.value);
71
+ }
72
+
61
73
  /**
62
74
  * Check if a comment should be preserved in to_ts mode
63
75
  * @param {AST.CommentWithLocation} comment
@@ -65,7 +77,10 @@ export function is_jsdoc_ts_annotation(comment) {
65
77
  */
66
78
  export function should_preserve_comment(comment) {
67
79
  return (
68
- is_ts_pragma(comment) || is_triple_slash_directive(comment) || is_jsdoc_ts_annotation(comment)
80
+ is_ts_pragma(comment) ||
81
+ is_triple_slash_directive(comment) ||
82
+ is_jsdoc_ts_annotation(comment) ||
83
+ is_jsx_pragma(comment)
69
84
  );
70
85
  }
71
86
 
@@ -61,6 +61,24 @@ export const mapping_data_completion_only = {
61
61
  completion: true,
62
62
  };
63
63
 
64
+ /**
65
+ * Full language support minus editor repainting, for a generated IDENTIFIER
66
+ * whose SOURCE span sits inside a string literal (e.g. the namespace
67
+ * reference a server-module lowering derives from the authored `'server'`
68
+ * import specifier). Hover, go-to-def, references, and diagnostics resolve
69
+ * through the mapping — `semantic` stays truthy via the object form, which
70
+ * Volar's `isHoverEnabled` accepts — but semantic TOKENS are suppressed with
71
+ * `shouldHighlight: () => false` so the span keeps its authored TextMate
72
+ * (string) coloring instead of being repainted as a variable. Completion is
73
+ * off: identifier completions inside a string literal are never valid.
74
+ * @type {Partial<VolarCodeMapping['data']>}
75
+ */
76
+ export const mapping_data_string_span = {
77
+ ...mapping_data,
78
+ completion: false,
79
+ semantic: { shouldHighlight: () => false },
80
+ };
81
+
64
82
  /**
65
83
  * Convert byte offset to line/column
66
84
  * @param {number} offset
@@ -2,6 +2,7 @@
2
2
  /** @import { Visitors } from 'zimmerframe' */
3
3
 
4
4
  import tsx from 'esrap/languages/tsx';
5
+ import { should_preserve_comment, format_comment } from '../../comment-utils.js';
5
6
 
6
7
  /**
7
8
  * Zimmerframe provides `path` as the ancestor chain. A native template node in
@@ -68,10 +69,32 @@ export function set_node_path_metadata(node, path) {
68
69
  * (structural tokens carry one-character source locations). typeOnly/volar
69
70
  * prints opt in — their maps are consumed positionally by the language
70
71
  * tooling and never shipped; build prints stay sparse.
72
+ * @param {AST.CommentWithLocation[]} [comments] Source comments; the ones
73
+ * `should_preserve_comment` classifies as semantic-to-TS (`@ts-nocheck`,
74
+ * `@jsxImportSource`, triple-slash references, …) and that LEAD the program
75
+ * are re-emitted at the top of the printed output. The generated TSX is real
76
+ * TS input — dropping a leading pragma changes how the whole file checks.
71
77
  */
72
- export function tsx_with_ts_locations(boundary_tokens = false) {
78
+ export function tsx_with_ts_locations(boundary_tokens = false, comments = undefined) {
73
79
  const base = /** @type {any} */ (tsx({ boundaryTokens: boundary_tokens }));
74
80
 
81
+ const leading_preserved = (/** @type {any} */ program) => {
82
+ if (!comments?.length) return [];
83
+ // Injected statements (dynamic-import/try-import prepends) carry no
84
+ // loc; anchor "leading" on the first statement that maps to source,
85
+ // else every preserved comment in the file would hoist to the top.
86
+ const first = program.body?.find((/** @type {any} */ node) => node.loc);
87
+ return comments.filter(
88
+ (/** @type {any} */ comment) =>
89
+ should_preserve_comment(comment) &&
90
+ (first?.loc == null ||
91
+ (comment.loc &&
92
+ (comment.loc.end.line < first.loc.start.line ||
93
+ (comment.loc.end.line === first.loc.start.line &&
94
+ comment.loc.end.column <= first.loc.start.column)))),
95
+ );
96
+ };
97
+
75
98
  /**
76
99
  * @param {any} node
77
100
  * @param {any} context
@@ -89,6 +112,15 @@ export function tsx_with_ts_locations(boundary_tokens = false) {
89
112
 
90
113
  /** @type {Record<string, (node: any, context: any) => void>} */
91
114
  const wrappers = {
115
+ Program: (node, context) => {
116
+ for (const comment of leading_preserved(node)) {
117
+ if (comment.loc) context.location(comment.loc.start.line, comment.loc.start.column);
118
+ context.write(format_comment(comment));
119
+ if (comment.loc) context.location(comment.loc.end.line, comment.loc.end.column);
120
+ context.newline();
121
+ }
122
+ base.Program(node, context);
123
+ },
92
124
  ArrayPattern: (node, context) => {
93
125
  base.ArrayPattern(node, context);
94
126
  if (node.typeAnnotation) {
@@ -60,6 +60,7 @@ import {
60
60
  capture_jsx_child as captureJsxChild,
61
61
  } from '../jsx-interleave.js';
62
62
  import { is_hoist_safe_jsx_node } from '../jsx-hoist.js';
63
+ import { lower_server_module_for_types } from './server-module.js';
63
64
 
64
65
  const TEMPLATE_FRAGMENT_ERROR =
65
66
  'JSX fragment syntax is not needed in TSRX templates. TSRX renders in immediate mode, so everything is already a fragment. Use `<>...</>` only in expression position.';
@@ -631,6 +632,19 @@ export function createJsxTransform(platform) {
631
632
  ...(platform.hooks?.initialState?.() ?? {}),
632
633
  };
633
634
 
635
+ // Opt-in server-module dialect (`module <name> { … }` plus its boundary
636
+ // `import … from '<specifier>'`): lower to plain checkable TS before any
637
+ // other pass sees the program. TYPE-ONLY output only — the runtime/build
638
+ // emit never reaches this branch, because the platform's own compiler
639
+ // owns the dialect's real codegen (isolation validation, RPC stubs).
640
+ // Copy-on-write: a program without a server block passes through as the
641
+ // same object.
642
+ if (transform_context.typeOnly && platform.serverModule) {
643
+ ast = /** @type {any} */ (
644
+ lower_server_module_for_types(/** @type {any} */ (ast), platform.serverModule)
645
+ );
646
+ }
647
+
634
648
  ast = expand_child_code_blocks(/** @type {any} */ (ast));
635
649
  ast = wrap_control_flow_expression_values(/** @type {any} */ (ast), transform_context);
636
650
 
@@ -841,10 +855,20 @@ export function createJsxTransform(platform) {
841
855
  : apply_lazy_transforms(/** @type {any} */ (lowered_program), new Map())
842
856
  );
843
857
 
844
- const result = print(final_program, tsx_with_ts_locations(transform_context.typeOnly), {
845
- sourceMapSource: filename,
846
- sourceMapContent: source,
847
- });
858
+ const result = print(
859
+ final_program,
860
+ // typeOnly output is real TS input: re-emit preserved leading comments
861
+ // (@jsxImportSource / @ts-nocheck / triple-slash references) so TS
862
+ // semantics survive the comment-stripping print.
863
+ tsx_with_ts_locations(
864
+ transform_context.typeOnly,
865
+ transform_context.typeOnly ? transform_context.comments : undefined,
866
+ ),
867
+ {
868
+ sourceMapSource: filename,
869
+ sourceMapContent: source,
870
+ },
871
+ );
848
872
 
849
873
  const { css, cssHash } = render_css_result(/** @type {any} */ (stylesheets));
850
874
 
@@ -0,0 +1,565 @@
1
+ /** @import * as AST from 'estree' */
2
+ /** @import { JsxPlatform } from '@tsrx/core/types' */
3
+
4
+ /**
5
+ * Type-only lowering of a platform's `module <name> { … }` server-module
6
+ * dialect (opt-in via `platform.serverModule`).
7
+ *
8
+ * The platform's runtime compiler owns the real semantics of a server
9
+ * block: it validates isolation, emits the server namespace for SSR, and
10
+ * replaces `import { fn } from '<specifier>'` with RPC stubs for the
11
+ * browser. The type-only path never runs that codegen — it prints the
12
+ * parsed AST as virtual TSX, which used to render the block verbatim.
13
+ * Verbatim `module server { import … }` can NEVER typecheck: a static
14
+ * import inside a namespace body is TS1147, and the companion
15
+ * `import { fn } from 'server'` is TS2307 (no such module). So the dialect
16
+ * was un-typecheckable in editors.
17
+ *
18
+ * This module rewrites the PARSED ast (before the type-only transform
19
+ * prints it) into plain, checkable TypeScript with identical types:
20
+ *
21
+ * module server { import { db } from './db.ts';
22
+ * import { db } from './db.ts'; namespace server {
23
+ * export function f() { … } → export function f() { … }
24
+ * } }
25
+ * import { f } from 'server'; import f = server.f;
26
+ *
27
+ * The lowered namespace keeps the AUTHORED name and identifier location, so
28
+ * hovering the block's name resolves, and the aliases' namespace
29
+ * references mark the namespace as used (a server block nobody imports from
30
+ * is the ONE case `noUnusedLocals` still flags — on the authored name, which
31
+ * is the correct signal). A `declare module '<specifier>'` bridge (which
32
+ * would have let the authored import statement survive verbatim) is NOT
33
+ * possible: the virtual TSX is a module, where `declare module 'server'` is
34
+ * a module AUGMENTATION — TS2664 when no module 'server' exists, and TS2666
35
+ * for the `export =` even when a global stub supplies one; augmentations
36
+ * also merge program-wide, which would break the dialect's file-local
37
+ * semantics. Each lowered alias keeps the import's specifier locations, so
38
+ * hover/rename on the imported names and on the `'<specifier>'` source
39
+ * still resolve.
40
+ *
41
+ * Block imports hoist to module top level (namespaces close over module
42
+ * scope, so the body still resolves them — `noUnusedLocals` counts those
43
+ * uses), and each boundary import becomes `import x = server.x` aliases
44
+ * that keep every meaning of the export — an authored named import binds
45
+ * value AND type, so a class or enum stays usable as a type (type-only
46
+ * specifiers become `type x = server.x` aliases; a string-named import
47
+ * falls back to a value-only destructure). When a
48
+ * hoisted import's local name is also used anywhere in the client module
49
+ * (the compiler's isolation rule stops the server block from referencing
50
+ * client bindings, but nothing stops both sides from importing — or
51
+ * referencing a global named — `db`), hoisting it verbatim would collide or
52
+ * shadow. Those imports hoist as a mangled namespace import instead —
53
+ * always a VALUE import keeping the authored `with { … }` attributes,
54
+ * because an import-equals alias cannot reference a type-only import
55
+ * (TS1380) — and each original binding is rebuilt inside the namespace:
56
+ * `import db = __tsrx_server_import$0.db;` for value specifiers (the one
57
+ * alias form that keeps EVERY meaning, so a colliding class import stays
58
+ * usable as a type) and `type T = __tsrx_server_import$0.T;` for type-only
59
+ * ones. Three corners cannot use import-equals: a string-named import
60
+ * (`'x y' as db`) keeps a value-only destructure even when type-only — a
61
+ * string can never appear in an entity name OR a qualified type
62
+ * reference, so the destructure is the one parseable fallback; a value
63
+ * DEFAULT import hoists an extra
64
+ * mangled DEFAULT specifier rebound with `const` (no entity name reaches
65
+ * a default — `import db = ns.default` is TS1359 and the default binding
66
+ * itself has no namespace meaning — so a colliding default CLASS import
67
+ * keeps only its value meaning, while a type-only default becomes
68
+ * `type x = ns.default`); and a type-only NAMESPACE import rebinds as a
69
+ * plain import-equals, which adds a value meaning the authored form
70
+ * lacked. Each is an acceptable corner — a collision already requires the
71
+ * client half to use the same name.
72
+ *
73
+ * The rewrite is copy-on-write: every replacement node is built with
74
+ * spreads/builders and carries the ORIGINAL node's start/end/loc wherever it
75
+ * corresponds to authored code, so the transform's esrap print emits real
76
+ * source-mapped segments and hover / go-to-def / diagnostics keep mapping
77
+ * back to the source. The original parse is never mutated.
78
+ */
79
+
80
+ import * as b from '../../utils/builders.js';
81
+
82
+ const HOISTED_IMPORT_PREFIX = '__tsrx_server_import$';
83
+
84
+ /** Object keys that never contain child AST nodes. */
85
+ const WALK_SKIP_KEYS = new Set([
86
+ 'loc',
87
+ 'start',
88
+ 'end',
89
+ 'range',
90
+ 'parent',
91
+ 'metadata',
92
+ 'leadingComments',
93
+ 'trailingComments',
94
+ 'comments',
95
+ ]);
96
+
97
+ /**
98
+ * A non-ambient `TSModuleDeclaration` authored with the `module` keyword,
99
+ * narrowed to the one name the platform's dialect supports. Blocks with any
100
+ * other name are a hard compile error in the runtime compiler, so leaving
101
+ * them verbatim (where TS flags them) mirrors the build failure instead of
102
+ * hiding it.
103
+ *
104
+ * @param {any} node
105
+ * @param {string} block_name
106
+ */
107
+ function is_server_module_declaration(node, block_name) {
108
+ return (
109
+ node?.type === 'TSModuleDeclaration' &&
110
+ node.declare !== true &&
111
+ node.metadata?.module_keyword === 'module' &&
112
+ identifier_name(node.id) === block_name
113
+ );
114
+ }
115
+
116
+ /**
117
+ * @param {any} node
118
+ * @returns {string | null}
119
+ */
120
+ function identifier_name(node) {
121
+ if (node?.type === 'Identifier') return node.name;
122
+ if (node?.type === 'Literal' && typeof node.value === 'string') return node.value;
123
+ return null;
124
+ }
125
+
126
+ /**
127
+ * @param {any} node
128
+ * @param {string} import_specifier
129
+ */
130
+ function is_server_import(node, import_specifier) {
131
+ return node?.type === 'ImportDeclaration' && node.source?.value === import_specifier;
132
+ }
133
+
134
+ /**
135
+ * Copy `node`'s authored location onto a replacement node.
136
+ * @param {any} node
137
+ * @param {any} source
138
+ * @returns {any}
139
+ */
140
+ function with_location(node, source) {
141
+ if (source?.start != null) node.start = source.start;
142
+ if (source?.end != null) node.end = source.end;
143
+ if (source?.loc != null) node.loc = source.loc;
144
+ node.metadata ??= { path: [] };
145
+ return node;
146
+ }
147
+
148
+ /**
149
+ * A namespace reference standing in for an authored STRING-LITERAL span (the
150
+ * `'server'` import source, or a string-named block id). It carries the
151
+ * literal's INNER span — the text between the quotes — as its source
152
+ * mapping: `source_length` pins the mapped length to that inner text, since
153
+ * the GENERATED identifier is the block name, whose length the authored
154
+ * specifier need not share (`blockName` and `importSpecifier` may differ).
155
+ * The `string_literal_source_span` metadata flag makes the mapping
156
+ * collector serve hover/navigation WITHOUT semantic tokens: repainting part
157
+ * of an authored string literal as a namespace token breaks the editor's
158
+ * string coloring (the quotes stay outside the mapping for the same
159
+ * reason). A literal without usable positions gets NO loc at all — the
160
+ * collector then skips the mapping entirely rather than deriving a span
161
+ * from the wrong length.
162
+ *
163
+ * @param {string} name
164
+ * @param {any} literal
165
+ * @returns {AST.Identifier}
166
+ */
167
+ function string_span_namespace_ref(name, literal) {
168
+ const node = b.id(name);
169
+ node.metadata.string_literal_source_span = true;
170
+ if (
171
+ typeof literal?.start === 'number' &&
172
+ typeof literal?.end === 'number' &&
173
+ literal.end - literal.start >= 2
174
+ ) {
175
+ node.start = literal.start + 1;
176
+ node.end = literal.end - 1;
177
+ node.metadata.source_length = literal.end - literal.start - 2;
178
+ if (literal.loc != null) {
179
+ // Fresh position objects: the authored literal's own loc must survive
180
+ // the transform untouched (copy-on-write), and a module specifier is
181
+ // always single-line, so shifting columns inside it is safe.
182
+ node.loc = {
183
+ start: { line: literal.loc.start.line, column: literal.loc.start.column + 1 },
184
+ end: { line: literal.loc.end.line, column: literal.loc.end.column - 1 },
185
+ };
186
+ }
187
+ }
188
+ return node;
189
+ }
190
+
191
+ /**
192
+ * Every identifier name that appears OUTSIDE the server block. This
193
+ * deliberately over-approximates "top-level client bindings": a hoisted
194
+ * server import may not only collide with a client import of the same name
195
+ * (a TS2300 duplicate we would introduce) but also shadow a GLOBAL the
196
+ * client code references (e.g. a server-side `import { crypto } from …`
197
+ * changing what client `crypto` resolves to). Treating any outside use of
198
+ * the name as a conflict costs nothing but an alias, and keeps the lowering
199
+ * from ever changing what the client half of the file typechecks against.
200
+ *
201
+ * @param {any} ast
202
+ * @param {any} declaration
203
+ * @returns {Set<string>}
204
+ */
205
+ function collect_outside_identifier_names(ast, declaration) {
206
+ /** @type {Set<string>} */
207
+ const names = new Set();
208
+ const seen = new WeakSet();
209
+ /** @param {any} node */
210
+ function walk(node) {
211
+ if (node === null || typeof node !== 'object' || seen.has(node) || node === declaration) {
212
+ return;
213
+ }
214
+ seen.add(node);
215
+ if (Array.isArray(node)) {
216
+ for (const child of node) walk(child);
217
+ return;
218
+ }
219
+ if (typeof node.type !== 'string') return;
220
+ if (node.type === 'Identifier' || node.type === 'JSXIdentifier') {
221
+ names.add(node.name);
222
+ }
223
+ for (const [key, value] of Object.entries(node)) {
224
+ if (WALK_SKIP_KEYS.has(key)) continue;
225
+ if (value !== null && typeof value === 'object') walk(value);
226
+ }
227
+ }
228
+ walk(ast);
229
+ return names;
230
+ }
231
+
232
+ /**
233
+ * `const { a, b: c } = <init>;` rebinding each specifier's imported
234
+ * name to its local name. Property nodes keep the authored specifier
235
+ * locations so hover / rename still target the source. `make_init`
236
+ * builds a fresh init expression per call (nodes are never shared).
237
+ *
238
+ * @param {any[]} specifiers
239
+ * @param {() => AST.Expression} make_init
240
+ * @param {any} loc_node
241
+ */
242
+ function build_destructure(specifiers, make_init, loc_node) {
243
+ const pattern = with_location(
244
+ b.object_pattern(
245
+ /** @type {any} */ (
246
+ specifiers.map((specifier) =>
247
+ with_location(
248
+ b.prop(
249
+ 'init',
250
+ { ...specifier.imported },
251
+ { ...specifier.local },
252
+ false,
253
+ specifier.imported?.type === 'Identifier' &&
254
+ specifier.imported.name === specifier.local?.name,
255
+ ),
256
+ specifier,
257
+ ),
258
+ )
259
+ ),
260
+ ),
261
+ loc_node,
262
+ );
263
+ const declarator = with_location(b.declarator(pattern, make_init()), loc_node);
264
+ return with_location(b.declaration('const', [declarator]), loc_node);
265
+ }
266
+
267
+ /**
268
+ * `<left>.<right>` qualified name. Callers pass a `right` that carries the
269
+ * authored specifier span, so hover / rename on the imported name resolve.
270
+ *
271
+ * @param {AST.Identifier} left
272
+ * @param {any} right
273
+ */
274
+ function qualified_name(left, right) {
275
+ return {
276
+ type: 'TSQualifiedName',
277
+ left,
278
+ right,
279
+ metadata: { path: [] },
280
+ };
281
+ }
282
+
283
+ /**
284
+ * `type <local> = <left>.<right>;` for a type-only import specifier.
285
+ * `right` defaults to the specifier's imported name; a DEFAULT import
286
+ * passes `default` (which has no authored span of its own).
287
+ *
288
+ * @param {any} specifier
289
+ * @param {() => AST.Identifier} make_left
290
+ * @param {any} [right]
291
+ */
292
+ function build_type_alias(specifier, make_left, right = { ...specifier.imported }) {
293
+ return with_location(
294
+ b.ts_type_alias(
295
+ { ...specifier.local },
296
+ /** @type {any} */ ({
297
+ type: 'TSTypeReference',
298
+ typeName: qualified_name(make_left(), right),
299
+ metadata: { path: [] },
300
+ }),
301
+ ),
302
+ specifier,
303
+ );
304
+ }
305
+
306
+ /**
307
+ * `import <local> = <reference>;` — an import-equals alias, the one form
308
+ * that preserves every meaning (value, type, namespace) of the referenced
309
+ * binding, where a `const` keeps only the value and a `type` alias only
310
+ * the type.
311
+ *
312
+ * @param {any} specifier
313
+ * @param {any} module_reference
314
+ */
315
+ function build_import_equals(specifier, module_reference) {
316
+ return with_location(
317
+ /** @type {any} */ ({
318
+ type: 'TSImportEqualsDeclaration',
319
+ id: { ...specifier.local },
320
+ moduleReference: module_reference,
321
+ importKind: 'value',
322
+ metadata: { path: [] },
323
+ }),
324
+ specifier,
325
+ );
326
+ }
327
+
328
+ /**
329
+ * Lower one block import whose local name(s) collide with outside code:
330
+ * hoist under mangled names as a VALUE import (an import-equals alias
331
+ * cannot reference a type-only import — TS1380) and rebuild each original
332
+ * binding inside the namespace body. Named value specifiers and namespace
333
+ * specifiers become `import x = …` aliases keeping every meaning of the
334
+ * mangled namespace specifier; type-only specifiers become `type` aliases
335
+ * (`<ns>.default` for a type-only default); string-named imports — even
336
+ * type-only ones — destructure the namespace object (a string cannot
337
+ * appear in an entity name or qualified type reference). A value DEFAULT import has no entity-name path at all
338
+ * (`import x = <ns>.default` is TS1359, and a default-import binding has
339
+ * no namespace meaning), so it hoists an extra mangled DEFAULT specifier
340
+ * and rebinds it with `const` — exact default-import semantics, which
341
+ * `<ns>.default` is not (a JSON module's namespace has no `default`
342
+ * member under bundler resolution).
343
+ *
344
+ * @param {any} statement
345
+ * @param {string} hoisted_name
346
+ */
347
+ function lower_colliding_import(statement, hoisted_name) {
348
+ const default_name = hoisted_name + '_default';
349
+ const aliases = [];
350
+ const destructured_specifiers = [];
351
+ let needs_default_hoist = false;
352
+ let needs_namespace_hoist = false;
353
+ for (const specifier of statement.specifiers) {
354
+ const type_only = statement.importKind === 'type' || specifier.importKind === 'type';
355
+ if (specifier.type === 'ImportNamespaceSpecifier') {
356
+ needs_namespace_hoist = true;
357
+ aliases.push(build_import_equals(specifier, b.id(hoisted_name)));
358
+ } else if (specifier.type === 'ImportDefaultSpecifier') {
359
+ if (type_only) {
360
+ needs_namespace_hoist = true;
361
+ aliases.push(build_type_alias(specifier, () => b.id(hoisted_name), b.id('default')));
362
+ } else {
363
+ needs_default_hoist = true;
364
+ const declarator = with_location(
365
+ b.declarator({ ...specifier.local }, b.id(default_name)),
366
+ specifier,
367
+ );
368
+ aliases.push(with_location(b.declaration('const', [declarator]), specifier));
369
+ }
370
+ } else if (specifier.imported?.type !== 'Identifier') {
371
+ // String-named — neither an entity name nor a qualified type
372
+ // reference can hold a string, so type-only ones land here too:
373
+ // the destructure is the one PARSEABLE fallback, at the cost of
374
+ // binding only a value.
375
+ needs_namespace_hoist = true;
376
+ destructured_specifiers.push(specifier);
377
+ } else if (type_only) {
378
+ needs_namespace_hoist = true;
379
+ aliases.push(build_type_alias(specifier, () => b.id(hoisted_name)));
380
+ } else {
381
+ needs_namespace_hoist = true;
382
+ aliases.push(
383
+ build_import_equals(
384
+ specifier,
385
+ qualified_name(b.id(hoisted_name), { ...specifier.imported }),
386
+ ),
387
+ );
388
+ }
389
+ }
390
+ if (destructured_specifiers.length > 0) {
391
+ aliases.push(build_destructure(destructured_specifiers, () => b.id(hoisted_name), statement));
392
+ }
393
+
394
+ // Only the specifiers the aliases reference — an unreferenced mangled
395
+ // specifier would draw a spurious `noUnusedLocals` diagnostic.
396
+ const hoist_specifiers = [];
397
+ if (needs_default_hoist) {
398
+ hoist_specifiers.push({
399
+ type: 'ImportDefaultSpecifier',
400
+ local: b.id(default_name),
401
+ metadata: { path: [] },
402
+ });
403
+ }
404
+ if (needs_namespace_hoist) {
405
+ hoist_specifiers.push({
406
+ type: 'ImportNamespaceSpecifier',
407
+ local: b.id(hoisted_name),
408
+ metadata: { path: [] },
409
+ });
410
+ }
411
+ const hoisted = with_location(
412
+ {
413
+ type: 'ImportDeclaration',
414
+ specifiers: hoist_specifiers,
415
+ source: with_location({ ...statement.source }, statement.source),
416
+ importKind: 'value',
417
+ attributes: statement.attributes,
418
+ },
419
+ statement,
420
+ );
421
+ return { hoisted, aliases };
422
+ }
423
+
424
+ /**
425
+ * Replace the server block with hoisted imports plus a namespace-valued
426
+ * binding the checker can see through.
427
+ *
428
+ * @param {any} declaration
429
+ * @param {Set<string>} outside_names
430
+ * @param {string} block_name
431
+ */
432
+ function lower_declaration(declaration, outside_names, block_name) {
433
+ const hoisted_imports = [];
434
+ const aliases = [];
435
+ const rest = [];
436
+ let hoisted_index = 0;
437
+
438
+ for (const statement of declaration.body?.body ?? []) {
439
+ if (statement.type !== 'ImportDeclaration') {
440
+ rest.push(statement);
441
+ continue;
442
+ }
443
+ const collides = (statement.specifiers ?? []).some((/** @type {any} */ specifier) =>
444
+ outside_names.has(specifier.local?.name),
445
+ );
446
+ if (!collides) {
447
+ // Authored node, hoisted as-is — its locations map 1:1.
448
+ hoisted_imports.push(statement);
449
+ continue;
450
+ }
451
+ const lowered = lower_colliding_import(statement, HOISTED_IMPORT_PREFIX + hoisted_index++);
452
+ hoisted_imports.push(lowered.hoisted);
453
+ aliases.push(...lowered.aliases);
454
+ }
455
+
456
+ // The lowered namespace deliberately keeps the authored block name: the
457
+ // authored `module <name>` id already claims that name in the file (the
458
+ // runtime compiler declares it as a module binding), so reusing it cannot
459
+ // introduce a new collision, and it is what makes hover on the block's
460
+ // name — and on the boundary import's source, whose span the destructure's
461
+ // init identifier carries — resolve to the block. The id is always an
462
+ // Identifier (the authored id could be a string Literal, whose span then
463
+ // gets the same string-literal mapping treatment as the import source).
464
+ const id =
465
+ declaration.id?.type === 'Literal'
466
+ ? string_span_namespace_ref(block_name, declaration.id)
467
+ : with_location(b.id(block_name), declaration.id);
468
+ const namespace = {
469
+ ...declaration,
470
+ id,
471
+ metadata: { ...declaration.metadata, module_keyword: 'namespace' },
472
+ body: { ...declaration.body, body: [...aliases, ...rest] },
473
+ };
474
+ return [...hoisted_imports, namespace];
475
+ }
476
+
477
+ /**
478
+ * Rewrite one `import { x, type T } from '<specifier>'` statement into
479
+ * bindings on the lowered namespace. Value specifiers become
480
+ * `import x = <ns>.x;` aliases — the authored import carries EVERY
481
+ * meaning of the export, so a `const { x } = <ns>;` destructure would
482
+ * strip the type meaning of a class or enum. Type-only specifiers become
483
+ * `type T = <ns>.T;` aliases; a string-named import — even a type-only
484
+ * one, since neither an entity name nor a qualified type reference can
485
+ * express it — falls back to a value-only destructure. A specifier-less
486
+ * boundary import binds nothing and is dropped (the runtime compiler
487
+ * accepts and elides it), while non-named specifiers (default / namespace
488
+ * imports) are a hard compile error in the dialect — those statements
489
+ * stay verbatim so the editor's TS2307 mirrors the build error.
490
+ *
491
+ * @param {any} statement
492
+ * @param {string} block_name
493
+ */
494
+ function lower_server_import(statement, block_name) {
495
+ const specifiers = statement.specifiers ?? [];
496
+ if (specifiers.length === 0) return [];
497
+ if (specifiers.some((/** @type {any} */ s) => s.type !== 'ImportSpecifier')) {
498
+ return [statement];
499
+ }
500
+
501
+ // Each reference to the namespace carries the authored import source's
502
+ // inner span, so hover / go-to-def on the module name resolves to the
503
+ // lowered block while the literal keeps its string coloring.
504
+ const namespace_ref = () => string_span_namespace_ref(block_name, statement.source);
505
+ const lowered = [];
506
+ const destructured_specifiers = [];
507
+ for (const specifier of specifiers) {
508
+ if (specifier.imported?.type !== 'Identifier') {
509
+ // String-named — inexpressible in an entity name or qualified type
510
+ // reference alike, so type-only ones fall back here too.
511
+ destructured_specifiers.push(specifier);
512
+ } else if (statement.importKind === 'type' || specifier.importKind === 'type') {
513
+ lowered.push(build_type_alias(specifier, namespace_ref));
514
+ } else {
515
+ lowered.push(
516
+ build_import_equals(specifier, qualified_name(namespace_ref(), { ...specifier.imported })),
517
+ );
518
+ }
519
+ }
520
+ if (destructured_specifiers.length > 0) {
521
+ lowered.push(build_destructure(destructured_specifiers, namespace_ref, statement));
522
+ }
523
+ return lowered;
524
+ }
525
+
526
+ /**
527
+ * Lower the server-module dialect in a parsed program to plain TS the type
528
+ * checker accepts. Returns the ORIGINAL ast unchanged (same object) when
529
+ * the file has no server block; otherwise returns a new Program that shares
530
+ * every untouched statement with the original parse.
531
+ *
532
+ * Only the FIRST server declaration is lowered — a second one is a hard
533
+ * compile error in the runtime compiler, and leaving it verbatim surfaces a
534
+ * TS error in the same place. Likewise a boundary import without any server
535
+ * block stays verbatim (TS2307), mirroring the build error.
536
+ *
537
+ * @param {AST.Program} ast
538
+ * @param {NonNullable<JsxPlatform['serverModule']>} server_module
539
+ * @returns {AST.Program}
540
+ */
541
+ export function lower_server_module_for_types(ast, server_module) {
542
+ const { blockName: block_name, importSpecifier: import_specifier } = server_module;
543
+ const body = /** @type {any[] | undefined} */ (ast?.body);
544
+ if (!Array.isArray(body)) return ast;
545
+ const declaration = body.find((node) => is_server_module_declaration(node, block_name));
546
+ // A body-less `module server;` only occurs mid-edit / in loose parses;
547
+ // leave it for TS to flag rather than fabricating an empty namespace.
548
+ if (declaration === undefined || !Array.isArray(declaration.body?.body)) return ast;
549
+
550
+ const outside_names = collect_outside_identifier_names(ast, declaration);
551
+ // The lowered namespace claims the authored block name at module scope; a
552
+ // block import local with the same name must be aliased out of its way.
553
+ outside_names.add(block_name);
554
+ const new_body = [];
555
+ for (const statement of body) {
556
+ if (statement === declaration) {
557
+ new_body.push(...lower_declaration(declaration, outside_names, block_name));
558
+ } else if (is_server_import(statement, import_specifier)) {
559
+ new_body.push(...lower_server_import(statement, block_name));
560
+ } else {
561
+ new_body.push(statement);
562
+ }
563
+ }
564
+ return { ...ast, body: new_body };
565
+ }
@@ -60,6 +60,7 @@ import {
60
60
  mapping_data_verify_only,
61
61
  mapping_data_verify_complete,
62
62
  mapping_data_completion_only,
63
+ mapping_data_string_span,
63
64
  build_line_offsets,
64
65
  get_mapping_from_node,
65
66
  } from '../source-map-utils.js';
@@ -586,6 +587,14 @@ export function convert_source_map_to_mappings(
586
587
  if (node.metadata?.disable_verification) {
587
588
  token.mappingData = { ...mapping_data, verification: false };
588
589
  }
590
+ // A generated identifier whose source span sits inside a string
591
+ // literal (e.g. a server-module lowering's namespace reference
592
+ // carrying the authored `'server'` import specifier): serve
593
+ // hover/navigation but never semantic tokens, so the span keeps
594
+ // its TextMate string coloring.
595
+ if (node.metadata?.string_literal_source_span) {
596
+ token.mappingData = mapping_data_string_span;
597
+ }
589
598
  tokens.push(token);
590
599
  add_extra_source_mapping_tokens(node);
591
600
 
@@ -2231,6 +2240,16 @@ export function convert_source_map_to_mappings(
2231
2240
  visit(node.expression);
2232
2241
  }
2233
2242
  return;
2243
+ } else if (node.type === 'TSImportEqualsDeclaration') {
2244
+ // TypeScript import alias: import foo = ns.bar;
2245
+ // Visit in source order: id, then the referenced entity name
2246
+ if (node.id) {
2247
+ visit(node.id);
2248
+ }
2249
+ if (node.moduleReference) {
2250
+ visit(node.moduleReference);
2251
+ }
2252
+ return;
2234
2253
  } else if (node.type === 'TSInstantiationExpression') {
2235
2254
  // TypeScript instantiation expression: new Foo<T>()
2236
2255
  if (node.expression) {
package/types/index.d.ts CHANGED
@@ -79,6 +79,14 @@ interface BaseNodeMetaData {
79
79
  source_name?: string;
80
80
  source_length?: number;
81
81
  module_keyword?: 'module' | 'namespace';
82
+ /**
83
+ * Generated identifier whose SOURCE span sits inside an authored string
84
+ * literal (e.g. a server-module lowering's namespace reference carrying
85
+ * the `'server'` import specifier). The mapping collector serves
86
+ * hover/navigation from it but disables semantic tokens so the span
87
+ * keeps its string coloring.
88
+ */
89
+ string_literal_source_span?: boolean;
82
90
  is_capitalized?: boolean;
83
91
  commentContainerId?: number;
84
92
  parenthesized?: boolean;
@@ -971,7 +979,13 @@ declare module 'estree' {
971
979
  typeParameters: TSTypeParameterDeclaration | undefined;
972
980
  parameters: Parameter[];
973
981
  }
974
- interface TSImportEqualsDeclaration extends AcornTSNode<TSESTree.TSImportEqualsDeclaration> {}
982
+ interface TSImportEqualsDeclaration extends Omit<
983
+ AcornTSNode<TSESTree.TSImportEqualsDeclaration>,
984
+ 'id' | 'moduleReference'
985
+ > {
986
+ id: AST.Identifier;
987
+ moduleReference: EntityName | TSExternalModuleReference;
988
+ }
975
989
  interface TSImportType extends Omit<
976
990
  AcornTSNode<TSESTree.TSImportType>,
977
991
  'argument' | 'qualifier' | 'typeParameters'
@@ -434,6 +434,26 @@ export interface JsxPlatform {
434
434
  unsupportedTryPendingMessage?: string;
435
435
  };
436
436
 
437
+ /**
438
+ * Opt-in lowering for a platform's file-local server-module dialect in
439
+ * TYPE-ONLY output. When set, a non-ambient `module <blockName> { … }`
440
+ * block and its companion `import { x } from '<importSpecifier>'`
441
+ * statements are rewritten into plain checkable TS (block imports hoisted
442
+ * to module scope, the block itself lowered to a namespace keeping the
443
+ * authored name, boundary imports lowered to destructures / `type`
444
+ * aliases on that namespace) before the type-only transform prints them.
445
+ * Verbatim, the dialect can never typecheck: a static import inside a
446
+ * namespace body is TS1147 and the boundary import is TS2307. Runtime /
447
+ * build output is untouched — the platform's own compiler owns the
448
+ * dialect's real codegen. When absent, the pre-pass is skipped entirely.
449
+ */
450
+ serverModule?: {
451
+ /** Authored block name (`module server { … }` → `'server'`). */
452
+ blockName: string;
453
+ /** Boundary import's module specifier (`from 'server'` → `'server'`). */
454
+ importSpecifier: string;
455
+ };
456
+
437
457
  /**
438
458
  * Optional overrides for parts of the transform that diverge substantially
439
459
  * between platforms (control flow, component lowering, imports, element