@mandujs/core 0.54.5 → 0.54.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/package.json +1 -1
- package/src/agent/__tests__/context.test.ts +237 -237
- package/src/agent/types.ts +308 -308
- package/src/agent/verify.ts +406 -406
- package/src/bundler/__tests__/build-runner.ts +36 -18
- package/src/bundler/__tests__/cold-start.test.ts +25 -1
- package/src/bundler/build.test.ts +35 -0
- package/src/bundler/build.ts +41 -16
- package/src/generator/generate.ts +30 -25
- package/src/resource/__tests__/generator.test.ts +6 -4
- package/src/resource/ddl/__tests__/emit.test.ts +165 -49
- package/src/resource/ddl/emit.ts +146 -51
- package/src/resource/generator-schema.ts +11 -15
- package/src/router/client-entry.test.ts +71 -0
- package/src/router/client-entry.ts +286 -0
- package/src/router/fs-scanner.ts +21 -8
- package/src/runtime/server.ts +16 -8
- package/src/watcher/__tests__/watcher.test.ts +59 -0
- package/src/watcher/watcher.ts +61 -22
package/src/resource/ddl/emit.ts
CHANGED
|
@@ -15,10 +15,11 @@
|
|
|
15
15
|
* 4. Value literals in `DEFAULT` clauses flow through `resolveDefault`
|
|
16
16
|
* which handles quote escaping. `kind: "sql"` is the explicit
|
|
17
17
|
* escape hatch — caller's responsibility.
|
|
18
|
-
* 5. Unsupported changes (v1 scope — `alter-column-type
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
18
|
+
* 5. Unsupported changes (v1 scope — `alter-column-type`, and some
|
|
19
|
+
* dialect-specific ALTER gaps) emit a `-- TODO:` comment block plus
|
|
20
|
+
* a deliberate failing sentinel statement. The generated migration
|
|
21
|
+
* is reviewable/editable, but `mandu db apply` will not record it
|
|
22
|
+
* until the operator replaces the TODO with real SQL.
|
|
22
23
|
*
|
|
23
24
|
* Determinism:
|
|
24
25
|
* - `emitCreateTable` emits columns in `DdlFieldDef` array order — the
|
|
@@ -338,15 +339,16 @@ export function emitChange(change: Change, provider: SqlProvider): string {
|
|
|
338
339
|
// Change emitters (internal — called only via emitChange dispatch).
|
|
339
340
|
// =====================================================================
|
|
340
341
|
|
|
341
|
-
function emitAddColumn(
|
|
342
|
-
resourceName: string,
|
|
343
|
-
field: DdlFieldDef,
|
|
344
|
-
provider: SqlProvider,
|
|
345
|
-
): string {
|
|
346
|
-
|
|
347
|
-
const
|
|
348
|
-
const
|
|
349
|
-
|
|
342
|
+
function emitAddColumn(
|
|
343
|
+
resourceName: string,
|
|
344
|
+
field: DdlFieldDef,
|
|
345
|
+
provider: SqlProvider,
|
|
346
|
+
): string {
|
|
347
|
+
validateAddColumn(resourceName, field, provider);
|
|
348
|
+
const table = quoteIdent(resourceName, provider);
|
|
349
|
+
const columnDef = emitColumnDef(field, provider);
|
|
350
|
+
const addColumn = `ALTER TABLE ${table} ADD COLUMN ${columnDef};`;
|
|
351
|
+
if (!field.indexed || field.unique || field.primary) return addColumn;
|
|
350
352
|
return [
|
|
351
353
|
addColumn,
|
|
352
354
|
emitCreateIndex(
|
|
@@ -356,8 +358,85 @@ function emitAddColumn(
|
|
|
356
358
|
false,
|
|
357
359
|
provider,
|
|
358
360
|
),
|
|
359
|
-
].join("\n");
|
|
360
|
-
}
|
|
361
|
+
].join("\n");
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function validateAddColumn(
|
|
365
|
+
resourceName: string,
|
|
366
|
+
field: DdlFieldDef,
|
|
367
|
+
provider: SqlProvider,
|
|
368
|
+
): void {
|
|
369
|
+
if (provider !== "sqlite") return;
|
|
370
|
+
|
|
371
|
+
if (field.primary) {
|
|
372
|
+
throw new Error(
|
|
373
|
+
`SQLite cannot add PRIMARY KEY column "${field.name}" to existing table "${resourceName}" via ALTER TABLE. ` +
|
|
374
|
+
`Create a manual table-rebuild migration instead.`,
|
|
375
|
+
);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
if (field.unique) {
|
|
379
|
+
throw new Error(
|
|
380
|
+
`SQLite cannot add UNIQUE column "${field.name}" to existing table "${resourceName}" via ALTER TABLE. ` +
|
|
381
|
+
`Add a nullable/non-unique column first, backfill it, then create a unique index manually.`,
|
|
382
|
+
);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const defaultIssue = sqliteAddColumnDefaultIssue(field);
|
|
386
|
+
if (defaultIssue) {
|
|
387
|
+
throw new Error(
|
|
388
|
+
`SQLite cannot add column "${field.name}" to existing table "${resourceName}" with this DEFAULT: ` +
|
|
389
|
+
`${defaultIssue} Use a scalar literal default or write a manual backfill migration.`,
|
|
390
|
+
);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
if (field.nullable) return;
|
|
394
|
+
if (field.default !== undefined && !isSqliteNullDefault(field.default)) return;
|
|
395
|
+
|
|
396
|
+
throw new Error(
|
|
397
|
+
`SQLite cannot add required column "${field.name}" to existing table "${resourceName}" without a non-NULL constant DEFAULT. ` +
|
|
398
|
+
`Add a scalar default, set required:false, or write a manual backfill migration.`,
|
|
399
|
+
);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function sqliteAddColumnDefaultIssue(field: DdlFieldDef): string | null {
|
|
403
|
+
const def = field.default;
|
|
404
|
+
if (!def) return null;
|
|
405
|
+
if (def.kind === "now") {
|
|
406
|
+
return `default "now" maps to CURRENT_TIMESTAMP, which SQLite rejects in ADD COLUMN.`;
|
|
407
|
+
}
|
|
408
|
+
if (def.kind !== "sql") return null;
|
|
409
|
+
|
|
410
|
+
const expr = def.expr.trim();
|
|
411
|
+
if (/^CURRENT_(?:TIME|DATE|TIMESTAMP)\b/i.test(expr)) {
|
|
412
|
+
return `SQLite rejects CURRENT_TIME/CURRENT_DATE/CURRENT_TIMESTAMP in ADD COLUMN.`;
|
|
413
|
+
}
|
|
414
|
+
if (/\b[A-Za-z_][A-Za-z0-9_]*\s*\(/.test(expr)) {
|
|
415
|
+
return `SQLite rejects non-constant function defaults in ADD COLUMN.`;
|
|
416
|
+
}
|
|
417
|
+
if (!isSqliteAddColumnConstantSqlDefault(expr)) {
|
|
418
|
+
return `SQLite ADD COLUMN only accepts literal constant defaults; raw SQL expression ${JSON.stringify(expr)} is not safe to emit automatically.`;
|
|
419
|
+
}
|
|
420
|
+
return null;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
function isSqliteNullDefault(def: NonNullable<DdlFieldDef["default"]>): boolean {
|
|
424
|
+
return def.kind === "null" || (def.kind === "sql" && /^NULL$/i.test(def.expr.trim()));
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function isSqliteAddColumnConstantSqlDefault(expr: string): boolean {
|
|
428
|
+
let value = expr.trim();
|
|
429
|
+
while (value.startsWith("(") && value.endsWith(")")) {
|
|
430
|
+
value = value.slice(1, -1).trim();
|
|
431
|
+
}
|
|
432
|
+
return (
|
|
433
|
+
/^NULL$/i.test(value) ||
|
|
434
|
+
/^(?:TRUE|FALSE)$/i.test(value) ||
|
|
435
|
+
/^[+-]?(?:\d+|\d+\.\d+|\.\d+)(?:e[+-]?\d+)?$/i.test(value) ||
|
|
436
|
+
/^'(?:''|[^'])*'$/.test(value) ||
|
|
437
|
+
/^X'(?:[0-9a-f]{2})*'$/i.test(value)
|
|
438
|
+
);
|
|
439
|
+
}
|
|
361
440
|
|
|
362
441
|
/**
|
|
363
442
|
* DROP COLUMN.
|
|
@@ -383,8 +462,8 @@ function emitDropColumn(
|
|
|
383
462
|
*
|
|
384
463
|
* Output: a multi-line SQL comment block naming the resource + field +
|
|
385
464
|
* fromType → toType, followed by the literal TODO message and a no-op
|
|
386
|
-
*
|
|
387
|
-
* this
|
|
465
|
+
* a deliberate failing sentinel so the migration runner refuses to mark
|
|
466
|
+
* this TODO as applied until the operator edits the migration manually.
|
|
388
467
|
*/
|
|
389
468
|
function emitAlterColumnTypeStub(
|
|
390
469
|
resourceName: string,
|
|
@@ -398,11 +477,13 @@ function emitAlterColumnTypeStub(
|
|
|
398
477
|
`-- from: ${fromType}`,
|
|
399
478
|
`-- to: ${toType}`,
|
|
400
479
|
`-- TODO: Mandu does not auto-generate ALTER COLUMN TYPE in v1.`,
|
|
401
|
-
`-- Please write the migration manually, then re-run \`mandu db apply\`.`,
|
|
402
|
-
`-- ================================================================`,
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
480
|
+
`-- Please write the migration manually, then re-run \`mandu db apply\`.`,
|
|
481
|
+
`-- ================================================================`,
|
|
482
|
+
manualMigrationRequiredStatement(
|
|
483
|
+
`Mandu cannot auto-generate ALTER COLUMN TYPE for ${resourceName}.${fieldName}`,
|
|
484
|
+
),
|
|
485
|
+
].join("\n");
|
|
486
|
+
}
|
|
406
487
|
|
|
407
488
|
function emitAlterColumnNullable(
|
|
408
489
|
resourceName: string,
|
|
@@ -419,30 +500,34 @@ function emitAlterColumnNullable(
|
|
|
419
500
|
: `ALTER TABLE ${table} ALTER COLUMN ${col} SET NOT NULL;`;
|
|
420
501
|
}
|
|
421
502
|
if (provider === "sqlite") {
|
|
422
|
-
// SQLite cannot toggle NOT NULL on an existing column without a full
|
|
423
|
-
// table recreate. Emit a stub so the user handles it manually.
|
|
424
|
-
return [
|
|
425
|
-
`-- ================================================================`,
|
|
426
|
-
`-- Nullability change: ${resourceName}.${fieldName} → ${nullable ? "NULL" : "NOT NULL"}`,
|
|
427
|
-
`-- TODO: SQLite cannot toggle NOT NULL in place. Recreate the table`,
|
|
428
|
-
`-- manually (CREATE new, INSERT SELECT, DROP old, RENAME) and re-run.`,
|
|
429
|
-
`-- ================================================================`,
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
503
|
+
// SQLite cannot toggle NOT NULL on an existing column without a full
|
|
504
|
+
// table recreate. Emit a failing stub so the user handles it manually.
|
|
505
|
+
return [
|
|
506
|
+
`-- ================================================================`,
|
|
507
|
+
`-- Nullability change: ${resourceName}.${fieldName} → ${nullable ? "NULL" : "NOT NULL"}`,
|
|
508
|
+
`-- TODO: SQLite cannot toggle NOT NULL in place. Recreate the table`,
|
|
509
|
+
`-- manually (CREATE new, INSERT SELECT, DROP old, RENAME) and re-run.`,
|
|
510
|
+
`-- ================================================================`,
|
|
511
|
+
manualMigrationRequiredStatement(
|
|
512
|
+
`SQLite nullability change requires manual table rebuild for ${resourceName}.${fieldName}`,
|
|
513
|
+
),
|
|
514
|
+
].join("\n");
|
|
515
|
+
}
|
|
433
516
|
// MySQL requires the full column spec; we cannot reconstruct it here.
|
|
434
517
|
// Emit a stub — Agent B's diff emits `alter-column-nullable` only when
|
|
435
518
|
// everything else matches, so this is narrow-scope.
|
|
436
519
|
return [
|
|
437
520
|
`-- ================================================================`,
|
|
438
521
|
`-- Nullability change: ${resourceName}.${fieldName} → ${nullable ? "NULL" : "NOT NULL"}`,
|
|
439
|
-
`-- TODO: MySQL MODIFY COLUMN requires the full column type; Mandu v1`,
|
|
440
|
-
`-- cannot emit this automatically. Please edit this migration to use`,
|
|
441
|
-
`-- \`ALTER TABLE ${resourceName} MODIFY COLUMN ${fieldName} <TYPE> ${nullable ? "NULL" : "NOT NULL"}\``,
|
|
442
|
-
`-- ================================================================`,
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
522
|
+
`-- TODO: MySQL MODIFY COLUMN requires the full column type; Mandu v1`,
|
|
523
|
+
`-- cannot emit this automatically. Please edit this migration to use`,
|
|
524
|
+
`-- \`ALTER TABLE ${resourceName} MODIFY COLUMN ${fieldName} <TYPE> ${nullable ? "NULL" : "NOT NULL"}\``,
|
|
525
|
+
`-- ================================================================`,
|
|
526
|
+
manualMigrationRequiredStatement(
|
|
527
|
+
`MySQL nullability change requires manual MODIFY COLUMN for ${resourceName}.${fieldName}`,
|
|
528
|
+
),
|
|
529
|
+
].join("\n");
|
|
530
|
+
}
|
|
446
531
|
|
|
447
532
|
function emitAlterColumnDefault(
|
|
448
533
|
resourceName: string,
|
|
@@ -458,15 +543,17 @@ function emitAlterColumnDefault(
|
|
|
458
543
|
: `ALTER TABLE ${table} ALTER COLUMN ${col} DROP DEFAULT;`;
|
|
459
544
|
}
|
|
460
545
|
if (provider === "sqlite") {
|
|
461
|
-
// Same constraint as nullability — SQLite needs a table recreate.
|
|
462
|
-
return [
|
|
463
|
-
`-- ================================================================`,
|
|
464
|
-
`-- Default change: ${resourceName}.${fieldName}`,
|
|
465
|
-
`-- TODO: SQLite cannot ALTER DEFAULT in place. Recreate the table.`,
|
|
466
|
-
`-- ================================================================`,
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
546
|
+
// Same constraint as nullability — SQLite needs a table recreate.
|
|
547
|
+
return [
|
|
548
|
+
`-- ================================================================`,
|
|
549
|
+
`-- Default change: ${resourceName}.${fieldName}`,
|
|
550
|
+
`-- TODO: SQLite cannot ALTER DEFAULT in place. Recreate the table.`,
|
|
551
|
+
`-- ================================================================`,
|
|
552
|
+
manualMigrationRequiredStatement(
|
|
553
|
+
`SQLite default change requires manual table rebuild for ${resourceName}.${fieldName}`,
|
|
554
|
+
),
|
|
555
|
+
].join("\n");
|
|
556
|
+
}
|
|
470
557
|
// MySQL — ALTER COLUMN ... SET DEFAULT / DROP DEFAULT is actually supported.
|
|
471
558
|
return def
|
|
472
559
|
? `ALTER TABLE ${table} ALTER COLUMN ${col} SET DEFAULT ${resolveDefault(def, provider)};`
|
|
@@ -510,7 +597,7 @@ function emitRenameTable(
|
|
|
510
597
|
return `ALTER TABLE ${from} RENAME TO ${to};`;
|
|
511
598
|
}
|
|
512
599
|
|
|
513
|
-
function emitRenameColumn(
|
|
600
|
+
function emitRenameColumn(
|
|
514
601
|
resourceName: string,
|
|
515
602
|
oldName: string,
|
|
516
603
|
newName: string,
|
|
@@ -522,7 +609,15 @@ function emitRenameColumn(
|
|
|
522
609
|
// All three dialects use `ALTER TABLE ... RENAME COLUMN ... TO ...` in
|
|
523
610
|
// their modern versions (PG >=9.2, MySQL >=8.0.3, SQLite >=3.25).
|
|
524
611
|
return `ALTER TABLE ${table} RENAME COLUMN ${from} TO ${to};`;
|
|
525
|
-
}
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
function manualMigrationRequiredStatement(message: string): string {
|
|
615
|
+
return `SELECT mandu_manual_migration_required(${sqlStringLiteral(message)});`;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
function sqlStringLiteral(value: string): string {
|
|
619
|
+
return `'${value.replace(/'/g, "''")}'`;
|
|
620
|
+
}
|
|
526
621
|
|
|
527
622
|
// =====================================================================
|
|
528
623
|
// Validation helpers.
|
|
@@ -363,21 +363,17 @@ ${result.migrationSql}`;
|
|
|
363
363
|
// ============================================
|
|
364
364
|
|
|
365
365
|
/**
|
|
366
|
-
*
|
|
367
|
-
*
|
|
368
|
-
*
|
|
369
|
-
*
|
|
370
|
-
*
|
|
371
|
-
*/
|
|
372
|
-
function composeMigrationSql(changes: readonly Change[], provider: SqlProvider): string {
|
|
373
|
-
const body = emitChanges(changes, provider);
|
|
374
|
-
if (body.length === 0) return "";
|
|
375
|
-
return
|
|
376
|
-
|
|
377
|
-
${body}
|
|
378
|
-
|
|
379
|
-
COMMIT;`;
|
|
380
|
-
}
|
|
366
|
+
* Compose the sequence of `Change` → SQL emission.
|
|
367
|
+
*
|
|
368
|
+
* Do not wrap with `BEGIN` / `COMMIT`: the migration runner already applies
|
|
369
|
+
* each file inside a transaction, and SQLite rejects nested BEGIN with
|
|
370
|
+
* "cannot start a transaction within a transaction".
|
|
371
|
+
*/
|
|
372
|
+
function composeMigrationSql(changes: readonly Change[], provider: SqlProvider): string {
|
|
373
|
+
const body = emitChanges(changes, provider);
|
|
374
|
+
if (body.length === 0) return "";
|
|
375
|
+
return body;
|
|
376
|
+
}
|
|
381
377
|
|
|
382
378
|
// ============================================
|
|
383
379
|
// Internals — filesystem I/O
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
findClientComponentImports,
|
|
4
|
+
findRouteLevelClientComponentImport,
|
|
5
|
+
} from "./client-entry";
|
|
6
|
+
|
|
7
|
+
describe("findClientComponentImports", () => {
|
|
8
|
+
it("detects named .client imports for diagnostics", () => {
|
|
9
|
+
const imports = findClientComponentImports(`
|
|
10
|
+
import { LoginForm, SubmitButton as Button } from "@/client/widgets/login-form/LoginForm.client";
|
|
11
|
+
import Header from "./Header.client.tsx";
|
|
12
|
+
`);
|
|
13
|
+
|
|
14
|
+
expect(imports).toEqual([
|
|
15
|
+
{
|
|
16
|
+
module: "@/client/widgets/login-form/LoginForm.client",
|
|
17
|
+
kind: "named",
|
|
18
|
+
names: ["LoginForm", "SubmitButton"],
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
module: "./Header.client.tsx",
|
|
22
|
+
kind: "default",
|
|
23
|
+
names: ["Header"],
|
|
24
|
+
},
|
|
25
|
+
]);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("detects a default-imported client component when the page returns only that component", () => {
|
|
29
|
+
const routeClient = findRouteLevelClientComponentImport(`
|
|
30
|
+
import LoginPage from "@/client/pages/login/LoginPage.client";
|
|
31
|
+
|
|
32
|
+
export const metadata = { title: "Login" };
|
|
33
|
+
|
|
34
|
+
export default function Page() {
|
|
35
|
+
return <LoginPage />;
|
|
36
|
+
}
|
|
37
|
+
`);
|
|
38
|
+
|
|
39
|
+
expect(routeClient).toEqual({
|
|
40
|
+
module: "@/client/pages/login/LoginPage.client",
|
|
41
|
+
localName: "LoginPage",
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("does not promote embedded client imports inside a larger server page", () => {
|
|
46
|
+
const routeClient = findRouteLevelClientComponentImport(`
|
|
47
|
+
import HomeApp from "@/client/pages/home/HomeApp.client";
|
|
48
|
+
|
|
49
|
+
export default function HomePage() {
|
|
50
|
+
return <>
|
|
51
|
+
<meta name="description" content="home" />
|
|
52
|
+
<HomeApp />
|
|
53
|
+
</>;
|
|
54
|
+
}
|
|
55
|
+
`);
|
|
56
|
+
|
|
57
|
+
expect(routeClient).toBeNull();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("does not promote client imports when the page wrapper passes props", () => {
|
|
61
|
+
const routeClient = findRouteLevelClientComponentImport(`
|
|
62
|
+
import PledgePage from "@/client/pages/pledges/PledgePage.client";
|
|
63
|
+
|
|
64
|
+
export default function Page({ params }) {
|
|
65
|
+
return <PledgePage id={params.id} />;
|
|
66
|
+
}
|
|
67
|
+
`);
|
|
68
|
+
|
|
69
|
+
expect(routeClient).toBeNull();
|
|
70
|
+
});
|
|
71
|
+
});
|
|
@@ -2,6 +2,17 @@ import { readFile } from "fs/promises";
|
|
|
2
2
|
import path from "path";
|
|
3
3
|
import type { RouteSpec } from "../spec/schema";
|
|
4
4
|
|
|
5
|
+
export interface ClientComponentImport {
|
|
6
|
+
module: string;
|
|
7
|
+
kind: "default" | "named" | "namespace" | "side-effect" | "mixed";
|
|
8
|
+
names: string[];
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface RouteLevelClientComponentImport {
|
|
12
|
+
module: string;
|
|
13
|
+
localName: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
5
16
|
export function normalizeRouteModulePath(value: string | undefined): string {
|
|
6
17
|
return (value ?? "").replace(/\\/g, "/").replace(/^\.\//, "");
|
|
7
18
|
}
|
|
@@ -32,6 +43,95 @@ async function readRouteModule(rootDir: string, modulePath: string): Promise<str
|
|
|
32
43
|
}
|
|
33
44
|
}
|
|
34
45
|
|
|
46
|
+
export function findClientComponentImports(source: string): ClientComponentImport[] {
|
|
47
|
+
const imports: ClientComponentImport[] = [];
|
|
48
|
+
const importFromPattern = /import\s+([\s\S]*?)\s+from\s+["']([^"']*\.client(?:\.[tj]sx?)?)["']/g;
|
|
49
|
+
const sideEffectPattern = /import\s+["']([^"']*\.client(?:\.[tj]sx?)?)["']/g;
|
|
50
|
+
|
|
51
|
+
for (const match of source.matchAll(importFromPattern)) {
|
|
52
|
+
const clause = (match[1] ?? "").trim();
|
|
53
|
+
const module = match[2] ?? "";
|
|
54
|
+
const names: string[] = [];
|
|
55
|
+
let hasDefault = false;
|
|
56
|
+
let hasNamed = false;
|
|
57
|
+
let hasNamespace = false;
|
|
58
|
+
|
|
59
|
+
const namedMatch = clause.match(/\{([^}]*)\}/);
|
|
60
|
+
if (namedMatch) {
|
|
61
|
+
hasNamed = true;
|
|
62
|
+
for (const rawName of namedMatch[1].split(",")) {
|
|
63
|
+
const name = rawName.trim();
|
|
64
|
+
if (!name) continue;
|
|
65
|
+
names.push(name.split(/\s+as\s+/i)[0].trim());
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (/\*\s+as\s+/.test(clause)) {
|
|
70
|
+
hasNamespace = true;
|
|
71
|
+
const namespaceMatch = clause.match(/\*\s+as\s+([A-Za-z_$][A-Za-z0-9_$]*)/);
|
|
72
|
+
if (namespaceMatch?.[1]) names.push(namespaceMatch[1]);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const beforeNamed = clause.split("{")[0]?.replace(/,\s*$/, "").trim() ?? "";
|
|
76
|
+
if (beforeNamed && !beforeNamed.startsWith("*")) {
|
|
77
|
+
hasDefault = true;
|
|
78
|
+
names.push(beforeNamed.split(",")[0].trim());
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const kind =
|
|
82
|
+
(hasDefault && (hasNamed || hasNamespace))
|
|
83
|
+
? "mixed"
|
|
84
|
+
: hasNamed
|
|
85
|
+
? "named"
|
|
86
|
+
: hasNamespace
|
|
87
|
+
? "namespace"
|
|
88
|
+
: "default";
|
|
89
|
+
|
|
90
|
+
imports.push({ module, kind, names });
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
for (const match of source.matchAll(sideEffectPattern)) {
|
|
94
|
+
const module = match[1] ?? "";
|
|
95
|
+
if (imports.some((entry) => entry.module === module)) continue;
|
|
96
|
+
imports.push({ module, kind: "side-effect", names: [] });
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return imports;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function findRouteLevelClientComponentImport(source: string): RouteLevelClientComponentImport | null {
|
|
103
|
+
const defaultImports = findClientComponentImports(source).filter((entry) => {
|
|
104
|
+
const localName = entry.names[0] ?? "";
|
|
105
|
+
return entry.kind === "default" && /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(localName);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
if (defaultImports.length !== 1) return null;
|
|
109
|
+
|
|
110
|
+
const entry = defaultImports[0];
|
|
111
|
+
const localName = entry.names[0];
|
|
112
|
+
if (!entry.module || !localName) return null;
|
|
113
|
+
if (!defaultExportReturnsOnlyClientComponent(source, localName)) return null;
|
|
114
|
+
|
|
115
|
+
return { module: entry.module, localName };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export async function resolveClientImportModulePath(
|
|
119
|
+
rootDir: string,
|
|
120
|
+
importerModule: string,
|
|
121
|
+
specifier: string,
|
|
122
|
+
): Promise<string | null> {
|
|
123
|
+
const base = resolveImportBasePath(rootDir, importerModule, specifier);
|
|
124
|
+
if (!base) return null;
|
|
125
|
+
|
|
126
|
+
for (const candidate of expandClientModuleCandidates(base)) {
|
|
127
|
+
if (await Bun.file(candidate).exists()) {
|
|
128
|
+
return path.relative(rootDir, candidate).replace(/\\/g, "/");
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
|
|
35
135
|
export async function shouldPreserveExistingClientModule(
|
|
36
136
|
route: RouteSpec,
|
|
37
137
|
clientModule: string,
|
|
@@ -46,6 +146,147 @@ export async function shouldPreserveExistingClientModule(
|
|
|
46
146
|
return true;
|
|
47
147
|
}
|
|
48
148
|
|
|
149
|
+
function resolveImportBasePath(rootDir: string, importerModule: string, specifier: string): string | null {
|
|
150
|
+
const normalized = specifier.replace(/\\/g, "/");
|
|
151
|
+
if (normalized.startsWith("@/") || normalized.startsWith("~/")) {
|
|
152
|
+
return path.resolve(rootDir, "src", normalized.slice(2));
|
|
153
|
+
}
|
|
154
|
+
if (normalized.startsWith("./") || normalized.startsWith("../")) {
|
|
155
|
+
return path.resolve(rootDir, path.dirname(importerModule), normalized);
|
|
156
|
+
}
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function expandClientModuleCandidates(basePath: string): string[] {
|
|
161
|
+
if (/\.[cm]?[jt]sx?$/.test(basePath)) return [basePath];
|
|
162
|
+
return [
|
|
163
|
+
`${basePath}.tsx`,
|
|
164
|
+
`${basePath}.ts`,
|
|
165
|
+
`${basePath}.jsx`,
|
|
166
|
+
`${basePath}.js`,
|
|
167
|
+
];
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function defaultExportReturnsOnlyClientComponent(source: string, localName: string): boolean {
|
|
171
|
+
const functionBody = extractDefaultExportFunctionBody(source);
|
|
172
|
+
if (functionBody !== null) {
|
|
173
|
+
const returned = extractOnlyReturnExpression(functionBody);
|
|
174
|
+
return returned !== null && isSelfClosingJsxElement(returned, localName);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const arrowExpression = extractDefaultExportArrowExpression(source);
|
|
178
|
+
return arrowExpression !== null && isSelfClosingJsxElement(arrowExpression, localName);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function extractDefaultExportFunctionBody(source: string): string | null {
|
|
182
|
+
const match = /export\s+default\s+(?:async\s+)?function(?:\s+[A-Za-z_$][A-Za-z0-9_$]*)?\s*\([^)]*\)\s*(?::\s*[^{=]+)?\{/m.exec(source);
|
|
183
|
+
if (!match) return null;
|
|
184
|
+
|
|
185
|
+
const openBrace = match.index + match[0].lastIndexOf("{");
|
|
186
|
+
const closeBrace = findMatchingBrace(source, openBrace);
|
|
187
|
+
if (closeBrace === -1) return null;
|
|
188
|
+
return source.slice(openBrace + 1, closeBrace);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function extractDefaultExportArrowExpression(source: string): string | null {
|
|
192
|
+
const match = /export\s+default\s+(?:async\s+)?(?:\([^)]*\)|[A-Za-z_$][A-Za-z0-9_$]*)\s*=>\s*/m.exec(source);
|
|
193
|
+
if (!match) return null;
|
|
194
|
+
|
|
195
|
+
const start = match.index + match[0].length;
|
|
196
|
+
const rest = source.slice(start).trim();
|
|
197
|
+
if (rest.startsWith("{")) return null;
|
|
198
|
+
|
|
199
|
+
const semicolon = rest.indexOf(";");
|
|
200
|
+
return semicolon === -1 ? rest : rest.slice(0, semicolon);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function extractOnlyReturnExpression(body: string): string | null {
|
|
204
|
+
const match = /^\s*return\s+([\s\S]*?)\s*;?\s*$/.exec(body);
|
|
205
|
+
return match?.[1]?.trim() ?? null;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function isSelfClosingJsxElement(expression: string, localName: string): boolean {
|
|
209
|
+
const expr = stripWrappingParentheses(expression.trim());
|
|
210
|
+
const escaped = escapeRegExp(localName);
|
|
211
|
+
return new RegExp(`^<${escaped}\\s*/>$`).test(expr);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function stripWrappingParentheses(value: string): string {
|
|
215
|
+
let current = value.trim();
|
|
216
|
+
while (current.startsWith("(") && current.endsWith(")")) {
|
|
217
|
+
const close = findMatchingParen(current, 0);
|
|
218
|
+
if (close !== current.length - 1) break;
|
|
219
|
+
current = current.slice(1, -1).trim();
|
|
220
|
+
}
|
|
221
|
+
return current;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function findMatchingBrace(source: string, openIndex: number): number {
|
|
225
|
+
return findMatchingDelimiter(source, openIndex, "{", "}");
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function findMatchingParen(source: string, openIndex: number): number {
|
|
229
|
+
return findMatchingDelimiter(source, openIndex, "(", ")");
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function findMatchingDelimiter(source: string, openIndex: number, open: string, close: string): number {
|
|
233
|
+
let depth = 0;
|
|
234
|
+
let quote: '"' | "'" | "`" | null = null;
|
|
235
|
+
let lineComment = false;
|
|
236
|
+
let blockComment = false;
|
|
237
|
+
|
|
238
|
+
for (let i = openIndex; i < source.length; i++) {
|
|
239
|
+
const char = source[i];
|
|
240
|
+
const next = source[i + 1];
|
|
241
|
+
const prev = source[i - 1];
|
|
242
|
+
|
|
243
|
+
if (lineComment) {
|
|
244
|
+
if (char === "\n" || char === "\r") lineComment = false;
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (blockComment) {
|
|
249
|
+
if (char === "*" && next === "/") {
|
|
250
|
+
blockComment = false;
|
|
251
|
+
i++;
|
|
252
|
+
}
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (quote) {
|
|
257
|
+
if (char === quote && prev !== "\\") quote = null;
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if (char === "/" && next === "/") {
|
|
262
|
+
lineComment = true;
|
|
263
|
+
i++;
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
if (char === "/" && next === "*") {
|
|
267
|
+
blockComment = true;
|
|
268
|
+
i++;
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
if (char === '"' || char === "'" || char === "`") {
|
|
272
|
+
quote = char;
|
|
273
|
+
continue;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (char === open) depth++;
|
|
277
|
+
if (char === close) {
|
|
278
|
+
depth--;
|
|
279
|
+
if (depth === 0) return i;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
return -1;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function escapeRegExp(value: string): string {
|
|
287
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
288
|
+
}
|
|
289
|
+
|
|
49
290
|
export async function validateClientModuleForBrowserBundle(
|
|
50
291
|
route: RouteSpec,
|
|
51
292
|
rootDir: string,
|
|
@@ -69,3 +310,48 @@ export async function validateClientModuleForBrowserBundle(
|
|
|
69
310
|
|
|
70
311
|
return null;
|
|
71
312
|
}
|
|
313
|
+
|
|
314
|
+
export async function describeMissingHydrationClientModule(
|
|
315
|
+
route: RouteSpec,
|
|
316
|
+
rootDir: string,
|
|
317
|
+
options: { allowPartialOnly?: boolean } = {},
|
|
318
|
+
): Promise<string | null> {
|
|
319
|
+
const hydration = route.hydration?.strategy ?? "island";
|
|
320
|
+
const base =
|
|
321
|
+
`[${route.id}] Route has hydration strategy "${hydration}" but no clientModule could be resolved. ` +
|
|
322
|
+
`Mandu cannot emit a working data-mandu-src for this route.`;
|
|
323
|
+
|
|
324
|
+
const componentModule = route.kind === "page" ? route.componentModule : undefined;
|
|
325
|
+
if (!componentModule) {
|
|
326
|
+
return options.allowPartialOnly && hydration === "island" ? null : base;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const source = await readRouteModule(rootDir, componentModule);
|
|
330
|
+
if (source === null) {
|
|
331
|
+
return options.allowPartialOnly && hydration === "island" ? null : base;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const clientImports = findClientComponentImports(source);
|
|
335
|
+
if (clientImports.length === 0) {
|
|
336
|
+
if (options.allowPartialOnly && hydration === "island") return null;
|
|
337
|
+
return (
|
|
338
|
+
`${base}\n` +
|
|
339
|
+
` Fix: add a route-level client module (for example app/*.island.tsx or spec/slots/${route.id}.client.tsx) ` +
|
|
340
|
+
`or set hydration.strategy to "none".`
|
|
341
|
+
);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const importList = clientImports
|
|
345
|
+
.map((entry) => {
|
|
346
|
+
const suffix = entry.names.length > 0 ? ` (${entry.kind}: ${entry.names.join(", ")})` : ` (${entry.kind})`;
|
|
347
|
+
return ` - ${entry.module}${suffix}`;
|
|
348
|
+
})
|
|
349
|
+
.join("\n");
|
|
350
|
+
|
|
351
|
+
return (
|
|
352
|
+
`${base}\n` +
|
|
353
|
+
` The page imports client-looking modules, but inline .client.tsx imports are not route bundles:\n` +
|
|
354
|
+
`${importList}\n` +
|
|
355
|
+
` Fix: use partial({ component }).Render for embedded client regions, or expose a route-level client module.`
|
|
356
|
+
);
|
|
357
|
+
}
|