diff --git a/src/cli.ts b/src/cli.ts
index 3b88a050..ae305909 100644
--- a/src/cli.ts
+++ b/src/cli.ts
@@ -25,8 +25,12 @@ import {
import { persistApiKey, persistSiteUuid, resolveConfig, writeConfigFile } from './config.js';
import {
buildInjectionSnippet,
+ buildSourceMarkerSnippet,
+ ensureSourceMarker,
findHtmlFiles,
+ hasJsxShell,
injectMarker,
+ productionGate,
resolveBuildDir,
} from './mark-build.js';
import {
@@ -35,6 +39,7 @@ import {
detectPackageManager,
installCommand,
renderGuideChecklist,
+ resolveWidgetFileHint,
} from './guide.js';
import { login, readPendingLogin, redeemIfApproved, startLogin, waitForApproval } from './login.js';
import { runProtect, runVerify } from './protect/install/index.js';
@@ -468,6 +473,14 @@ async function runScan(
return 0;
}
+ // Ahead of the post deliberately. The marker carries no site UUID and needs no
+ // server round-trip, while `scan` is commonly chained as `scan || true`, so a
+ // failed or offline post must not be what decides whether a published build
+ // gets its production flag. --dry-run has already returned by here.
+ if (config.widget) {
+ reportSourceMarker(detectStack(payload.packages).framework);
+ }
+
const provisioning = config.siteUuid === null;
if (provisioning) {
console.log('No site UUID configured — provisioning a new Patchstack site from this manifest…');
@@ -581,6 +594,53 @@ function reportSourceWidget(siteUuid: string): void {
}
}
+/**
+ * Run the production-marker pass for `scan` and narrate the outcome. Never
+ * throws, for the same reason the widget pass doesn't: this is a convenience on
+ * top of a successful scan and must not turn one into a failure.
+ *
+ * `scan` is a pre-build hook, so an edit here is compiled into the build that
+ * follows. On a server-rendered root that is the only way the marker reaches
+ * production — `mark-build` runs after the build and has no HTML to stamp.
+ */
+function reportSourceMarker(framework: string | null): void {
+ try {
+ const shell = resolveWidgetFileHint(process.cwd(), framework);
+ if (shell === null || shell.toLowerCase().endsWith('.html')) {
+ // An HTML shell needs nothing here: mark-build stamps the built copy.
+ return;
+ }
+
+ const result = ensureSourceMarker(process.cwd(), shell, framework);
+ switch (result.action) {
+ case 'added':
+ console.log(`Production marker: added to ${shell} (guarded by ${productionGate(framework)}).`);
+ console.log(' This root is server-rendered, so the marker ships in source rather than built HTML.');
+ break;
+ case 'manual':
+ console.log(`Production marker: already set in ${shell} — left untouched.`);
+ break;
+ case 'no-anchor':
+ case 'unsupported':
+ console.log(
+ `Production marker: ${shell} needs it by hand — without it the widget reads the published site as build mode.`,
+ );
+ if (hasJsxShell(framework)) {
+ for (const line of buildSourceMarkerSnippet(framework).split('\n')) {
+ console.log(` ${line}`);
+ }
+ } else {
+ console.log(
+ ` Emit only when ${productionGate(framework)}.`,
+ );
+ }
+ break;
+ }
+ } catch (err) {
+ console.warn(`Production marker: skipped (${(err as Error).message}).`);
+ }
+}
+
async function runProtectCommand(args: ParsedArgs): Promise {
// `--check`: verify the guard is wired (for the agent/CI loop). Non-zero exit if not.
if (args.flags.get('check') === true) {
@@ -932,7 +992,17 @@ async function runMarkBuild(args: ParsedArgs): Promise {
const files = findHtmlFiles(dir);
if (files.length === 0) {
- console.warn(`mark-build: no HTML files found under ${dir}. Nothing to mark.`);
+ // A build directory with no HTML in it is the signature of a server-rendered
+ // app: the document is produced per request, so there is no file to stamp and
+ // the marker only reaches production if it ships in the source shell. Silence
+ // here reads as success, and the widget then treats the live site as a build.
+ console.warn(`mark-build: found ${dir} but no HTML files in it.`);
+ console.warn(
+ 'mark-build: this build looks server-rendered, so the production flag has nothing to stamp.',
+ );
+ console.warn(
+ 'mark-build: add the marker to your root shell instead — run `patchstack-connect guide` for the snippet.',
+ );
return 0;
}
diff --git a/src/guide.ts b/src/guide.ts
index ccecf64d..962e5399 100644
--- a/src/guide.ts
+++ b/src/guide.ts
@@ -14,9 +14,17 @@ import { DEFAULT_ENDPOINT, buildClaimUrl } from './client.js';
import { resolveConfig } from './config.js';
import { runVerify } from './protect/install/index.js';
import type { VerifyCheck } from './protect/install/types.js';
+import {
+ buildSourceMarkerSnippet,
+ hasJsxShell,
+ productionGate,
+} from './mark-build.js';
import { detectStack } from './stack.js';
import { buildWidgetTag } from './widget.js';
+/** Global the widget reads to decide it is running on a published build. */
+const PROD_MARKER_NEEDLE = '__PATCHSTACK_PROD__';
+
/** Substring that marks the widget as installed anywhere in the source tree. */
const WIDGET_NEEDLE = 'patchstack-widget';
@@ -46,6 +54,11 @@ export interface GuideState {
framework: string | null;
/** Existing file the widget snippet belongs in, best-effort. */
widgetFileHint: string | null;
+ /**
+ * True when the root shell already sets `__PATCHSTACK_PROD__`. Only meaningful
+ * for code shells: HTML shells get the marker stamped by `mark-build` instead.
+ */
+ productionMarkerWired: boolean;
/** Result of the same local inspection used by `protect --check`. */
protectionWired: boolean;
protectionStack: string;
@@ -260,7 +273,23 @@ export function findWidgetMarker(cwd: string, siteUuid?: string | null): WidgetS
};
}
-function resolveWidgetFileHint(cwd: string, framework: string | null): string | null {
+/**
+ * Whether the root shell already carries the production marker. Reads only the
+ * file the snippet belongs in — the marker has one correct home, so a tree walk
+ * would cost more and answer no better.
+ */
+function findProductionMarker(cwd: string, widgetFileHint: string | null): boolean {
+ if (widgetFileHint === null) {
+ return false;
+ }
+ try {
+ return readFileSync(path.join(cwd, widgetFileHint), 'utf8').includes(PROD_MARKER_NEEDLE);
+ } catch {
+ return false;
+ }
+}
+
+export function resolveWidgetFileHint(cwd: string, framework: string | null): string | null {
const candidates = [
...(framework !== null ? WIDGET_FILE_CANDIDATES[framework] ?? [] : []),
...GENERIC_WIDGET_FILES,
@@ -317,6 +346,7 @@ export async function collectGuideState(cwd: string): Promise {
);
const widget = findWidgetMarker(cwd, siteUuid);
+ const widgetFileHint = resolveWidgetFileHint(cwd, stack.framework);
const protection = runVerify(cwd);
return {
@@ -341,7 +371,8 @@ export async function collectGuideState(cwd: string): Promise {
widgetTokenMatches: widget.uuidMatches,
widgetOptOut,
framework: stack.framework,
- widgetFileHint: resolveWidgetFileHint(cwd, stack.framework),
+ widgetFileHint: widgetFileHint,
+ productionMarkerWired: findProductionMarker(cwd, widgetFileHint),
protectionWired: protection.wired,
protectionStack: protection.stack,
protectionChecks: protection.checks,
@@ -358,6 +389,18 @@ const ANSI = {
};
/** Setup steps still missing — 0 means the checklist is fully green. */
+/**
+ * True when the site's root shell is code rather than an HTML file. Those roots
+ * are server-rendered, so no built HTML file carries the marker to production and
+ * `mark-build`'s HTML pass has nothing to stamp.
+ */
+export function needsSourceProductionMarker(state: GuideState): boolean {
+ if (state.siteUuid === null || state.widgetOptOut || state.widgetFileHint === null) {
+ return false;
+ }
+ return !state.widgetFileHint.toLowerCase().endsWith('.html');
+}
+
export function countRemainingSteps(state: GuideState): number {
return [
state.installed?.section === 'dependencies',
@@ -365,6 +408,7 @@ export function countRemainingSteps(state: GuideState): number {
state.installScanWired,
!state.hasBuildScript || (state.prebuildWired && state.postbuildWired),
state.widgetOptOut || (state.widgetInstalled && state.widgetTokenMatches !== false),
+ !needsSourceProductionMarker(state) || state.productionMarkerWired,
state.protectionWired,
].filter((step) => !step).length;
}
@@ -474,6 +518,38 @@ export function renderGuideChecklist(state: GuideState, useColor: boolean): stri
lines.push(detail('The site UUID is public by design — it ships in client-side HTML.'));
}
+ // 5b. Production marker on server-rendered roots. A code root never emits a
+ // static HTML file, so `mark-build` has nothing to stamp and the marker only
+ // reaches production if it ships in the shell alongside the widget tag.
+ if (needsSourceProductionMarker(state)) {
+ if (state.productionMarkerWired) {
+ lines.push(done('Production marker wired (widget switches to report mode on the published site)'));
+ } else {
+ lines.push(todo('Add the production marker — this root is server-rendered, so mark-build cannot stamp it'));
+ lines.push(
+ detail(
+ 'Without it the widget treats the published site as build mode and shows the claim flow to visitors.',
+ ),
+ );
+ const gate = productionGate(state.framework);
+ if (hasJsxShell(state.framework)) {
+ lines.push(detail(`Run → npx @patchstack/connect scan (adds it to ${state.widgetFileHint} automatically)`));
+ lines.push(detail('Or add it by hand, in above the widget tag:'));
+ for (const snippetLine of buildSourceMarkerSnippet(state.framework).split('\n')) {
+ lines.push(detail(` ${snippetLine}`));
+ }
+ } else {
+ lines.push(
+ detail(
+ `Edit ${state.widgetFileHint} → using the framework's head mechanism, emit an inline`,
+ ),
+ );
+ lines.push(detail(` only when ${gate}.`));
+ }
+ lines.push(detail(`The ${gate} guard is required — an ungated marker also hides the claim flow in preview.`))
+ }
+ }
+
// 6. Runtime protection
if (state.protectionWired) {
lines.push(done(`Runtime protection wired (${state.protectionStack})`));
diff --git a/src/mark-build.ts b/src/mark-build.ts
index e95b3738..4ef5998f 100644
--- a/src/mark-build.ts
+++ b/src/mark-build.ts
@@ -1,4 +1,4 @@
-import { existsSync, readdirSync, statSync } from 'node:fs';
+import { existsSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs';
import path from 'node:path';
import { isEmptyStack, type StackDescriptor } from './stack.js';
@@ -88,3 +88,170 @@ export function injectMarker(html: string, snippet: string): string {
}
return stripped + snippet;
}
+
+/* ------------------------------------------------------------------ */
+/* Source-shell marking (server-rendered roots) */
+/* ------------------------------------------------------------------ */
+
+/**
+ * Frameworks whose root shell is JSX. These get a literal snippet; every other
+ * code shell gets the requirement described instead, because its head mechanism
+ * (`useHead`, ``, Astro frontmatter) is not a plain script tag and
+ * a wrong snippet costs more than an accurate sentence.
+ */
+const JSX_SHELL_FRAMEWORKS = new Set([
+ 'next',
+ 'remix',
+ 'react-router',
+ 'tanstack-start',
+ 'gatsby',
+]);
+
+/**
+ * How each framework spells "this is a production build" in source. Vite-built
+ * roots use `import.meta.env.PROD`, which the bundler statically replaces;
+ * bundlers that don't define it fall back to NODE_ENV.
+ *
+ * The gate is load-bearing: an ungated marker also fires in the hosted builder's
+ * dev preview, which is exactly where the owner still needs the claim flow.
+ */
+const PRODUCTION_GATES: Record = {
+ next: "process.env.NODE_ENV === 'production'",
+ gatsby: "process.env.NODE_ENV === 'production'",
+ nuxt: '!import.meta.dev',
+};
+
+const DEFAULT_PRODUCTION_GATE = 'import.meta.env.PROD';
+
+/** The build-time expression that must guard the marker for this framework. */
+export function productionGate(framework: string | null): string {
+ const mapped = framework !== null ? PRODUCTION_GATES[framework] : undefined;
+ return mapped ?? DEFAULT_PRODUCTION_GATE;
+}
+
+/** True when `guide` can print a literal marker snippet for this framework. */
+export function hasJsxShell(framework: string | null): boolean {
+ return framework !== null && JSX_SHELL_FRAMEWORKS.has(framework);
+}
+
+/**
+ * The production marker as it belongs in a JSX root shell.
+ *
+ * Server-rendered roots never produce a static HTML file for `mark-build` to
+ * stamp, so on those stacks this is the only path the marker has to production.
+ * It stays an inline document script rather than a module-level assignment: the
+ * widget tag is `defer`, and only a parser-executed inline script is ordered
+ * ahead of it for certain.
+ */
+export function buildSourceMarkerSnippet(framework: string | null): string {
+ const gate = productionGate(framework);
+ return (
+ `{${gate} && (\n` +
+ ` \n` +
+ `)}`
+ );
+}
+
+/** Global the widget reads to decide it is running on a published build. */
+const PROD_MARKER_GLOBAL = '__PATCHSTACK_PROD__';
+
+/**
+ * JSX comment fence around the injected marker. Mirrors the `#region` fence the
+ * protect installer uses on server entries: it makes the block obviously ours,
+ * and lets a re-run replace it instead of stacking copies.
+ */
+const REGION_OPEN = '{/* #region patchstack (managed by patchstack-connect — do not edit) */}';
+const REGION_CLOSE = '{/* #endregion patchstack */}';
+const REGION_RE = /[ \t]*\{\/\* #region patchstack[\s\S]*?#endregion patchstack \*\/\}\n?/g;
+
+export type SourceMarkerAction =
+ /** The managed block was inserted (or refreshed in place). */
+ | 'added'
+ /** A marker is already present and not ours — adopted, left untouched. */
+ | 'manual'
+ /** No JSX anchor to attach to; the caller falls back to printing the snippet. */
+ | 'no-anchor'
+ /** This framework's root shell is not JSX, so there is no snippet to insert. */
+ | 'unsupported';
+
+export interface SourceMarkerResult {
+ source: string;
+ action: SourceMarkerAction;
+}
+
+/**
+ * Insert the production marker into a JSX root shell, idempotently.
+ *
+ * Anchors on the widget tag when one is present, so the marker is ordered ahead
+ * of it in the document; otherwise on ``, then ``. A marker the
+ * developer placed themselves is adopted rather than duplicated.
+ */
+export function ensureMarkerInJsxShell(source: string, framework: string | null): SourceMarkerResult {
+ if (!hasJsxShell(framework)) {
+ return { source, action: 'unsupported' };
+ }
+
+ const stripped = source.replace(REGION_RE, '');
+ if (stripped.includes(PROD_MARKER_GLOBAL)) {
+ return { source, action: 'manual' };
+ }
+
+ const block = (indent: string): string =>
+ [REGION_OPEN, ...buildSourceMarkerSnippet(framework).split('\n'), REGION_CLOSE]
+ .map((line) => `${indent}${line}`)
+ .join('\n');
+
+ const widget = stripped.match(/^([ \t]*).*patchstack-widget.*$/m);
+ if (widget?.index !== undefined) {
+ const indent = widget[1] ?? '';
+ return {
+ source: `${stripped.slice(0, widget.index)}${block(indent)}\n${stripped.slice(widget.index)}`,
+ action: 'added',
+ };
+ }
+
+ for (const anchor of [/^([ \t]*)[ \t]*$/m, /^([ \t]*)[ \t]*$/m]) {
+ const match = stripped.match(anchor);
+ if (match?.index === undefined) {
+ continue;
+ }
+ const end = match.index + match[0].length;
+ const indent = `${match[1] ?? ''} `;
+ return {
+ source: `${stripped.slice(0, end)}\n${block(indent)}${stripped.slice(end)}`,
+ action: 'added',
+ };
+ }
+
+ return { source, action: 'no-anchor' };
+}
+
+export interface EnsureSourceMarkerResult extends SourceMarkerResult {
+ /** Project-relative shell that was inspected, or null when there is none. */
+ shell: string | null;
+}
+
+/**
+ * Ensure the production marker in the project's JSX root shell. Runs during
+ * `scan`, which is a pre-build hook, so the edit is picked up by the build that
+ * follows — the marker reaches production without anyone pasting anything.
+ */
+export function ensureSourceMarker(
+ cwd: string,
+ shell: string | null,
+ framework: string | null,
+): EnsureSourceMarkerResult {
+ if (shell === null) {
+ return { shell: null, source: '', action: 'no-anchor' };
+ }
+ const file = path.resolve(cwd, shell);
+ const before = readFileSync(file, 'utf8');
+ const result = ensureMarkerInJsxShell(before, framework);
+ if (result.source !== before && result.action === 'added') {
+ writeFileSync(file, result.source);
+ }
+ return { ...result, shell };
+}
diff --git a/tests/guide.test.ts b/tests/guide.test.ts
index 5f1976dd..d193440c 100644
--- a/tests/guide.test.ts
+++ b/tests/guide.test.ts
@@ -10,6 +10,7 @@ import {
detectPackageManager,
findWidgetMarker,
installCommand,
+ needsSourceProductionMarker,
renderGuideChecklist,
} from '../src/guide.js';
@@ -328,4 +329,64 @@ describe('guide', () => {
expect(output).toContain('No package.json found');
});
});
+
+ describe('production marker on server-rendered roots', () => {
+ const tanstackProject = (rootContents: string): void => {
+ writeJson('package.json', {
+ name: 'ssr-app',
+ dependencies: { '@tanstack/react-start': '1.0.0', react: '18.0.0' },
+ });
+ writeJson('.patchstackrc.json', { siteUuid: VALID_UUID });
+ mkdirSync(path.join(cwd, 'src', 'routes'), { recursive: true });
+ writeFileSync(path.join(cwd, 'src', 'routes', '__root.tsx'), rootContents);
+ };
+
+ const widgetTag = ``;
+
+ it('asks for the marker when the root is code and does not have it', async () => {
+ tanstackProject(`export const Root = () => ${widgetTag};`);
+ const state = await collectGuideState(cwd);
+
+ expect(needsSourceProductionMarker(state)).toBe(true);
+ expect(state.productionMarkerWired).toBe(false);
+
+ const output = renderGuideChecklist(state, false);
+ expect(output).toContain('Add the production marker');
+ expect(output).toContain('npx @patchstack/connect scan');
+ expect(output).toContain('import.meta.env.PROD &&');
+ expect(output).toContain('window.__PATCHSTACK_PROD__=true;');
+ });
+
+ it('counts the missing marker as an outstanding step', async () => {
+ tanstackProject(`export const Root = () => ${widgetTag};`);
+ const withoutMarker = countRemainingSteps(await collectGuideState(cwd));
+
+ tanstackProject(
+ `export const Root = () => {import.meta.env.PROD && }${widgetTag};`,
+ );
+ const withMarker = countRemainingSteps(await collectGuideState(cwd));
+
+ expect(withoutMarker - withMarker).toBe(1);
+ });
+
+ it('reports the marker as done once the root sets it', async () => {
+ tanstackProject(
+ `export const Root = () => {import.meta.env.PROD && }${widgetTag};`,
+ );
+ const state = await collectGuideState(cwd);
+
+ expect(state.productionMarkerWired).toBe(true);
+ expect(renderGuideChecklist(state, false)).toContain('Production marker wired');
+ });
+
+ it('stays silent for a plain HTML shell, where mark-build stamps the marker', async () => {
+ writeJson('package.json', { name: 'spa', dependencies: { vite: '5.0.0' } });
+ writeJson('.patchstackrc.json', { siteUuid: VALID_UUID });
+ writeFileSync(path.join(cwd, 'index.html'), `${widgetTag}`);
+
+ const state = await collectGuideState(cwd);
+ expect(needsSourceProductionMarker(state)).toBe(false);
+ expect(renderGuideChecklist(state, false)).not.toContain('Add the production marker');
+ });
+ });
});
diff --git a/tests/mark-build.test.ts b/tests/mark-build.test.ts
index 787a711e..5c82a57d 100644
--- a/tests/mark-build.test.ts
+++ b/tests/mark-build.test.ts
@@ -1,12 +1,17 @@
-import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
+import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {
MARKER_ATTR,
buildInjectionSnippet,
+ buildSourceMarkerSnippet,
+ ensureMarkerInJsxShell,
+ ensureSourceMarker,
findHtmlFiles,
+ hasJsxShell,
injectMarker,
+ productionGate,
resolveBuildDir,
} from '../src/mark-build.js';
@@ -118,3 +123,133 @@ describe('resolveBuildDir + findHtmlFiles', () => {
expect(resolveBuildDir(root)).toBeNull();
});
});
+
+
+describe('productionGate', () => {
+ it('uses the Vite-replaced flag for bundlers that define it', () => {
+ expect(productionGate('tanstack-start')).toBe('import.meta.env.PROD');
+ expect(productionGate('remix')).toBe('import.meta.env.PROD');
+ expect(productionGate(null)).toBe('import.meta.env.PROD');
+ });
+
+ it('falls back to NODE_ENV where import.meta.env is not defined', () => {
+ expect(productionGate('next')).toContain('process.env.NODE_ENV');
+ expect(productionGate('gatsby')).toContain('process.env.NODE_ENV');
+ });
+
+ it('uses the Nuxt dev flag for Nuxt', () => {
+ expect(productionGate('nuxt')).toBe('!import.meta.dev');
+ });
+});
+
+describe('hasJsxShell', () => {
+ it('is true for React-family roots and false otherwise', () => {
+ expect(hasJsxShell('tanstack-start')).toBe(true);
+ expect(hasJsxShell('next')).toBe(true);
+ expect(hasJsxShell('nuxt')).toBe(false);
+ expect(hasJsxShell('sveltekit')).toBe(false);
+ expect(hasJsxShell(null)).toBe(false);
+ });
+});
+
+describe('buildSourceMarkerSnippet', () => {
+ it('sets the marker the widget reads, behind the framework production gate', () => {
+ const snippet = buildSourceMarkerSnippet('tanstack-start');
+ expect(snippet).toContain('window.__PATCHSTACK_PROD__=true;');
+ expect(snippet).toContain('import.meta.env.PROD &&');
+ expect(snippet).toContain(MARKER_ATTR);
+ });
+
+ it('never emits an ungated marker, which would also hide the claim flow in dev', () => {
+ for (const framework of ['tanstack-start', 'next', 'remix', 'react-router', 'gatsby']) {
+ const snippet = buildSourceMarkerSnippet(framework);
+ const gateIndex = snippet.indexOf(productionGate(framework));
+ expect(gateIndex).toBeGreaterThanOrEqual(0);
+ expect(gateIndex).toBeLessThan(snippet.indexOf('__PATCHSTACK_PROD__'));
+ }
+ });
+
+ it('is an inline document script, so it beats the deferred widget tag', () => {
+ // A module-level assignment would not be ordered ahead of the widget's init.
+ expect(buildSourceMarkerSnippet('tanstack-start')).toContain('dangerouslySetInnerHTML');
+ });
+});
+
+
+describe('ensureMarkerInJsxShell', () => {
+ const widgetLine =
+ ' ';
+
+ const doc = (body: string): string =>
+ ['export const Root = () => (', ' ', ' ', ' ', ' ', body, ' ', ' ', ');'].join('\n');
+
+ it('inserts the marker above the widget tag, so it runs first', () => {
+ const { source, action } = ensureMarkerInJsxShell(doc(widgetLine), 'tanstack-start');
+ expect(action).toBe('added');
+ expect(source.indexOf('__PATCHSTACK_PROD__')).toBeLessThan(source.indexOf('patchstack-widget'));
+ expect(source).toContain('import.meta.env.PROD &&');
+ });
+
+ it('falls back to when no widget tag is present yet', () => {
+ const { source, action } = ensureMarkerInJsxShell(doc(' '), 'tanstack-start');
+ expect(action).toBe('added');
+ expect(source.indexOf('__PATCHSTACK_PROD__')).toBeLessThan(source.indexOf(''));
+ });
+
+ it('is idempotent — a re-run refreshes the block instead of stacking copies', () => {
+ const once = ensureMarkerInJsxShell(doc(widgetLine), 'tanstack-start').source;
+ const twice = ensureMarkerInJsxShell(once, 'tanstack-start').source;
+ expect((twice.match(/__PATCHSTACK_PROD__/g) ?? []).length).toBe(1);
+ expect((twice.match(/#region patchstack/g) ?? []).length).toBe(1);
+ });
+
+ it('adopts a hand-placed marker rather than adding a second one', () => {
+ const hand = doc(' {import.meta.env.PROD && }');
+ const { source, action } = ensureMarkerInJsxShell(hand, 'tanstack-start');
+ expect(action).toBe('manual');
+ expect(source).toBe(hand);
+ });
+
+ it('reports no-anchor when the file has no JSX shell to attach to', () => {
+ const { action } = ensureMarkerInJsxShell('export const x = 1;\n', 'tanstack-start');
+ expect(action).toBe('no-anchor');
+ });
+
+ it('declines frameworks whose root shell is not JSX', () => {
+ expect(ensureMarkerInJsxShell(doc(widgetLine), 'nuxt').action).toBe('unsupported');
+ expect(ensureMarkerInJsxShell(doc(widgetLine), 'sveltekit').action).toBe('unsupported');
+ });
+});
+
+describe('ensureSourceMarker', () => {
+ let root: string;
+
+ beforeEach(() => {
+ root = mkdtempSync(path.join(tmpdir(), 'psmarker-'));
+ });
+
+ afterEach(() => {
+ rmSync(root, { recursive: true, force: true });
+ });
+
+ it('writes the marker into the shell so the next build compiles it in', () => {
+ mkdirSync(path.join(root, 'src', 'routes'), { recursive: true });
+ const shell = path.join('src', 'routes', '__root.tsx');
+ writeFileSync(
+ path.join(root, shell),
+ 'export const Root = () => (\n \n \n \n \n);\n',
+ );
+
+ const result = ensureSourceMarker(root, shell, 'tanstack-start');
+ expect(result.action).toBe('added');
+ expect(readFileSync(path.join(root, shell), 'utf8')).toContain('window.__PATCHSTACK_PROD__=true;');
+ });
+
+ it('leaves the file untouched when there is no anchor', () => {
+ const shell = 'root.tsx';
+ writeFileSync(path.join(root, shell), 'export const x = 1;\n');
+
+ expect(ensureSourceMarker(root, shell, 'tanstack-start').action).toBe('no-anchor');
+ expect(readFileSync(path.join(root, shell), 'utf8')).toBe('export const x = 1;\n');
+ });
+});