finish CSS delivery and generated type remediation
This commit is contained in:
@@ -16,6 +16,12 @@ showcase-generation, typecheck, lint, formatting, public-API, visual-contract,
|
|||||||
security, and runtime-size gates. The only skipped test is the explicitly
|
security, and runtime-size gates. The only skipped test is the explicitly
|
||||||
environment-dependent live PostgreSQL/MySQL test.
|
environment-dependent live PostgreSQL/MySQL test.
|
||||||
|
|
||||||
|
Production CSS delivery now links the selected palette/accent sheet (6,632
|
||||||
|
decoded bytes for dark/violet) instead of the complete 74,764-byte theme
|
||||||
|
matrix. The compatibility `theme.css` endpoint remains available but is not
|
||||||
|
linked by generated pages. Generated application declarations are committed
|
||||||
|
and checked for drift by `check:production`.
|
||||||
|
|
||||||
Written 2026-08-09, after the 0.8.6 release. Every number here was measured
|
Written 2026-08-09, after the 0.8.6 release. Every number here was measured
|
||||||
against the completed tree, not estimated. Where a cause is not yet
|
against the completed tree, not estimated. Where a cause is not yet
|
||||||
proven the item says so and makes proving it step one — nothing in this document
|
proven the item says so and makes proving it step one — nothing in this document
|
||||||
|
|||||||
+5
-4
@@ -13,15 +13,14 @@ declare namespace WRNexusGenerated {
|
|||||||
: never;
|
: never;
|
||||||
type RealtimeMessage<T> = T extends import("@wrnexus/core").RoomDefinition<any, infer M> ? M : unknown;
|
type RealtimeMessage<T> = T extends import("@wrnexus/core").RoomDefinition<any, infer M> ? M : unknown;
|
||||||
type QueuePayload<T> = T extends import("@wrnexus/queue").JobDefinition<infer I> ? I : unknown;
|
type QueuePayload<T> = T extends import("@wrnexus/queue").JobDefinition<infer I> ? I : unknown;
|
||||||
type RouteName = "about" | "async.data" | "chat" | "client.only" | "dashboard" | "hello" | "index" | "language.tools" | "login" | "modal" | "partial.static" | "platform.showcase" | "reactive" | "server.actions" | "test" | "ui";
|
type RouteName = "about" | "async.data" | "chat" | "client.only" | "dashboard" | "hello" | "index" | "language.tools" | "layout" | "login" | "modal" | "navigation" | "partial.static" | "platform.showcase" | "reactive" | "server.actions" | "table" | "test" | "ui";
|
||||||
type ApiRoute = "/api/echo" | "/api/graphql-example" | "/api/hello" | "/api/login" | "/api/logout" | "/api/me" | "/api/typed-user" | "/api/users/csr" | "/api/users/ssr" | "/api/webhooks/payment";
|
type ApiRoute = "/api/accounts" | "/api/echo" | "/api/graphql-example" | "/api/hello" | "/api/invite" | "/api/login" | "/api/logout" | "/api/me" | "/api/typed-user" | "/api/users/csr" | "/api/users/ssr" | "/api/webhooks/payment";
|
||||||
type RealtimeRoute = "/realtime/chat" | "/realtime/hello";
|
type RealtimeRoute = "/realtime/chat" | "/realtime/hello";
|
||||||
type EnvironmentKey = "APP_LABEL" | "DATABASE_URL" | "DEMO_SHARED" | "HOST" | "NODE_ENV" | "PORT" | "SESSION_SECRET" | "UAT_ONLY";
|
type EnvironmentKey = "APP_LABEL" | "DATABASE_URL" | "DEMO_SHARED" | "HOST" | "NODE_ENV" | "PORT" | "SESSION_SECRET" | "UAT_ONLY";
|
||||||
type TranslationKey = "api.greeting" | "home.intro" | "home.title" | "nav.about" | "nav.chat" | "nav.dashboard" | "nav.home" | "nav.ui";
|
type TranslationKey = "api.greeting" | "home.intro" | "home.title" | "nav.about" | "nav.chat" | "nav.dashboard" | "nav.home" | "nav.layout" | "nav.navigation" | "nav.ui";
|
||||||
type QueueName = "welcome-email";
|
type QueueName = "welcome-email";
|
||||||
type CacheKey = "users";
|
type CacheKey = "users";
|
||||||
interface Components {
|
interface Components {
|
||||||
"Modal": { props: Record<string, never>; outputs: Record<string, never> };
|
|
||||||
"Counter": { props: { "start"?: unknown; "label"?: unknown }; outputs: Record<string, never> };
|
"Counter": { props: { "start"?: unknown; "label"?: unknown }; outputs: Record<string, never> };
|
||||||
}
|
}
|
||||||
interface ApiContracts {
|
interface ApiContracts {
|
||||||
@@ -30,6 +29,8 @@ declare namespace WRNexusGenerated {
|
|||||||
"/api/users/ssr": { GET: ApiContract<typeof import("../api/users/ssr.ts")["GET"]> };
|
"/api/users/ssr": { GET: ApiContract<typeof import("../api/users/ssr.ts")["GET"]> };
|
||||||
"/api/graphql-example": { POST: ApiContract<typeof import("../api/graphql-example.ts")["POST"]> };
|
"/api/graphql-example": { POST: ApiContract<typeof import("../api/graphql-example.ts")["POST"]> };
|
||||||
"/api/typed-user": { POST: ApiContract<typeof import("../api/typed-user.ts")["POST"]> };
|
"/api/typed-user": { POST: ApiContract<typeof import("../api/typed-user.ts")["POST"]> };
|
||||||
|
"/api/accounts": { GET: ApiContract<typeof import("../api/accounts.ts")["GET"]> };
|
||||||
|
"/api/invite": { POST: ApiContract<typeof import("../api/invite.ts")["POST"]> };
|
||||||
"/api/logout": { POST: ApiContract<typeof import("../api/logout.ts")["POST"]> };
|
"/api/logout": { POST: ApiContract<typeof import("../api/logout.ts")["POST"]> };
|
||||||
"/api/hello": { GET: ApiContract<typeof import("../api/hello.ts")["GET"]> };
|
"/api/hello": { GET: ApiContract<typeof import("../api/hello.ts")["GET"]> };
|
||||||
"/api/login": { POST: ApiContract<typeof import("../api/login.ts")["POST"]> };
|
"/api/login": { POST: ApiContract<typeof import("../api/login.ts")["POST"]> };
|
||||||
|
|||||||
+3
-1
@@ -43,12 +43,14 @@
|
|||||||
"security:asvs": "node scripts/check-security-asvs.mjs",
|
"security:asvs": "node scripts/check-security-asvs.mjs",
|
||||||
"generate:security-report": "node scripts/security-performance-audit.mjs --write",
|
"generate:security-report": "node scripts/security-performance-audit.mjs --write",
|
||||||
"check:public-api": "node scripts/check-public-api.mjs",
|
"check:public-api": "node scripts/check-public-api.mjs",
|
||||||
|
"check:generated-types": "bun run scripts/check-generated-types.ts",
|
||||||
|
"generate:example-types": "bun run packages/cli/src/index.ts generate types examples/basic-app",
|
||||||
"generate:public-api": "node scripts/check-public-api.mjs --write",
|
"generate:public-api": "node scripts/check-public-api.mjs --write",
|
||||||
"check:ui-visual": "node scripts/check-ui-visual-contract.mjs",
|
"check:ui-visual": "node scripts/check-ui-visual-contract.mjs",
|
||||||
"generate:ui-visual": "node scripts/check-ui-visual-contract.mjs --write",
|
"generate:ui-visual": "node scripts/check-ui-visual-contract.mjs --write",
|
||||||
"sbom": "node scripts/generate-sbom.mjs",
|
"sbom": "node scripts/generate-sbom.mjs",
|
||||||
"benchmark:framework": "node --experimental-transform-types scripts/benchmark-framework.mjs --write",
|
"benchmark:framework": "node --experimental-transform-types scripts/benchmark-framework.mjs --write",
|
||||||
"check:production": "bun run check:workspace && bun run check:public-api && bun run check:ui-visual && bun run validate:0.8 && bun run security:framework && bun run security:asvs && bun run check:editor-compiler && bun run check:editor-language-server && bun run check:editor-extension && bun run check && bun run test:examples",
|
"check:production": "bun run check:workspace && bun run check:generated-types && bun run check:public-api && bun run check:ui-visual && bun run validate:0.8 && bun run security:framework && bun run security:asvs && bun run check:editor-compiler && bun run check:editor-language-server && bun run check:editor-extension && bun run check && bun run test:examples",
|
||||||
"validate:staging": "node --experimental-transform-types scripts/test-package-integrity.mjs",
|
"validate:staging": "node --experimental-transform-types scripts/test-package-integrity.mjs",
|
||||||
"stage:packages": "bun run scripts/publish-packages.ts",
|
"stage:packages": "bun run scripts/publish-packages.ts",
|
||||||
"test:staged-consumers": "node scripts/test-staged-consumers.mjs",
|
"test:staged-consumers": "node scripts/test-staged-consumers.mjs",
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import { Database } from "bun:sqlite";
|
||||||
|
import {
|
||||||
|
RedisCaptchaStore,
|
||||||
|
SqliteCaptchaStore,
|
||||||
|
managedCaptchaProvider,
|
||||||
|
type CaptchaChallengeRecord,
|
||||||
|
type CaptchaResponseTokenRecord,
|
||||||
|
type RedisCaptchaClient,
|
||||||
|
type SqliteDatabaseLike,
|
||||||
|
} from "../src/index.ts";
|
||||||
|
|
||||||
|
const challenge = (id: string, expiresAt = Date.now() + 60_000): CaptchaChallengeRecord => ({
|
||||||
|
id,
|
||||||
|
provider: "self-hosted",
|
||||||
|
type: "text",
|
||||||
|
presentation: "visual",
|
||||||
|
action: "signup",
|
||||||
|
publicChallenge: {
|
||||||
|
id,
|
||||||
|
provider: "self-hosted",
|
||||||
|
type: "text",
|
||||||
|
presentation: "visual",
|
||||||
|
action: "signup",
|
||||||
|
prompt: "Type the text",
|
||||||
|
createdAt: Date.now(),
|
||||||
|
expiresAt,
|
||||||
|
responseField: "wrn-captcha-response",
|
||||||
|
},
|
||||||
|
answerDigest: "digest",
|
||||||
|
answerSalt: "salt",
|
||||||
|
answerKind: "text",
|
||||||
|
caseSensitive: false,
|
||||||
|
attempts: 0,
|
||||||
|
maxAttempts: 3,
|
||||||
|
metadata: {},
|
||||||
|
createdAt: Date.now(),
|
||||||
|
expiresAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
const token = (tokenHash: string): CaptchaResponseTokenRecord => ({
|
||||||
|
tokenHash,
|
||||||
|
provider: "self-hosted",
|
||||||
|
action: "signup",
|
||||||
|
createdAt: Date.now(),
|
||||||
|
expiresAt: Date.now() + 60_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("CAPTCHA durable stores", () => {
|
||||||
|
test("SQLite atomically mutates, consumes, deletes, and collects records", async () => {
|
||||||
|
const db = new Database(":memory:");
|
||||||
|
const store = new SqliteCaptchaStore(db as unknown as SqliteDatabaseLike);
|
||||||
|
await store.createChallenge(challenge("c1"));
|
||||||
|
expect((await store.incrementAttempts("c1", Date.now()))?.attempts).toBe(1);
|
||||||
|
expect(await store.consumeChallenge("c1", Date.now())).toMatchObject({
|
||||||
|
consumedAt: expect.any(Number),
|
||||||
|
});
|
||||||
|
expect(await store.consumeChallenge("c1", Date.now())).toBeUndefined();
|
||||||
|
|
||||||
|
await store.createToken(token("t1"));
|
||||||
|
expect(await store.consumeToken("t1", Date.now())).toMatchObject({
|
||||||
|
consumedAt: expect.any(Number),
|
||||||
|
});
|
||||||
|
expect(await store.consumeToken("t1", Date.now())).toBeUndefined();
|
||||||
|
await store.deleteChallenge("c1");
|
||||||
|
await store.deleteToken("t1");
|
||||||
|
await store.createChallenge(challenge("expired", Date.now() - 1));
|
||||||
|
await store.gc(Date.now());
|
||||||
|
expect(await store.getChallenge("expired")).toBeUndefined();
|
||||||
|
db.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Redis fallback locking preserves one-time consumption without Lua", async () => {
|
||||||
|
const values = new Map<string, string>();
|
||||||
|
const redis: RedisCaptchaClient = {
|
||||||
|
get: (key) => values.get(key) ?? null,
|
||||||
|
set: (key, value) => void values.set(key, value),
|
||||||
|
del: (key) => (values.delete(key) ? 1 : 0),
|
||||||
|
};
|
||||||
|
const store = new RedisCaptchaStore(redis, { prefix: "test:" });
|
||||||
|
await store.createChallenge(challenge("c1"));
|
||||||
|
expect((await store.incrementAttempts("c1", Date.now()))?.attempts).toBe(1);
|
||||||
|
const consumed = await Promise.all([
|
||||||
|
store.consumeChallenge("c1", Date.now()),
|
||||||
|
store.consumeChallenge("c1", Date.now()),
|
||||||
|
]);
|
||||||
|
expect(consumed.filter(Boolean)).toHaveLength(1);
|
||||||
|
|
||||||
|
await store.createToken(token("t1"));
|
||||||
|
const tokens = await Promise.all([
|
||||||
|
store.consumeToken("t1", Date.now()),
|
||||||
|
store.consumeToken("t1", Date.now()),
|
||||||
|
]);
|
||||||
|
expect(tokens.filter(Boolean)).toHaveLength(1);
|
||||||
|
await store.deleteChallenge("c1");
|
||||||
|
await store.deleteToken("t1");
|
||||||
|
expect(await store.getChallenge("c1")).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("managed provider separates public creation from secret verification", async () => {
|
||||||
|
const requests: RequestInit[] = [];
|
||||||
|
const provider = managedCaptchaProvider({
|
||||||
|
baseUrl: "https://captcha.test/",
|
||||||
|
siteKey: "site",
|
||||||
|
secretKey: "secret",
|
||||||
|
fetch: (async (_url, init) => {
|
||||||
|
requests.push(init ?? {});
|
||||||
|
return Response.json(
|
||||||
|
requests.length === 1
|
||||||
|
? {
|
||||||
|
id: "c1",
|
||||||
|
provider: "managed",
|
||||||
|
type: "text",
|
||||||
|
presentation: "visual",
|
||||||
|
action: "signup",
|
||||||
|
prompt: "Type",
|
||||||
|
createdAt: 1,
|
||||||
|
expiresAt: 2,
|
||||||
|
responseField: "token",
|
||||||
|
}
|
||||||
|
: { success: true, provider: "managed", action: "signup" },
|
||||||
|
);
|
||||||
|
}) as typeof fetch,
|
||||||
|
});
|
||||||
|
await provider.createChallenge({ action: "signup" });
|
||||||
|
await provider.verify({ action: "signup", providerToken: "answer" });
|
||||||
|
expect(new Headers(requests[0]?.headers).has("authorization")).toBe(false);
|
||||||
|
expect(new Headers(requests[1]?.headers).get("authorization")).toBe("Bearer secret");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -566,14 +566,17 @@ export async function runBuild(appRoot: string): Promise<void> {
|
|||||||
},
|
},
|
||||||
config.styles,
|
config.styles,
|
||||||
);
|
);
|
||||||
const combinedSource = `${frameworkStyles}\n${css}`;
|
// Theme tokens are request-selected and loaded separately. Keep only the
|
||||||
|
// shared UI primitives with application CSS so the full theme/accent
|
||||||
|
// matrix cannot leak back into the blocking stylesheet.
|
||||||
|
const combinedSource = `${uiStyles}\n${css}`;
|
||||||
const combinedInput = join(distDir, ".wrnexus-combined.css");
|
const combinedInput = join(distDir, ".wrnexus-combined.css");
|
||||||
writeFileSync(combinedInput, combinedSource, "utf8");
|
writeFileSync(combinedInput, combinedSource, "utf8");
|
||||||
const combinedCss = await bundleCss(combinedInput, "production");
|
const combinedCss = await bundleCss(combinedInput, "production");
|
||||||
rmSync(combinedInput, { force: true });
|
rmSync(combinedInput, { force: true });
|
||||||
assetHash.update(combinedCss);
|
assetHash.update(combinedCss);
|
||||||
// One blocking CSS request in production: tokens → UI → application CSS.
|
// One shared blocking request for UI + application CSS. The small active
|
||||||
// The standalone framework.css remains available for apps without global CSS.
|
// theme stylesheet is selected from the request cookie and loaded first.
|
||||||
writeFileSync(join(distDir, "styles.css"), combinedCss, "utf8");
|
writeFileSync(join(distDir, "styles.css"), combinedCss, "utf8");
|
||||||
hasStyles = true;
|
hasStyles = true;
|
||||||
if (Buffer.byteLength(combinedCss, "utf8") <= INLINE_CSS_LIMIT_BYTES) {
|
if (Buffer.byteLength(combinedCss, "utf8") <= INLINE_CSS_LIMIT_BYTES) {
|
||||||
@@ -655,8 +658,8 @@ applyAuthzManifestEarly([${authzSetupEntries}]);
|
|||||||
const componentsLit = router.components
|
const componentsLit = router.components
|
||||||
.map((c) => {
|
.map((c) => {
|
||||||
const v = `c${counter++}`;
|
const v = `c${counter++}`;
|
||||||
imports.push(`import * as ${v} from ${JSON.stringify(importPathFor(c.file))};`);
|
imports.push(`import { render as ${v} } from ${JSON.stringify(importPathFor(c.file))};`);
|
||||||
return `{ name: ${JSON.stringify(c.name)}, mod: ${v} }`;
|
return `{ name: ${JSON.stringify(c.name)}, mod: { render: ${v} } }`;
|
||||||
})
|
})
|
||||||
.join(", ");
|
.join(", ");
|
||||||
console.log(`✓ Components: ${router.components.length}`);
|
console.log(`✓ Components: ${router.components.length}`);
|
||||||
@@ -665,8 +668,8 @@ applyAuthzManifestEarly([${authzSetupEntries}]);
|
|||||||
const layoutsLit = router.layouts
|
const layoutsLit = router.layouts
|
||||||
.map((l) => {
|
.map((l) => {
|
||||||
const v = `c${counter++}`;
|
const v = `c${counter++}`;
|
||||||
imports.push(`import * as ${v} from ${JSON.stringify(importPathFor(l.file))};`);
|
imports.push(`import { render as ${v} } from ${JSON.stringify(importPathFor(l.file))};`);
|
||||||
return `{ name: ${JSON.stringify(l.name)}, mod: ${v} }`;
|
return `{ name: ${JSON.stringify(l.name)}, mod: { render: ${v} } }`;
|
||||||
})
|
})
|
||||||
.join(", ");
|
.join(", ");
|
||||||
if (router.layouts.length) console.log(`✓ Layouts: ${router.layouts.length}`);
|
if (router.layouts.length) console.log(`✓ Layouts: ${router.layouts.length}`);
|
||||||
@@ -748,7 +751,7 @@ await createProductionServer(
|
|||||||
realtime: ${config.realtime ? JSON.stringify(config.realtime) : "undefined"},
|
realtime: ${config.realtime ? JSON.stringify(config.realtime) : "undefined"},
|
||||||
publicDir: join(import.meta.dir, "public"),
|
publicDir: join(import.meta.dir, "public"),
|
||||||
${hasStyles ? `stylesPath: join(import.meta.dir, "styles.css"),` : ""}
|
${hasStyles ? `stylesPath: join(import.meta.dir, "styles.css"),` : ""}
|
||||||
${hasStyles ? `stylesIncludeFramework: true,` : ""}
|
${hasStyles ? `stylesIncludeUi: true,` : ""}
|
||||||
${inlineStyles ? `inlineStyles: ${JSON.stringify(inlineStyles)},` : ""}
|
${inlineStyles ? `inlineStyles: ${JSON.stringify(inlineStyles)},` : ""}
|
||||||
assetVersion: ${JSON.stringify(assetVersion)},
|
assetVersion: ${JSON.stringify(assetVersion)},
|
||||||
clientRuntimes: ${JSON.stringify(emittedPluginAssets.runtimes)},
|
clientRuntimes: ${JSON.stringify(emittedPluginAssets.runtimes)},
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ function writePluginArtifacts(root: string, contributions?: PluginContributions)
|
|||||||
});
|
});
|
||||||
writeFileSync(
|
writeFileSync(
|
||||||
join(typeDir, "wrnexus.plugins.generated.d.ts"),
|
join(typeDir, "wrnexus.plugins.generated.d.ts"),
|
||||||
`// AUTO-GENERATED plugin type aggregation - do not edit.\n${references.join("\n")}\n`,
|
`// AUTO-GENERATED plugin type aggregation - do not edit.\n${references.length > 0 ? `${references.join("\n")}\n` : ""}`,
|
||||||
"utf8",
|
"utf8",
|
||||||
);
|
);
|
||||||
const docsDir = join(root, ".wrnexus", "documentation");
|
const docsDir = join(root, ".wrnexus", "documentation");
|
||||||
@@ -182,7 +182,6 @@ export function generateApplicationTypes(
|
|||||||
.map((name) => join(root, name))
|
.map((name) => join(root, name))
|
||||||
.find(existsSync);
|
.find(existsSync);
|
||||||
const code = `// AUTO-GENERATED by \`wrnexus generate types\` - do not edit.
|
const code = `// AUTO-GENERATED by \`wrnexus generate types\` - do not edit.
|
||||||
/* eslint-disable @typescript-eslint/no-empty-object-type */
|
|
||||||
declare namespace WRNexusGenerated {
|
declare namespace WRNexusGenerated {
|
||||||
type ApiContract<T> = T extends import("@wrnexus/core").DefinedEndpoint<infer I, infer O>
|
type ApiContract<T> = T extends import("@wrnexus/core").DefinedEndpoint<infer I, infer O>
|
||||||
? { input: I; output: O }
|
? { input: I; output: O }
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { afterAll, expect, test } from "bun:test";
|
||||||
|
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { runBuild } from "../src/build.ts";
|
||||||
|
|
||||||
|
const scratchRoot = join(import.meta.dir, ".tmp-css-delivery");
|
||||||
|
mkdirSync(scratchRoot, { recursive: true });
|
||||||
|
afterAll(() => rmSync(scratchRoot, { recursive: true, force: true }));
|
||||||
|
|
||||||
|
test("production ships active themes separately and retains one component style copy", async () => {
|
||||||
|
const root = mkdtempSync(join(scratchRoot, "app-"));
|
||||||
|
mkdirSync(join(root, "app", "pages"), { recursive: true });
|
||||||
|
mkdirSync(join(root, "app", "components"), { recursive: true });
|
||||||
|
mkdirSync(join(root, "app", "styles"), { recursive: true });
|
||||||
|
writeFileSync(join(root, "package.json"), JSON.stringify({ name: "css-delivery-fixture" }));
|
||||||
|
writeFileSync(
|
||||||
|
join(root, "wrnexus.config.ts"),
|
||||||
|
`export default { theme: { default: "dark", palette: "violet" }, styles: { entry: "app/styles/global.css" } };\n`,
|
||||||
|
);
|
||||||
|
writeFileSync(join(root, "app", "styles", "global.css"), ".app-shell{display:block}\n");
|
||||||
|
writeFileSync(
|
||||||
|
join(root, "app", "components", "Probe.wrn"),
|
||||||
|
`component Probe { style { .unique-server-style-marker{color:red} } view { <p class="unique-server-style-marker">Probe</p> } }\n`,
|
||||||
|
);
|
||||||
|
writeFileSync(
|
||||||
|
join(root, "app", "pages", "index.wrn"),
|
||||||
|
`import Probe from "../components/Probe.wrn"\npage Home { view { <main class="app-shell"><Probe /></main> } }\n`,
|
||||||
|
);
|
||||||
|
|
||||||
|
await runBuild(root);
|
||||||
|
const styles = readFileSync(join(root, "dist", "styles.css"), "utf8");
|
||||||
|
const activeTheme = readFileSync(join(root, "dist", "theme", "dark", "violet.css"), "utf8");
|
||||||
|
const server = readFileSync(join(root, "dist", "server.js"), "utf8");
|
||||||
|
expect(styles).toContain(".app-shell");
|
||||||
|
expect(styles).not.toContain("[data-theme=");
|
||||||
|
expect(activeTheme.length).toBeLessThan(10_000);
|
||||||
|
expect(server.match(/unique-server-style-marker/g)).toHaveLength(2);
|
||||||
|
});
|
||||||
@@ -91,6 +91,8 @@ export interface ProdOptions {
|
|||||||
inlineStyles?: string;
|
inlineStyles?: string;
|
||||||
/** `stylesPath` contains theme + UI + app CSS in cascade order. */
|
/** `stylesPath` contains theme + UI + app CSS in cascade order. */
|
||||||
stylesIncludeFramework?: boolean;
|
stylesIncludeFramework?: boolean;
|
||||||
|
/** `stylesPath` already contains the shared Wire UI stylesheet. */
|
||||||
|
stylesIncludeUi?: boolean;
|
||||||
/** Absolute path to the pre-built reactive runtime. */
|
/** Absolute path to the pre-built reactive runtime. */
|
||||||
reactivePath?: string;
|
reactivePath?: string;
|
||||||
/** Absolute path to the on-demand component controller runtime. */
|
/** Absolute path to the on-demand component controller runtime. */
|
||||||
@@ -501,6 +503,7 @@ export function createProductionHandlers(
|
|||||||
i18n: opts.i18n,
|
i18n: opts.i18n,
|
||||||
inlineStyles: opts.inlineStyles,
|
inlineStyles: opts.inlineStyles,
|
||||||
stylesIncludeFramework: opts.stylesIncludeFramework,
|
stylesIncludeFramework: opts.stylesIncludeFramework,
|
||||||
|
stylesIncludeUi: opts.stylesIncludeUi,
|
||||||
assetVersion: opts.assetVersion,
|
assetVersion: opts.assetVersion,
|
||||||
clientRuntimes: opts.clientRuntimes,
|
clientRuntimes: opts.clientRuntimes,
|
||||||
head: opts.head,
|
head: opts.head,
|
||||||
|
|||||||
@@ -160,6 +160,7 @@ export interface RuntimeDeps {
|
|||||||
hasFrameworkStyles?: boolean;
|
hasFrameworkStyles?: boolean;
|
||||||
/** App stylesheet already contains theme + UI CSS and is the only CSS request needed. */
|
/** App stylesheet already contains theme + UI CSS and is the only CSS request needed. */
|
||||||
stylesIncludeFramework?: boolean;
|
stylesIncludeFramework?: boolean;
|
||||||
|
stylesIncludeUi?: boolean;
|
||||||
/** Resolved theme config: enables `/__wrnexus/theme.css` + `<html data-theme>`. */
|
/** Resolved theme config: enables `/__wrnexus/theme.css` + `<html data-theme>`. */
|
||||||
theme?: ResolvedTheme;
|
theme?: ResolvedTheme;
|
||||||
/** Resolved i18n bundle: enables `ctx.t`, `<html lang>`, and `{t:key}` markers. */
|
/** Resolved i18n bundle: enables `ctx.t`, `<html lang>`, and `{t:key}` markers. */
|
||||||
@@ -940,12 +941,12 @@ export function createHandlers(deps: RuntimeDeps): Handlers {
|
|||||||
// Order: theme tokens, then Wire UI, then the app stylesheet — so the app's
|
// Order: theme tokens, then Wire UI, then the app stylesheet — so the app's
|
||||||
// own CSS (loaded last) can override both the tokens and the UI classes.
|
// own CSS (loaded last) can override both the tokens and the UI classes.
|
||||||
const headParts: string[] = [];
|
const headParts: string[] = [];
|
||||||
if (deps.hasFrameworkStyles && !deps.stylesIncludeFramework) {
|
if (!deps.theme && deps.hasFrameworkStyles && !deps.stylesIncludeFramework) {
|
||||||
headParts.push(
|
headParts.push(
|
||||||
`<link rel="stylesheet" href="${versionAssetUrl("/__wrnexus/framework.css", deps.assetVersion)}" />`,
|
`<link rel="stylesheet" href="${versionAssetUrl("/__wrnexus/framework.css", deps.assetVersion)}" />`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (deps.hasUi && !deps.hasFrameworkStyles && !deps.stylesIncludeFramework) {
|
if (deps.hasUi && !deps.stylesIncludeUi && !deps.stylesIncludeFramework) {
|
||||||
headParts.push(
|
headParts.push(
|
||||||
`<link rel="stylesheet" href="${versionAssetUrl("/__wrnexus/ui.css", deps.assetVersion)}" />`,
|
`<link rel="stylesheet" href="${versionAssetUrl("/__wrnexus/ui.css", deps.assetVersion)}" />`,
|
||||||
);
|
);
|
||||||
@@ -1847,7 +1848,7 @@ export function createHandlers(deps: RuntimeDeps): Handlers {
|
|||||||
if (accentName) {
|
if (accentName) {
|
||||||
attrs.push(`data-accent="${accentName}"`);
|
attrs.push(`data-accent="${accentName}"`);
|
||||||
}
|
}
|
||||||
if (!deps.hasFrameworkStyles && !deps.stylesIncludeFramework) {
|
if (!deps.stylesIncludeFramework) {
|
||||||
const themeHref = versionAssetUrl(
|
const themeHref = versionAssetUrl(
|
||||||
activeThemeCssHref(themeName, accentName),
|
activeThemeCssHref(themeName, accentName),
|
||||||
deps.assetVersion,
|
deps.assetVersion,
|
||||||
|
|||||||
@@ -38,4 +38,39 @@ describe("syntax contract and security diagnostics", () => {
|
|||||||
expect(codes).toContain("WRN-SEC-STRING-TIMER");
|
expect(codes).toContain("WRN-SEC-STRING-TIMER");
|
||||||
expect(codes).toContain("WRN-PERSIST-SENSITIVE");
|
expect(codes).toContain("WRN-PERSIST-SENSITIVE");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test.each([
|
||||||
|
[
|
||||||
|
"server output call",
|
||||||
|
`component Bad { outputs { save() } functions { server function run(): void { output.save() } } view { <div></div> } }`,
|
||||||
|
"WRN-OUTPUT-SERVER-CALL",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"unknown output call",
|
||||||
|
`component Bad { functions { client function run(): void { output.missing() } } view { <button @click="run()">Run</button> } }`,
|
||||||
|
"WRN-OUTPUT-UNKNOWN",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"server API in client function",
|
||||||
|
`component Bad { functions { client function run(): void { process.cwd() } } view { <button @click="run()">Run</button> } }`,
|
||||||
|
"WRN-CLIENT-SERVER-API",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"browser API in server function",
|
||||||
|
`component Bad { functions { server function run(): void { document.title = "bad" } } view { <div></div> } }`,
|
||||||
|
"WRN-SERVER-BROWSER-API",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"non-serializable shared state",
|
||||||
|
`global store Bad { state { values: unknown = new Map() } }`,
|
||||||
|
"WRN-STATE-NON-SERIALIZABLE",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"unknown persisted state",
|
||||||
|
`global store Bad { state { value: string = "x" } persist { storage = "local" include = ["missing"] version = 1 } }`,
|
||||||
|
"WRN-PERSIST-UNKNOWN-FIELD",
|
||||||
|
],
|
||||||
|
])("diagnoses %s", (_name, source, expectedCode) => {
|
||||||
|
expect(diagnose(source).map((diagnostic) => diagnostic.code)).toContain(expectedCode);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { cpSync, existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { generateApplicationTypesWithPlugins } from "../packages/cli/src/types.ts";
|
||||||
|
|
||||||
|
const root = process.cwd();
|
||||||
|
const source = join(root, "examples", "basic-app");
|
||||||
|
const scratchParent = join(root, ".wrnexus-type-check");
|
||||||
|
const scratch = mkdtempSync(`${scratchParent}-`);
|
||||||
|
const generated = [
|
||||||
|
join("app", "types", "wrnexus.generated.d.ts"),
|
||||||
|
join("app", "types", "wrnexus.plugins.generated.d.ts"),
|
||||||
|
];
|
||||||
|
|
||||||
|
try {
|
||||||
|
cpSync(source, scratch, {
|
||||||
|
recursive: true,
|
||||||
|
filter(path) {
|
||||||
|
const relative = path.slice(source.length).replaceAll("\\", "/");
|
||||||
|
return !/(?:^|\/)(?:dist|node_modules|\.wrnexus(?:-[^/]*)?)(?:\/|$)/.test(relative);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await generateApplicationTypesWithPlugins(scratch);
|
||||||
|
|
||||||
|
const stale = generated.filter((relative) => {
|
||||||
|
const committed = join(source, relative);
|
||||||
|
const expected = join(scratch, relative);
|
||||||
|
return (
|
||||||
|
!existsSync(committed) ||
|
||||||
|
!existsSync(expected) ||
|
||||||
|
readFileSync(committed, "utf8") !== readFileSync(expected, "utf8")
|
||||||
|
);
|
||||||
|
});
|
||||||
|
if (stale.length) {
|
||||||
|
throw new Error(
|
||||||
|
`Generated application types are stale: ${stale.join(", ")}. Run \`bun run generate:example-types\`.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
console.log(`Generated application types match ${generated.length} committed artifacts.`);
|
||||||
|
} finally {
|
||||||
|
rmSync(scratch, { recursive: true, force: true });
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user