veryfront 0.1.131 → 0.1.136

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.
Files changed (140) hide show
  1. package/esm/_dnt.polyfills.d.ts +11 -0
  2. package/esm/_dnt.polyfills.d.ts.map +1 -1
  3. package/esm/_dnt.polyfills.js +14 -0
  4. package/esm/cli/commands/build/handler.js +2 -0
  5. package/esm/cli/commands/deploy/command.d.ts.map +1 -1
  6. package/esm/cli/commands/deploy/command.js +2 -0
  7. package/esm/cli/commands/files/command.d.ts.map +1 -1
  8. package/esm/cli/commands/files/command.js +1 -0
  9. package/esm/cli/commands/uploads/command.d.ts.map +1 -1
  10. package/esm/cli/commands/uploads/command.js +1 -0
  11. package/esm/cli/help/tips.d.ts +3 -0
  12. package/esm/cli/help/tips.d.ts.map +1 -1
  13. package/esm/cli/help/tips.js +15 -1
  14. package/esm/cli/router.d.ts.map +1 -1
  15. package/esm/cli/router.js +4 -0
  16. package/esm/cli/shared/animation.d.ts +3 -0
  17. package/esm/cli/shared/animation.d.ts.map +1 -0
  18. package/esm/cli/shared/animation.js +23 -0
  19. package/esm/cli/templates/manifest.d.ts +6 -0
  20. package/esm/cli/templates/manifest.js +12 -6
  21. package/esm/cli/ui/progress.d.ts.map +1 -1
  22. package/esm/cli/ui/progress.js +13 -1
  23. package/esm/deno.js +1 -1
  24. package/esm/src/agent/index.d.ts +1 -1
  25. package/esm/src/agent/index.d.ts.map +1 -1
  26. package/esm/src/agent/runtime/ai-stream-handler.d.ts.map +1 -1
  27. package/esm/src/agent/runtime/ai-stream-handler.js +56 -5
  28. package/esm/src/agent/runtime/index.d.ts.map +1 -1
  29. package/esm/src/agent/runtime/index.js +21 -3
  30. package/esm/src/agent/runtime/model-tool-converter.d.ts +5 -1
  31. package/esm/src/agent/runtime/model-tool-converter.d.ts.map +1 -1
  32. package/esm/src/agent/runtime/model-tool-converter.js +35 -4
  33. package/esm/src/agent/runtime/tool-helpers.d.ts +2 -1
  34. package/esm/src/agent/runtime/tool-helpers.d.ts.map +1 -1
  35. package/esm/src/agent/runtime/tool-helpers.js +6 -3
  36. package/esm/src/agent/types.d.ts +19 -0
  37. package/esm/src/agent/types.d.ts.map +1 -1
  38. package/esm/src/channels/control-plane.d.ts +67 -0
  39. package/esm/src/channels/control-plane.d.ts.map +1 -1
  40. package/esm/src/channels/control-plane.js +27 -0
  41. package/esm/src/discovery/handlers/tool-handler.d.ts.map +1 -1
  42. package/esm/src/discovery/handlers/tool-handler.js +12 -2
  43. package/esm/src/html/html-injection.d.ts +2 -0
  44. package/esm/src/html/html-injection.d.ts.map +1 -1
  45. package/esm/src/html/html-injection.js +10 -5
  46. package/esm/src/html/nonce-injection.d.ts +3 -0
  47. package/esm/src/html/nonce-injection.d.ts.map +1 -0
  48. package/esm/src/html/nonce-injection.js +249 -0
  49. package/esm/src/internal-agents/ag-ui-sse.d.ts +1 -0
  50. package/esm/src/internal-agents/ag-ui-sse.d.ts.map +1 -1
  51. package/esm/src/internal-agents/ag-ui-sse.js +18 -0
  52. package/esm/src/internal-agents/run-stream.d.ts.map +1 -1
  53. package/esm/src/internal-agents/run-stream.js +26 -4
  54. package/esm/src/platform/adapters/fs/veryfront/proxy-manager.d.ts +1 -0
  55. package/esm/src/platform/adapters/fs/veryfront/proxy-manager.d.ts.map +1 -1
  56. package/esm/src/platform/adapters/fs/veryfront/proxy-manager.js +15 -1
  57. package/esm/src/platform/adapters/fs/veryfront/types.d.ts +2 -0
  58. package/esm/src/platform/adapters/fs/veryfront/types.d.ts.map +1 -1
  59. package/esm/src/platform/adapters/fs/veryfront/websocket-manager.d.ts.map +1 -1
  60. package/esm/src/platform/adapters/fs/veryfront/websocket-manager.js +2 -0
  61. package/esm/src/proxy/handler.d.ts.map +1 -1
  62. package/esm/src/proxy/handler.js +25 -5
  63. package/esm/src/react/components/Head.d.ts +9 -0
  64. package/esm/src/react/components/Head.d.ts.map +1 -1
  65. package/esm/src/react/components/Head.js +9 -0
  66. package/esm/src/react/context/index.d.ts +9 -0
  67. package/esm/src/react/context/index.d.ts.map +1 -1
  68. package/esm/src/react/context/index.js +9 -0
  69. package/esm/src/react/router/index.d.ts +9 -0
  70. package/esm/src/react/router/index.d.ts.map +1 -1
  71. package/esm/src/react/router/index.js +9 -0
  72. package/esm/src/rendering/orchestrator/html.d.ts +1 -0
  73. package/esm/src/rendering/orchestrator/html.d.ts.map +1 -1
  74. package/esm/src/rendering/orchestrator/html.js +81 -89
  75. package/esm/src/rendering/script-page-handling.js +1 -0
  76. package/esm/src/server/handlers/dev/framework-candidates.generated.d.ts.map +1 -1
  77. package/esm/src/server/handlers/dev/framework-candidates.generated.js +9 -0
  78. package/esm/src/server/handlers/request/ssr/ssr-response-builder.d.ts.map +1 -1
  79. package/esm/src/server/handlers/request/ssr/ssr-response-builder.js +3 -7
  80. package/esm/src/server/handlers/request/static.handler.d.ts.map +1 -1
  81. package/esm/src/server/handlers/request/static.handler.js +18 -10
  82. package/esm/src/tool/factory.d.ts.map +1 -1
  83. package/esm/src/tool/factory.js +14 -4
  84. package/esm/src/tool/types.d.ts +2 -0
  85. package/esm/src/tool/types.d.ts.map +1 -1
  86. package/esm/src/transforms/pipeline/stages/ssr-vf-modules/constants.d.ts +1 -0
  87. package/esm/src/transforms/pipeline/stages/ssr-vf-modules/constants.d.ts.map +1 -1
  88. package/esm/src/transforms/pipeline/stages/ssr-vf-modules/constants.js +3 -0
  89. package/esm/src/transforms/pipeline/stages/ssr-vf-modules/import-finder.d.ts.map +1 -1
  90. package/esm/src/transforms/pipeline/stages/ssr-vf-modules/import-finder.js +4 -2
  91. package/esm/src/transforms/pipeline/stages/ssr-vf-modules/index.d.ts +1 -1
  92. package/esm/src/transforms/pipeline/stages/ssr-vf-modules/index.d.ts.map +1 -1
  93. package/esm/src/transforms/pipeline/stages/ssr-vf-modules/index.js +10 -9
  94. package/esm/src/transforms/pipeline/stages/ssr-vf-modules/path-resolver.d.ts.map +1 -1
  95. package/esm/src/transforms/pipeline/stages/ssr-vf-modules/path-resolver.js +3 -1
  96. package/esm/src/utils/version-constant.d.ts +1 -1
  97. package/esm/src/utils/version-constant.js +1 -1
  98. package/package.json +1 -1
  99. package/src/_dnt.polyfills.ts +27 -0
  100. package/src/cli/commands/build/handler.ts +3 -0
  101. package/src/cli/commands/deploy/command.ts +3 -0
  102. package/src/cli/commands/files/command.ts +1 -0
  103. package/src/cli/commands/uploads/command.ts +3 -0
  104. package/src/cli/help/tips.ts +18 -1
  105. package/src/cli/router.ts +5 -0
  106. package/src/cli/shared/animation.ts +25 -0
  107. package/src/cli/templates/manifest.js +12 -6
  108. package/src/cli/ui/progress.ts +13 -1
  109. package/src/deno.js +1 -1
  110. package/src/src/agent/index.ts +2 -0
  111. package/src/src/agent/runtime/ai-stream-handler.ts +64 -6
  112. package/src/src/agent/runtime/index.ts +26 -1
  113. package/src/src/agent/runtime/model-tool-converter.ts +47 -3
  114. package/src/src/agent/runtime/tool-helpers.ts +15 -3
  115. package/src/src/agent/types.ts +23 -0
  116. package/src/src/channels/control-plane.ts +31 -0
  117. package/src/src/discovery/handlers/tool-handler.ts +13 -2
  118. package/src/src/html/html-injection.ts +16 -5
  119. package/src/src/html/nonce-injection.ts +300 -0
  120. package/src/src/internal-agents/ag-ui-sse.ts +20 -0
  121. package/src/src/internal-agents/run-stream.ts +35 -4
  122. package/src/src/platform/adapters/fs/veryfront/proxy-manager.ts +29 -3
  123. package/src/src/platform/adapters/fs/veryfront/types.ts +2 -0
  124. package/src/src/platform/adapters/fs/veryfront/websocket-manager.ts +2 -0
  125. package/src/src/proxy/handler.ts +43 -5
  126. package/src/src/react/components/Head.tsx +10 -0
  127. package/src/src/react/context/index.tsx +10 -0
  128. package/src/src/react/router/index.tsx +10 -0
  129. package/src/src/rendering/orchestrator/html.ts +125 -100
  130. package/src/src/rendering/script-page-handling.ts +1 -0
  131. package/src/src/server/handlers/dev/framework-candidates.generated.ts +9 -0
  132. package/src/src/server/handlers/request/ssr/ssr-response-builder.ts +7 -11
  133. package/src/src/server/handlers/request/static.handler.ts +22 -10
  134. package/src/src/tool/factory.ts +17 -4
  135. package/src/src/tool/types.ts +2 -0
  136. package/src/src/transforms/pipeline/stages/ssr-vf-modules/constants.ts +4 -0
  137. package/src/src/transforms/pipeline/stages/ssr-vf-modules/import-finder.ts +4 -2
  138. package/src/src/transforms/pipeline/stages/ssr-vf-modules/index.ts +18 -15
  139. package/src/src/transforms/pipeline/stages/ssr-vf-modules/path-resolver.ts +4 -1
  140. package/src/src/utils/version-constant.ts +1 -1
