Skip to content

Commit 3f6de12

Browse files
committed
feat(javascript-web): keep a lockfile-less site with a root index.html off the Node SDK
javascript_web requires a committed lockfile and javascript_node matches any package.json, so a browser-only project whose lockfile is not committed falls through to the Node integration and the run installs the server SDK. Add one branch, reached only after the existing lockfile check declines, that claims the project when index.html sits in the ROOT and no server framework is in dependencies. Root-only matters: the existing hasIndexHtml helper globs six levels deep, which is fine alongside a lockfile and far too loose deciding alone, and would pull in a Node library with a docs/index.html. dependencies and not devDependencies, since a static site may keep a server around to preview itself locally. Sites whose index.html lives under public/ or dist/ are left to javascript_node: without a lockfile that shape is indistinguishable from a server's asset directory. A site with no package.json at all also stays unclaimed, since PostHog/posthog.com#18390 routes those users to the JS snippet from the docs. Both limits are pinned as tests. Refs #870
1 parent a1bcf57 commit 3f6de12

3 files changed

Lines changed: 149 additions & 0 deletions

File tree

src/frameworks/javascript-web/javascript-web-wizard-agent.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,11 @@ import { hasDeclaredDependency } from '@utils/package-json';
88
import { tryGetPackageJson } from '@utils/setup-utils';
99
import {
1010
FRAMEWORK_PACKAGES,
11+
SERVER_PACKAGES,
1112
detectJsPackageManager,
1213
detectBundler,
1314
hasIndexHtml,
15+
hasRootIndexHtml,
1416
type JavaScriptContext,
1517
} from './utils';
1618
import { detectNodePackageManagers } from '@lib/detection/package-manager';
@@ -38,6 +40,10 @@ export const JAVASCRIPT_WEB_AGENT_CONFIG: FrameworkConfig<JavaScriptContext> = {
3840
detectPackageManager: detectNodePackageManagers,
3941
detect: async (options) => {
4042
const packageJson = await tryGetPackageJson(options);
43+
44+
// A site with no package.json at all is deliberately left unclaimed: the
45+
// snippet is the whole integration and there is nothing for the wizard to
46+
// do. PostHog/posthog.com#18390 routes those users to it from the docs.
4147
if (!packageJson) {
4248
return false;
4349
}
@@ -73,6 +79,20 @@ export const JAVASCRIPT_WEB_AGENT_CONFIG: FrameworkConfig<JavaScriptContext> = {
7379
return true;
7480
}
7581

82+
// No lockfile. `javascriptNode` matches any package.json, so falling
83+
// through here hands a browser-only project the server SDK. Claim it
84+
// instead, but only on evidence strong enough to stand without the
85+
// lockfile: an index.html in the ROOT (the site's entry point, not a
86+
// `docs/` page or a generated report), and no server framework in
87+
// `dependencies`. A site under `public/` or `dist/` is left alone —
88+
// that shape is indistinguishable from a server's asset directory.
89+
const declaresServer = SERVER_PACKAGES.some(
90+
(pkg) => packageJson?.dependencies?.[pkg] !== undefined,
91+
);
92+
if (hasRootIndexHtml(options) && !declaresServer) {
93+
return true;
94+
}
95+
7696
// Otherwise → Node/Backend (handled by javascriptNode fallback)
7797
return false;
7898
},

src/frameworks/javascript-web/utils.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,34 @@ export const FRAMEWORK_PACKAGES = [
3232
'@sveltejs/kit',
3333
] as const;
3434

35+
/**
36+
* Packages that mean the project serves requests rather than shipping a browser
37+
* bundle. Checked against `dependencies` only — a static site may well keep a
38+
* server in `devDependencies` to preview itself locally, and that shouldn't
39+
* read as a backend.
40+
*/
41+
export const SERVER_PACKAGES = [
42+
'express',
43+
'fastify',
44+
'koa',
45+
'@nestjs/core',
46+
'@hapi/hapi',
47+
'hapi',
48+
'restify',
49+
'h3',
50+
] as const;
51+
52+
/**
53+
* An index.html in the project root — the entry point of a site, as opposed to
54+
* a `docs/` page or a generated report deeper in the tree. Used only where a
55+
* match has to stand on its own without a lockfile to corroborate it.
56+
*/
57+
export function hasRootIndexHtml(
58+
options: Pick<WizardRunOptions, 'installDir'>,
59+
): boolean {
60+
return fs.existsSync(path.join(options.installDir, 'index.html'));
61+
}
62+
3563
/**
3664
* Detect the JS package manager for the project by checking lockfiles.
3765
* Reuses the existing package manager detection infrastructure.

src/lib/detection/__tests__/framework.test.ts

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,107 @@ describe('detectFramework (end-to-end over real project dirs)', () => {
115115
);
116116
});
117117

118+
test('a browser project with no committed lockfile is not handed to javascript_node', async () => {
119+
const opts = project({
120+
'package.json': JSON.stringify({
121+
name: 'portfolio',
122+
scripts: { build: 'tailwindcss -o dist/style.css' },
123+
}),
124+
'index.html': '<!doctype html><html></html>',
125+
});
126+
await expect(detectFramework(opts.installDir)).resolves.toBe(
127+
Integration.javascript_web,
128+
);
129+
});
130+
131+
test('an Express server with a root index.html stays on javascript_node', async () => {
132+
const opts = project({
133+
'package.json': JSON.stringify({ dependencies: { express: '^4' } }),
134+
'index.html': '<!doctype html><html></html>',
135+
});
136+
await expect(detectFramework(opts.installDir)).resolves.toBe(
137+
Integration.javascriptNode,
138+
);
139+
});
140+
141+
test('a Node library with a docs/index.html stays on javascript_node', async () => {
142+
const opts = project({
143+
'package.json': JSON.stringify({ name: 'lib' }),
144+
'src/index.js': 'module.exports = {}',
145+
'docs/index.html': '<!doctype html><html></html>',
146+
});
147+
await expect(detectFramework(opts.installDir)).resolves.toBe(
148+
Integration.javascriptNode,
149+
);
150+
});
151+
152+
test('a server framework we do not list is still safe if index.html is not at the root', async () => {
153+
const opts = project({
154+
'package.json': JSON.stringify({ dependencies: { hono: '^4' } }),
155+
'public/index.html': '<!doctype html><html></html>',
156+
});
157+
await expect(detectFramework(opts.installDir)).resolves.toBe(
158+
Integration.javascriptNode,
159+
);
160+
});
161+
162+
test('a server kept in devDependencies to preview a site is not read as a backend', async () => {
163+
const opts = project({
164+
'package.json': JSON.stringify({
165+
name: 'portfolio',
166+
devDependencies: { express: '^4' },
167+
}),
168+
'index.html': '<!doctype html><html></html>',
169+
});
170+
await expect(detectFramework(opts.installDir)).resolves.toBe(
171+
Integration.javascript_web,
172+
);
173+
});
174+
175+
// Known limit: indistinguishable from a server's asset directory without a
176+
// lockfile, so it is left to javascript_node rather than guessed at.
177+
test('a site whose index.html lives under public/ is not claimed without a lockfile', async () => {
178+
const opts = project({
179+
'package.json': JSON.stringify({ name: 'site' }),
180+
'public/index.html': '<!doctype html><html></html>',
181+
});
182+
await expect(detectFramework(opts.installDir)).resolves.toBe(
183+
Integration.javascriptNode,
184+
);
185+
});
186+
187+
test('a lockfile project keeps matching on a nested index.html (unchanged)', async () => {
188+
const opts = project({
189+
'package.json': JSON.stringify({ name: 'site' }),
190+
'package-lock.json': '{}',
191+
'public/index.html': '<!doctype html><html></html>',
192+
});
193+
await expect(detectFramework(opts.installDir)).resolves.toBe(
194+
Integration.javascript_web,
195+
);
196+
});
197+
198+
test('a Node project with no frontend signal stays on javascript_node', async () => {
199+
const opts = project({
200+
'package.json': JSON.stringify({ dependencies: { lodash: '^4' } }),
201+
'index.js': 'module.exports = {}',
202+
});
203+
await expect(detectFramework(opts.installDir)).resolves.toBe(
204+
Integration.javascriptNode,
205+
);
206+
});
207+
208+
// A site with no package.json is left unclaimed on purpose — the snippet is
209+
// the whole integration. PostHog/posthog.com#18390 routes those users to it.
210+
test('a hand-written static site is deliberately claimed by nobody', async () => {
211+
const opts = project({
212+
'index.html': '<!doctype html><html><head></head><body>hi</body></html>',
213+
'about.html': '<!doctype html><html></html>',
214+
'css/style.css': 'body { color: black }',
215+
});
216+
await expect(detectFramework(opts.installDir)).resolves.toBeUndefined();
217+
});
218+
118219
test('a Kotlin Multiplatform project resolves to kmp', async () => {
119220
const opts = project({
120221
'settings.gradle.kts': 'include(":shared")',

0 commit comments

Comments
 (0)