@@ -20,6 +20,7 @@ export interface RunFinishedMetadata {
20
20
  export interface StreamTransformState {
21
21
  messageId: string | null;
22
22
  textOpen: boolean;
23
+ sawVisibleOutput: boolean;
23
24
  sawTerminalError: boolean;
24
25
  metadata: RunFinishedMetadata;
25
26
  }
@@ -28,6 +29,7 @@ export function createStreamTransformState(): StreamTransformState {
28
29
  return {
29
30
  messageId: null,
30
31
  textOpen: false,
32
+ sawVisibleOutput: false,
31
33
  sawTerminalError: false,
32
34
  metadata: {},
33
35
  };
@@ -181,6 +183,7 @@ export function mapRuntimeEventToAgUi(
181
183
  if (state.textOpen) return [];
182
184
  const messageId = getMessageId(state, event);
183
185
  state.textOpen = true;
186
+ state.sawVisibleOutput = true;
184
187
  return [{
185
188
  event: "TextMessageStart",
186
189
  payload: { messageId, role: "assistant" },
@@ -189,6 +192,7 @@ export function mapRuntimeEventToAgUi(
189
192
 
190
193
  case "text-delta": {
191
194
  const messageId = getMessageId(state, event);
195
+ state.sawVisibleOutput = true;
192
196
  if (!state.textOpen) {
193
197
  state.textOpen = true;
194
198
  return [
@@ -216,6 +220,7 @@ export function mapRuntimeEventToAgUi(
216
220
  }
217
221
 
218
222
  case "tool-input-start":
223
+ state.sawVisibleOutput = true;
219
224
  return [{
220
225
  event: "ToolCallStart",
221
226
  payload: {
@@ -225,6 +230,7 @@ export function mapRuntimeEventToAgUi(
225
230
  }];
226
231
 
227
232
  case "tool-input-delta":
233
+ state.sawVisibleOutput = true;
228
234
  return [{
229
235
  event: "ToolCallArgs",
230
236
  payload: {
@@ -234,12 +240,14 @@ export function mapRuntimeEventToAgUi(
234
240
  }];
235
241
 
236
242
  case "tool-input-available":
243
+ state.sawVisibleOutput = true;
237
244
  return [{
238
245
  event: "ToolCallEnd",
239
246
  payload: { toolCallId: event.toolCallId },
240
247
  }];
241
248
 
242
249
  case "tool-output-available":
250
+ state.sawVisibleOutput = true;
243
251
  return [{
244
252
  event: "ToolCallResult",
245
253
  payload: {
@@ -249,6 +257,7 @@ export function mapRuntimeEventToAgUi(
249
257
  }];
250
258
 
251
259
  case "tool-output-error":
260
+ state.sawVisibleOutput = true;
252
261
  return [{
253
262
  event: "ToolCallResult",
254
263
  payload: {
@@ -292,6 +301,17 @@ export function finalizeRunEvents(
292
301
  return [];
293
302
  }
294
303
 
304
+ if (!state.sawVisibleOutput) {
305
+ state.sawTerminalError = true;
306
+ return [{
307
+ event: "RunError",
308
+ payload: {
309
+ code: "EMPTY_ASSISTANT_OUTPUT",
310
+ message: "Agent run produced no assistant-visible output",
311
+ },
312
+ }];
313
+ }
314
+
295
315
  const events: Array<{ event: string; payload: Record<string, unknown> }> = [];
296
316
  if (state.textOpen) {
297
317
  state.textOpen = false;
@@ -20,6 +20,12 @@ import type { RuntimeRunAgentInput } from "./schema.js";
20
20
 
21
21
  const anyObjectSchema = z.record(z.string(), z.unknown());
22
22
 
23
+ type RuntimeFilteredAgent = Agent & {
24
+ config: Agent["config"] & {
25
+ __vfAllowedRemoteTools?: string[];
26
+ };
27
+ };
28
+
23
29
  export interface RuntimeAgentStreamExecutionDeps {
24
30
  sessionManager: AgentRunSessionManager;
25
31
  createRuntime?: (
@@ -203,6 +209,22 @@ function normalizeRuntimeMessages(messages: RuntimeRunAgentInput["messages"]): M
203
209
  }));
204
210
  }
205
211
 
212
+ function getAllowedRemoteToolNames(
213
+ forwardedProps: RuntimeRunAgentInput["forwardedProps"],
214
+ ): string[] | undefined {
215
+ const runtimeOverrides = isRecord(forwardedProps?.runtimeOverrides)
216
+ ? forwardedProps.runtimeOverrides
217
+ : null;
218
+ if (!runtimeOverrides || !Object.hasOwn(runtimeOverrides, "allowedTools")) {
219
+ return undefined;
220
+ }
221
+ const allowedTools = runtimeOverrides.allowedTools;
222
+ if (!Array.isArray(allowedTools)) {
223
+ return [];
224
+ }
225
+ return allowedTools.every((toolName) => typeof toolName === "string") ? allowedTools : [];
226
+ }
227
+
206
228
  export async function createRuntimeAgentStreamResponse(
207
229
  input: RuntimeRunAgentInput,
208
230
  agent: Agent,
@@ -214,10 +236,19 @@ export async function createRuntimeAgentStreamResponse(
214
236
  });
215
237
 
216
238
  const mergedTools = buildMergedTools(agent, input, deps.sessionManager);
217
- const runtime = deps.createRuntime?.(agent, mergedTools) ?? new AgentRuntime(agent.id, {
218
- ...agent.config,
219
- tools: mergedTools,
220
- });
239
+ const allowedRemoteToolNames = getAllowedRemoteToolNames(input.forwardedProps);
240
+ const runtimeAgent: RuntimeFilteredAgent = {
241
+ ...agent,
242
+ config: {
243
+ ...agent.config,
244
+ tools: mergedTools,
245
+ ...(allowedRemoteToolNames !== undefined
246
+ ? { __vfAllowedRemoteTools: allowedRemoteToolNames }
247
+ : {}),
248
+ },
249
+ };
250
+ const runtime = deps.createRuntime?.(runtimeAgent, mergedTools) ??
251
+ new AgentRuntime(runtimeAgent.id, runtimeAgent.config);
221
252
 
222
253
  let completedResponse: AgentResponse | null = null;
223
254
  const runtimeMessages = normalizeRuntimeMessages(input.messages);
@@ -398,9 +398,11 @@ export class ProxyFSAdapterManager {
398
398
  projectId,
399
399
  apiToken: effectiveToken,
400
400
  },
401
- invalidationCallbacks: createDefaultInvalidationCallbacks(
402
- this.baseConfig.invalidationCallbacks,
403
- ),
401
+ invalidationCallbacks: createDefaultInvalidationCallbacks({
402
+ ...this.baseConfig.invalidationCallbacks,
403
+ evictCurrentAdapter: () =>
404
+ this.evictAdapter(projectSlug, productionMode, releaseId, branch),
405
+ }),
404
406
  };
405
407
 
406
408
  const adapter = new VeryfrontFSAdapter(config);
@@ -557,6 +559,30 @@ export class ProxyFSAdapterManager {
557
559
  return this.adapters.has(cacheKey);
558
560
  }
559
561
 
562
+ evictAdapter(
563
+ projectSlug: string,
564
+ productionMode?: boolean,
565
+ releaseId?: string | null,
566
+ branch?: string | null,
567
+ ): void {
568
+ const cacheKey = buildProxyManagerCacheKey(
569
+ projectSlug,
570
+ productionMode ?? false,
571
+ releaseId ?? null,
572
+ branch ?? null,
573
+ );
574
+
575
+ const adapter = this.adapters.get(cacheKey);
576
+ if (!adapter) {
577
+ logger.debug("No adapter to evict", { cacheKey });
578
+ return;
579
+ }
580
+
581
+ logger.debug("Evicting adapter", { cacheKey });
582
+ adapter.adapter.dispose();
583
+ this.adapters.delete(cacheKey);
584
+ }
585
+
560
586
  getStats(): { adapters: number; stats: Record<string, CacheStats> } {
561
587
  const stats: Record<string, CacheStats> = {};
562
588
 
@@ -137,4 +137,6 @@ export interface InvalidationCallbacks {
137
137
  clearProjectCSSCache?: (projectSlug: string) => void;
138
138
  /** Clear domain lookup cache to refresh release IDs after publishing */
139
139
  clearDomainCache?: () => void;
140
+ /** Evict the current shared proxy adapter after successful invalidation */
141
+ evictCurrentAdapter?: () => void;
140
142
  }
@@ -693,6 +693,7 @@ export class WebSocketManager {
693
693
  previewInvalidationPrefixes,
694
694
  previewInvalidationVersion,
695
695
  );
696
+ this.deps.invalidationCallbacks.evictCurrentAdapter?.();
696
697
  }
697
698
  }
698
699
  }
@@ -837,6 +838,7 @@ export class WebSocketManager {
837
838
  previewInvalidationPrefixes,
838
839
  previewInvalidationVersion,
839
840
  );
841
+ this.deps.invalidationCallbacks.evictCurrentAdapter?.();
840
842
  }
841
843
  }
842
844
  }
@@ -410,6 +410,24 @@ export function createProxyHandler(options: ProxyHandlerOptions) {
410
410
  );
411
411
  }
412
412
 
413
+ function makeProjectNotFoundContext(
414
+ base: {
415
+ scope: TokenScope;
416
+ host: string;
417
+ parsedDomain: ParsedDomain;
418
+ },
419
+ token?: string,
420
+ ): ProxyContext {
421
+ return makeErrorContext(
422
+ base,
423
+ 404,
424
+ "Project not found",
425
+ token,
426
+ undefined,
427
+ "project-not-found",
428
+ );
429
+ }
430
+
413
431
  async function resolveRequestToken(
414
432
  req: dntShim.Request,
415
433
  scope: TokenScope,
@@ -607,20 +625,30 @@ export function createProxyHandler(options: ProxyHandlerOptions) {
607
625
  },
608
626
  ));
609
627
 
610
- if (projectSlug && scope === "preview" && !token) {
628
+ if (projectSlug && !token) {
611
629
  const status = parseStatusFromError(tokenFetchError);
612
630
  if (status === 404) {
613
- logger?.info("Preview project not found", { projectSlug, host });
614
- return makePreviewProjectNotFoundContext(base);
631
+ if (scope === "preview") {
632
+ logger?.info("Preview project not found", { projectSlug, host });
633
+ return makePreviewProjectNotFoundContext(base);
634
+ }
635
+
636
+ logger?.info("Project not found", { projectSlug, host, scope });
637
+ return makeProjectNotFoundContext(base);
615
638
  }
616
639
 
617
- logger?.warn("Preview request has no usable token", {
640
+ const message = scope === "preview"
641
+ ? "Failed to authenticate preview request"
642
+ : "Failed to authenticate project request";
643
+
644
+ logger?.warn("Project request has no usable token", {
618
645
  projectSlug,
619
646
  host,
647
+ scope,
620
648
  hadUserToken: !!userToken,
621
649
  hadTokenFetchError: !!tokenFetchError,
622
650
  });
623
- return makeErrorContext(base, 502, "Failed to authenticate preview request");
651
+ return makeErrorContext(base, 502, message);
624
652
  }
625
653
 
626
654
  if (isCustomDomain && !projectSlug) {
@@ -692,6 +720,16 @@ export function createProxyHandler(options: ProxyHandlerOptions) {
692
720
  );
693
721
  }
694
722
 
723
+ if (!resolved.projectId) {
724
+ logger?.info("Project not found after lookup", {
725
+ projectSlug,
726
+ host,
727
+ scope,
728
+ targetEnvName: parsedDomain.environment,
729
+ });
730
+ return makeProjectNotFoundContext(base, token);
731
+ }
732
+
695
733
  projectId = resolved.projectId;
696
734
  releaseId = resolved.releaseId;
697
735
  environmentId = resolved.environmentId;
@@ -1,2 +1,12 @@
1
+ /**
2
+ * React head exports for document metadata rendering.
3
+ *
4
+ * @module
5
+ * @example
6
+ * ```tsx
7
+ * import { Head } from "veryfront/head";
8
+ * ```
9
+ */
1
10
  import "../../../_dnt.polyfills.js";
11
+
2
12
  export { Head } from "../runtime/core.js";
@@ -1,3 +1,13 @@
1
+ /**
2
+ * React page-context exports for MDX and route-aware rendering.
3
+ *
4
+ * @module
5
+ * @example
6
+ * ```tsx
7
+ * import { PageContextProvider, usePageContext } from "veryfront/context";
8
+ * ```
9
+ */
1
10
  import "../../../_dnt.polyfills.js";
11
+
2
12
  export { PageContextProvider, usePageContext } from "../runtime/core.js";
3
13
  export type { MdxHeading, PageContextProviderProps, PageContextValue } from "../runtime/core.js";
@@ -1,3 +1,13 @@
1
+ /**
2
+ * React router exports for client navigation and route context.
3
+ *
4
+ * @module
5
+ * @example
6
+ * ```tsx
7
+ * import { Link, RouterProvider, useRouter } from "veryfront/router";
8
+ * ```
9
+ */
1
10
  import "../../../_dnt.polyfills.js";
11
+
2
12
  export { Link, Router, RouterProvider, useRouter } from "../runtime/core.js";
3
13
  export type { LinkProps, RouterProviderProps, RouterValue } from "../runtime/core.js";
@@ -19,7 +19,7 @@ import type {
19
19
  PageBundle,
20
20
  } from "../../types/index.js";
21
21
  import { DEFAULT_DASHBOARD_PORT, rendererLogger } from "../../utils/index.js";
22
- import { escapeHtml } from "../../utils/html-escape.js";
22
+ import { addNonceToHtmlTags } from "../../html/nonce-injection.js";
23
23
  import type { RenderOptions } from "./types.js";
24
24
  import { injectElementSelectors } from "../../studio/element-selector-injector.js";
25
25
  import { computeSourceHash } from "../../studio/hash-utils.js";
@@ -41,106 +41,60 @@ import type { ResolvedContentContext } from "../../platform/adapters/fs/veryfron
41
41
  const logger = rendererLogger.component("html-generator");
42
42
  type ProjectCSSResult = Awaited<ReturnType<typeof getProjectCSS>> | null;
43
43
 
44
- function findTagEnd(html: string, start: number): number {
45
- let activeQuote: '"' | "'" | null = null;
44
+ function applyExplicitThemeToDocument(
45
+ html: string,
46
+ colorScheme: "light" | "dark" | undefined,
47
+ enabled: boolean | undefined,
48
+ ): string {
49
+ if (!enabled || !colorScheme) return html;
46
50
 
47
- for (let index = start + 1; index < html.length; index++) {
48
- const char = html[index];
51
+ return html.replace(/<html\b([^>]*)>/i, (_match, attrs: string) => {
52
+ let nextAttrs = attrs;
49
53
 
50
- if (activeQuote) {
51
- if (char === activeQuote) activeQuote = null;
52
- continue;
54
+ if (/\sdata-theme\s*=/i.test(nextAttrs)) {
55
+ nextAttrs = nextAttrs.replace(/\sdata-theme\s*=\s*(["']).*?\1/i, "");
53
56
  }
54
-
55
- if (char === '"' || char === "'") {
56
- activeQuote = char;
57
- continue;
58
- }
59
-
60
- if (char === ">") return index;
61
- }
62
-
63
- return -1;
64
- }
65
-
66
- function getOpeningTagName(tag: string): "script" | "style" | undefined {
67
- const match = /^<\s*([a-zA-Z][\w:-]*)/u.exec(tag);
68
- const tagName = match?.[1]?.toLowerCase();
69
- if (tagName === "script" || tagName === "style") return tagName;
70
- return undefined;
71
- }
72
-
73
- function injectNonceIntoOpeningTag(tag: string, escapedNonce: string): string {
74
- if (/\bnonce\s*=/iu.test(tag)) return tag;
75
-
76
- const closeIndex = tag.lastIndexOf(">");
77
- if (closeIndex === -1) return tag;
78
-
79
- const insertAt = /\/\s*>$/u.test(tag) ? closeIndex - 1 : closeIndex;
80
- return `${tag.slice(0, insertAt)} nonce="${escapedNonce}"${tag.slice(insertAt)}`;
81
- }
82
-
83
- function addNonceToRenderedTags(html: string, nonce?: string): string {
84
- if (!nonce) return html;
85
-
86
- const escapedNonce = escapeHtml(nonce);
87
- const lowerHtml = html.toLowerCase();
88
- let result = "";
89
- let index = 0;
90
- let rawTextTag: "script" | "style" | null = null;
91
-
92
- while (index < html.length) {
93
- if (rawTextTag) {
94
- const closingIndex = lowerHtml.indexOf(`</${rawTextTag}`, index);
95
- if (closingIndex === -1) {
96
- result += html.slice(index);
97
- break;
57
+ nextAttrs += ` data-theme="${colorScheme}"`;
58
+
59
+ const styleMatch = nextAttrs.match(/\sstyle\s*=\s*(["'])(.*?)\1/i);
60
+ if (styleMatch) {
61
+ let styleValue = (styleMatch[2] ?? "").trim();
62
+
63
+ if (/color-scheme\s*:/i.test(styleValue)) {
64
+ styleValue = styleValue.replace(
65
+ /color-scheme\s*:\s*[^;]+/i,
66
+ `color-scheme: ${colorScheme}`,
67
+ );
68
+ } else {
69
+ styleValue = styleValue
70
+ ? `${styleValue.replace(/;?\s*$/, ";")} color-scheme: ${colorScheme};`
71
+ : `color-scheme: ${colorScheme};`;
98
72
  }
99
73
 
100
- result += html.slice(index, closingIndex);
101
- index = closingIndex;
102
- rawTextTag = null;
103
- continue;
74
+ nextAttrs = nextAttrs.replace(styleMatch[0], ` style="${styleValue}"`);
75
+ } else {
76
+ nextAttrs += ` style="color-scheme: ${colorScheme};"`;
104
77
  }
105
78
 
106
- if (html.startsWith("<!--", index)) {
107
- const commentEnd = html.indexOf("-->", index + 4);
108
- const endIndex = commentEnd === -1 ? html.length : commentEnd + 3;
109
- result += html.slice(index, endIndex);
110
- index = endIndex;
111
- continue;
112
- }
113
-
114
- if (html[index] !== "<") {
115
- result += html[index];
116
- index++;
117
- continue;
118
- }
119
-
120
- const tagEnd = findTagEnd(html, index);
121
- if (tagEnd === -1) {
122
- result += html.slice(index);
123
- break;
124
- }
125
-
126
- const tag = html.slice(index, tagEnd + 1);
127
- const tagName = getOpeningTagName(tag);
128
-
129
- if (!tagName) {
130
- result += tag;
131
- index = tagEnd + 1;
132
- continue;
133
- }
134
-
135
- result += injectNonceIntoOpeningTag(tag, escapedNonce);
136
- index = tagEnd + 1;
137
-
138
- if (!/\/\s*>$/u.test(tag)) {
139
- rawTextTag = tagName;
140
- }
141
- }
79
+ return `<html${nextAttrs}>`;
80
+ });
81
+ }
142
82
 
143
- return result;
83
+ function injectThemePersistenceScript(
84
+ html: string,
85
+ colorScheme: "light" | "dark" | undefined,
86
+ enabled: boolean | undefined,
87
+ nonce?: string,
88
+ ): string {
89
+ if (!enabled || !colorScheme || !/<\/head>/i.test(html)) return html;
90
+ if (html.includes(`localStorage.setItem('theme','${colorScheme}')`)) return html;
91
+
92
+ const nonceAttr = nonce ? ` nonce="${nonce}"` : "";
93
+ const script = `<script${nonceAttr}>
94
+ (function(){try{localStorage.setItem('theme','${colorScheme}')}catch(e){/* SILENT: localStorage may be unavailable */}})();
95
+ </script>`;
96
+
97
+ return html.replace(/<\/head>/i, `${script}\n</head>`);
144
98
  }
145
99
 
146
100
  export interface HTMLGeneratorConfig {
@@ -173,16 +127,29 @@ export class HTMLGenerator {
173
127
  }
174
128
 
175
129
  async generateFullHTML(context: HTMLGenerationContext): Promise<string> {
176
- const html = isFullHTMLDocument(context.html)
177
- ? await this.handleFullHTMLDocument(context)
178
- : await this.wrapHTMLFragment(context);
130
+ let html: string;
131
+ if (isFullHTMLDocument(context.html)) {
132
+ let projectCSSPromise: Promise<ProjectCSSResult> | undefined;
133
+ if (this.config.mode === "production" && context.options?.environment === "production") {
134
+ const mergedFrontmatter = this.mergeFrontmatter(context);
135
+ const htmlOptions = await profilePhase(
136
+ "html.build_options",
137
+ () => this.buildHTMLOptions(context, mergedFrontmatter),
138
+ );
139
+ projectCSSPromise = this.startProjectCSSPreparation(context, htmlOptions);
140
+ }
141
+
142
+ html = await this.handleFullHTMLDocument(context, projectCSSPromise);
143
+ } else {
144
+ html = await this.wrapHTMLFragment(context);
145
+ }
179
146
  const finalHtml = context.options?.studioEmbed ? injectElementSelectors(html) : html;
180
147
 
181
148
  if (context.options?.studioEmbed) {
182
149
  logger.debug("Injected element selectors for Studio");
183
150
  }
184
151
 
185
- return addNonceToRenderedTags(finalHtml, context.options?.nonce);
152
+ return addNonceToHtmlTags(finalHtml, context.options?.nonce);
186
153
  }
187
154
 
188
155
  async generateHTMLStream(
@@ -210,6 +177,27 @@ export class HTMLGenerator {
210
177
  reactContent = error.partialContent.trim();
211
178
  }
212
179
 
180
+ if (isFullHTMLDocument(reactContent)) {
181
+ const encoder = new TextEncoder();
182
+ const fullHtml = addNonceToHtmlTags(
183
+ await this.handleFullHTMLDocument(
184
+ {
185
+ ...fullContext,
186
+ html: reactContent,
187
+ },
188
+ projectCSSPromise,
189
+ ),
190
+ context.options?.nonce,
191
+ );
192
+
193
+ return new ReadableStream({
194
+ start(controller) {
195
+ controller.enqueue(encoder.encode(fullHtml));
196
+ controller.close();
197
+ },
198
+ });
199
+ }
200
+
213
201
  const { start, end } = await profilePhase(
214
202
  "html.generate_shell_parts",
215
203
  () =>
@@ -223,7 +211,7 @@ export class HTMLGenerator {
223
211
  );
224
212
 
225
213
  const encoder = new TextEncoder();
226
- const fullHtml = addNonceToRenderedTags(
214
+ const fullHtml = addNonceToHtmlTags(
227
215
  `${start}${reactContent}${end}`,
228
216
  context.options?.nonce,
229
217
  );
@@ -236,7 +224,10 @@ export class HTMLGenerator {
236
224
  });
237
225
  }
238
226
 
239
- private async handleFullHTMLDocument(context: HTMLGenerationContext): Promise<string> {
227
+ private async handleFullHTMLDocument(
228
+ context: HTMLGenerationContext,
229
+ projectCSSPromise?: Promise<ProjectCSSResult>,
230
+ ): Promise<string> {
240
231
  const metadata = extractHTMLMetadata(
241
232
  (context.pageInfo.entity.frontmatter || {}) as MDXFrontmatter,
242
233
  (context.layoutBundle?.frontmatter || {}) as MDXFrontmatter,
@@ -251,7 +242,23 @@ export class HTMLGenerator {
251
242
  }),
252
243
  ]);
253
244
 
254
- const injectedHtml = injectHTMLContent(context.html, "", metadata, {
245
+ const themedHtml = injectThemePersistenceScript(
246
+ applyExplicitThemeToDocument(
247
+ context.html,
248
+ context.options?.colorScheme,
249
+ context.options?.colorSchemeFromParam,
250
+ ),
251
+ context.options?.colorScheme,
252
+ context.options?.colorSchemeFromParam,
253
+ context.options?.nonce,
254
+ );
255
+
256
+ const projectStylesheetHref = await this.resolveProjectStylesheetHref(
257
+ context,
258
+ projectCSSPromise,
259
+ );
260
+
261
+ const injectedHtml = injectHTMLContent(themedHtml, "", metadata, {
255
262
  mode: this.config.mode,
256
263
  slug: context.slug,
257
264
  devPort: this.config.config?.dev?.port || DEFAULT_DASHBOARD_PORT,
@@ -262,6 +269,7 @@ export class HTMLGenerator {
262
269
  isLocalProject: this.config.mode === "development",
263
270
  nonce: context.options?.nonce,
264
271
  importMapJson,
272
+ projectStylesheetHref,
265
273
  });
266
274
 
267
275
  if (injectedHtml.trimStart().toLowerCase().startsWith("<!doctype")) return injectedHtml;
@@ -269,6 +277,23 @@ export class HTMLGenerator {
269
277
  return `<!DOCTYPE html>\n${injectedHtml}`;
270
278
  }
271
279
 
280
+ private async resolveProjectStylesheetHref(
281
+ context: HTMLGenerationContext,
282
+ projectCSSPromise?: Promise<ProjectCSSResult>,
283
+ ): Promise<string | undefined> {
284
+ if (!projectCSSPromise) return undefined;
285
+
286
+ const projectCSS = await profilePhase("html.project_css", () => projectCSSPromise);
287
+ const cssHash = projectCSS?.hash ?? "";
288
+ if (cssHash) return `/_vf/css/${cssHash}.css`;
289
+
290
+ logger.error("Project CSS hash is empty for full-document HTML", {
291
+ slug: context.slug,
292
+ environment: context.options?.environment,
293
+ });
294
+ return undefined;
295
+ }
296
+
272
297
  private async detectUseClientDirective(pagePath: string): Promise<boolean> {
273
298
  try {
274
299
  const pageContent = await this.config.adapter.fs.readFile(pagePath);
@@ -239,6 +239,7 @@ async function generateFullHtml(
239
239
  mode: options.mode,
240
240
  slug,
241
241
  devPort: options.config?.dev?.port ?? DEFAULT_DASHBOARD_PORT,
242
+ nonce: options.nonce,
242
243
  });
243
244
  }
244
245