infinite-tag 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +142 -0
- package/dist/src/apply.d.ts +14 -0
- package/dist/src/apply.js +84 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +416 -0
- package/dist/src/frameworks/index.d.ts +4 -0
- package/dist/src/frameworks/index.js +16 -0
- package/dist/src/frameworks/managed-files.d.ts +10 -0
- package/dist/src/frameworks/managed-files.js +104 -0
- package/dist/src/frameworks/next-app-router.d.ts +2 -0
- package/dist/src/frameworks/next-app-router.js +187 -0
- package/dist/src/frameworks/next-pages-router.d.ts +2 -0
- package/dist/src/frameworks/next-pages-router.js +169 -0
- package/dist/src/frameworks/shared.d.ts +17 -0
- package/dist/src/frameworks/shared.js +136 -0
- package/dist/src/frameworks/static-html.d.ts +2 -0
- package/dist/src/frameworks/static-html.js +137 -0
- package/dist/src/frameworks/vite-react.d.ts +2 -0
- package/dist/src/frameworks/vite-react.js +274 -0
- package/dist/src/index.d.ts +12 -0
- package/dist/src/index.js +11 -0
- package/dist/src/inspect.d.ts +10 -0
- package/dist/src/inspect.js +150 -0
- package/dist/src/manifest.d.ts +10 -0
- package/dist/src/manifest.js +83 -0
- package/dist/src/package-manager.d.ts +6 -0
- package/dist/src/package-manager.js +110 -0
- package/dist/src/plan.d.ts +9 -0
- package/dist/src/plan.js +97 -0
- package/dist/src/providers/ga4.d.ts +2 -0
- package/dist/src/providers/ga4.js +73 -0
- package/dist/src/providers/index.d.ts +3 -0
- package/dist/src/providers/index.js +11 -0
- package/dist/src/providers/posthog.d.ts +2 -0
- package/dist/src/providers/posthog.js +77 -0
- package/dist/src/providers/validate.d.ts +31 -0
- package/dist/src/providers/validate.js +110 -0
- package/dist/src/providers/x.d.ts +2 -0
- package/dist/src/providers/x.js +76 -0
- package/dist/src/render.d.ts +25 -0
- package/dist/src/render.js +260 -0
- package/dist/src/types.d.ts +151 -0
- package/dist/src/types.js +8 -0
- package/dist/src/uninstall.d.ts +7 -0
- package/dist/src/uninstall.js +66 -0
- package/dist/src/verify.d.ts +5 -0
- package/dist/src/verify.js +50 -0
- package/dist/src/workspace-artifacts.d.ts +41 -0
- package/dist/src/workspace-artifacts.js +171 -0
- package/package.json +27 -0
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { nextAppRouterAdapter } from "./next-app-router.js";
|
|
2
|
+
import { nextPagesRouterAdapter } from "./next-pages-router.js";
|
|
3
|
+
import { staticHtmlAdapter } from "./static-html.js";
|
|
4
|
+
import { viteReactAdapter } from "./vite-react.js";
|
|
5
|
+
export const frameworkAdapters = [
|
|
6
|
+
nextAppRouterAdapter,
|
|
7
|
+
nextPagesRouterAdapter,
|
|
8
|
+
viteReactAdapter,
|
|
9
|
+
staticHtmlAdapter
|
|
10
|
+
];
|
|
11
|
+
export function getFrameworkAdapter(framework) {
|
|
12
|
+
return frameworkAdapters.find((adapter) => adapter.id === framework);
|
|
13
|
+
}
|
|
14
|
+
export function isSupportedFramework(framework) {
|
|
15
|
+
return frameworkAdapters.some((adapter) => adapter.id === framework);
|
|
16
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { InstallPlan } from "../types.js";
|
|
2
|
+
export declare function isManagedInfiniteFile(source: string): boolean;
|
|
3
|
+
export declare function hasExistingUnmanagedFile(root: string, relativePath: string): boolean;
|
|
4
|
+
export interface RemoveManagedFileResult {
|
|
5
|
+
removed: boolean;
|
|
6
|
+
warning?: string;
|
|
7
|
+
}
|
|
8
|
+
export declare function removeManagedFile(root: string, relativePath: string, dryRun: boolean): RemoveManagedFileResult;
|
|
9
|
+
export declare function buildAnalyticsModuleSource(plan: InstallPlan): string;
|
|
10
|
+
export declare function buildClientComponentSource(analyticsImportPath?: string): string;
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { existsSync, readFileSync, rmSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
const managedFileBanner = "// Managed by Infinite. Public install artifacts only.";
|
|
4
|
+
export function isManagedInfiniteFile(source) {
|
|
5
|
+
return source.includes("Managed by Infinite");
|
|
6
|
+
}
|
|
7
|
+
export function hasExistingUnmanagedFile(root, relativePath) {
|
|
8
|
+
const absolutePath = join(root, relativePath);
|
|
9
|
+
return existsSync(absolutePath) && !isManagedInfiniteFile(readFileSync(absolutePath, "utf8"));
|
|
10
|
+
}
|
|
11
|
+
export function removeManagedFile(root, relativePath, dryRun) {
|
|
12
|
+
const absolutePath = join(root, relativePath);
|
|
13
|
+
if (!existsSync(absolutePath)) {
|
|
14
|
+
return {
|
|
15
|
+
removed: false,
|
|
16
|
+
warning: `Managed file already absent: ${relativePath}`
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
if (!isManagedInfiniteFile(readFileSync(absolutePath, "utf8"))) {
|
|
20
|
+
throw new Error(`Refusing to remove ${relativePath} because it no longer looks managed by Infinite. Remove it manually if it should go.`);
|
|
21
|
+
}
|
|
22
|
+
if (!dryRun) {
|
|
23
|
+
rmSync(absolutePath);
|
|
24
|
+
}
|
|
25
|
+
return { removed: true };
|
|
26
|
+
}
|
|
27
|
+
export function buildAnalyticsModuleSource(plan) {
|
|
28
|
+
const bootstrapSnippets = plan.instructions
|
|
29
|
+
.filter((instruction) => instruction.provider &&
|
|
30
|
+
/(?:^|\/)lib\/infinite-analytics\.(?:ts|js)$/.test(instruction.path))
|
|
31
|
+
.map((instruction) => instruction.snippet.trim())
|
|
32
|
+
.filter((snippet) => snippet.length > 0);
|
|
33
|
+
const ga4MeasurementId = plan.artifacts.ga4?.measurementId;
|
|
34
|
+
const externalScripts = typeof ga4MeasurementId === "string" && ga4MeasurementId.length > 0
|
|
35
|
+
? [
|
|
36
|
+
{
|
|
37
|
+
id: "infinite-ga4-loader",
|
|
38
|
+
src: `https://www.googletagmanager.com/gtag/js?id=${ga4MeasurementId}`,
|
|
39
|
+
async: true
|
|
40
|
+
}
|
|
41
|
+
]
|
|
42
|
+
: [];
|
|
43
|
+
return [
|
|
44
|
+
managedFileBanner,
|
|
45
|
+
"",
|
|
46
|
+
`const externalScripts = ${JSON.stringify(externalScripts, null, 2)} as const`,
|
|
47
|
+
"",
|
|
48
|
+
"const bootstrapSource = String.raw`",
|
|
49
|
+
bootstrapSnippets.join("\n\n"),
|
|
50
|
+
"`",
|
|
51
|
+
"",
|
|
52
|
+
"function ensureExternalScript(id: string, src: string, isAsync: boolean): void {",
|
|
53
|
+
' if (document.querySelector(`script[data-infinite-id="${id}"]`)) {',
|
|
54
|
+
" return",
|
|
55
|
+
" }",
|
|
56
|
+
"",
|
|
57
|
+
' const script = document.createElement("script")',
|
|
58
|
+
" script.src = src",
|
|
59
|
+
" script.async = isAsync",
|
|
60
|
+
' script.setAttribute("data-infinite-id", id)',
|
|
61
|
+
" document.head.appendChild(script)",
|
|
62
|
+
"}",
|
|
63
|
+
"",
|
|
64
|
+
"export function installInfiniteInstrumentation(): void {",
|
|
65
|
+
' if (typeof document === "undefined") {',
|
|
66
|
+
" return",
|
|
67
|
+
" }",
|
|
68
|
+
"",
|
|
69
|
+
" for (const scriptSpec of externalScripts) {",
|
|
70
|
+
" ensureExternalScript(scriptSpec.id, scriptSpec.src, scriptSpec.async)",
|
|
71
|
+
" }",
|
|
72
|
+
"",
|
|
73
|
+
' if (document.getElementById("infinite-analytics-bootstrap")) {',
|
|
74
|
+
" return",
|
|
75
|
+
" }",
|
|
76
|
+
"",
|
|
77
|
+
' const script = document.createElement("script")',
|
|
78
|
+
' script.id = "infinite-analytics-bootstrap"',
|
|
79
|
+
' script.setAttribute("data-infinite-analytics", "managed")',
|
|
80
|
+
" script.text = bootstrapSource",
|
|
81
|
+
" document.head.appendChild(script)",
|
|
82
|
+
"}",
|
|
83
|
+
""
|
|
84
|
+
].join("\n");
|
|
85
|
+
}
|
|
86
|
+
export function buildClientComponentSource(analyticsImportPath = "./infinite-analytics") {
|
|
87
|
+
return [
|
|
88
|
+
'"use client"',
|
|
89
|
+
"",
|
|
90
|
+
managedFileBanner,
|
|
91
|
+
"",
|
|
92
|
+
'import { useEffect } from "react"',
|
|
93
|
+
`import { installInfiniteInstrumentation } from "${analyticsImportPath}"`,
|
|
94
|
+
"",
|
|
95
|
+
"export function InfiniteAnalyticsClient(): null {",
|
|
96
|
+
" useEffect(() => {",
|
|
97
|
+
" installInfiniteInstrumentation()",
|
|
98
|
+
" }, [])",
|
|
99
|
+
"",
|
|
100
|
+
" return null",
|
|
101
|
+
"}",
|
|
102
|
+
""
|
|
103
|
+
].join("\n");
|
|
104
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { buildAnalyticsModuleSource, buildClientComponentSource, hasExistingUnmanagedFile, isManagedInfiniteFile, removeManagedFile } from "./managed-files.js";
|
|
4
|
+
import { firstExistingPath, hasDependency, normalizeAppRelativePath, readRequiredFile, writeFileIfChanged } from "./shared.js";
|
|
5
|
+
const layoutCandidates = [
|
|
6
|
+
"app/layout.tsx",
|
|
7
|
+
"app/layout.ts",
|
|
8
|
+
"app/layout.jsx",
|
|
9
|
+
"app/layout.js"
|
|
10
|
+
];
|
|
11
|
+
const pageCandidates = ["app/page.tsx", "app/page.ts", "app/page.jsx", "app/page.js"];
|
|
12
|
+
const pagesRouterCandidates = [
|
|
13
|
+
"pages/_app.tsx",
|
|
14
|
+
"pages/_app.ts",
|
|
15
|
+
"pages/_app.jsx",
|
|
16
|
+
"pages/_app.js",
|
|
17
|
+
"pages/index.tsx",
|
|
18
|
+
"pages/index.ts",
|
|
19
|
+
"pages/index.jsx",
|
|
20
|
+
"pages/index.js"
|
|
21
|
+
];
|
|
22
|
+
const layoutFilePath = "app/layout.tsx";
|
|
23
|
+
const clientComponentPath = "lib/infinite-analytics-client.tsx";
|
|
24
|
+
const analyticsModulePath = "lib/infinite-analytics.ts";
|
|
25
|
+
const clientImportLine = 'import { InfiniteAnalyticsClient } from "../lib/infinite-analytics-client"';
|
|
26
|
+
const clientTag = "<InfiniteAnalyticsClient />";
|
|
27
|
+
const missingLayoutBlocker = "Next.js App Router apply requires a root app/layout.* file so the managed client component can be mounted safely.";
|
|
28
|
+
export const nextAppRouterAdapter = {
|
|
29
|
+
id: "next-app-router",
|
|
30
|
+
displayName: "Next.js App Router",
|
|
31
|
+
detect(root) {
|
|
32
|
+
if (!hasDependency(root, "next")) {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
const layoutFile = firstExistingPath(root, layoutCandidates);
|
|
36
|
+
const pageFile = firstExistingPath(root, pageCandidates);
|
|
37
|
+
if (!layoutFile && !pageFile) {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
const assumptions = ["Next.js app router wiring will target the app/ tree."];
|
|
41
|
+
if (firstExistingPath(root, pagesRouterCandidates)) {
|
|
42
|
+
assumptions.push("Both app/ and pages/ router trees were detected. App Router wiring was selected; confirm the app/ tree is the active router before applying.");
|
|
43
|
+
}
|
|
44
|
+
return {
|
|
45
|
+
framework: "next-app-router",
|
|
46
|
+
confidence: 0.94,
|
|
47
|
+
files: [layoutFile ?? "app/layout.tsx", pageFile ?? "app/page.tsx", "lib/infinite-analytics.ts"],
|
|
48
|
+
assumptions
|
|
49
|
+
};
|
|
50
|
+
},
|
|
51
|
+
plan(root) {
|
|
52
|
+
const detected = this.detect(root);
|
|
53
|
+
const layoutFile = firstExistingPath(root, layoutCandidates);
|
|
54
|
+
if (!layoutFile) {
|
|
55
|
+
return {
|
|
56
|
+
files: [layoutFilePath, clientComponentPath, analyticsModulePath],
|
|
57
|
+
applyMode: "plan-only",
|
|
58
|
+
instructions: [],
|
|
59
|
+
assumptions: ["Next.js app router placement points are inferred from the app/ tree."],
|
|
60
|
+
blockers: [missingLayoutBlocker],
|
|
61
|
+
confidence: detected?.confidence ?? 0.9
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
const layoutSource = readRequiredFile(root, layoutFile);
|
|
65
|
+
const blockers = [];
|
|
66
|
+
if (layoutFile !== layoutFilePath) {
|
|
67
|
+
blockers.push("Next.js App Router apply currently supports app/layout.tsx only. Other root layout entrypoints remain plan-only.");
|
|
68
|
+
}
|
|
69
|
+
if (!layoutSource.includes("<body")) {
|
|
70
|
+
blockers.push("Next.js App Router apply requires app/layout.tsx to render a <body> element.");
|
|
71
|
+
}
|
|
72
|
+
if (hasExistingUnmanagedFile(root, clientComponentPath)) {
|
|
73
|
+
blockers.push("Next.js App Router apply will not overwrite an existing unmanaged lib/infinite-analytics-client.tsx file.");
|
|
74
|
+
}
|
|
75
|
+
if (hasExistingUnmanagedFile(root, analyticsModulePath)) {
|
|
76
|
+
blockers.push("Next.js App Router apply will not overwrite an existing unmanaged lib/infinite-analytics.ts file.");
|
|
77
|
+
}
|
|
78
|
+
return {
|
|
79
|
+
files: [layoutFilePath, clientComponentPath, analyticsModulePath],
|
|
80
|
+
applyMode: blockers.length === 0 ? "supported" : "plan-only",
|
|
81
|
+
instructions: [
|
|
82
|
+
{
|
|
83
|
+
path: layoutFilePath,
|
|
84
|
+
action: "modify",
|
|
85
|
+
description: "Import and mount the managed InfiniteAnalyticsClient from the root app layout.",
|
|
86
|
+
snippet: `${clientImportLine}\n\n${clientTag}`
|
|
87
|
+
}
|
|
88
|
+
],
|
|
89
|
+
assumptions: [
|
|
90
|
+
"Next.js app router placement points are inferred from the app/ tree."
|
|
91
|
+
],
|
|
92
|
+
blockers,
|
|
93
|
+
confidence: detected?.confidence ?? 0.9
|
|
94
|
+
};
|
|
95
|
+
},
|
|
96
|
+
apply(context) {
|
|
97
|
+
const appRoot = context.appRoot === "." ? context.root : join(context.root, context.appRoot);
|
|
98
|
+
const currentLayout = readRequiredFile(appRoot, layoutFilePath);
|
|
99
|
+
if (!currentLayout.includes("<body")) {
|
|
100
|
+
throw new Error("Next.js App Router apply requires app/layout.tsx to render a <body> element.");
|
|
101
|
+
}
|
|
102
|
+
const analyticsModuleAbsolutePath = join(appRoot, analyticsModulePath);
|
|
103
|
+
if (existsSync(analyticsModuleAbsolutePath) &&
|
|
104
|
+
!isManagedInfiniteFile(readFileSync(analyticsModuleAbsolutePath, "utf8"))) {
|
|
105
|
+
throw new Error("Refusing to overwrite existing unmanaged analytics module at lib/infinite-analytics.ts.");
|
|
106
|
+
}
|
|
107
|
+
const clientComponentAbsolutePath = join(appRoot, clientComponentPath);
|
|
108
|
+
if (existsSync(clientComponentAbsolutePath) &&
|
|
109
|
+
!isManagedInfiniteFile(readFileSync(clientComponentAbsolutePath, "utf8"))) {
|
|
110
|
+
throw new Error("Refusing to overwrite existing unmanaged client component at lib/infinite-analytics-client.tsx.");
|
|
111
|
+
}
|
|
112
|
+
const nextLayout = upsertLayoutSource(currentLayout);
|
|
113
|
+
const nextClientComponent = buildClientComponentSource();
|
|
114
|
+
const nextAnalyticsModule = buildAnalyticsModuleSource(context.plan);
|
|
115
|
+
const changedFiles = [];
|
|
116
|
+
if (writeFileIfChanged(appRoot, layoutFilePath, nextLayout)) {
|
|
117
|
+
changedFiles.push(normalizeAppRelativePath(context.appRoot, layoutFilePath));
|
|
118
|
+
}
|
|
119
|
+
if (writeFileIfChanged(appRoot, clientComponentPath, nextClientComponent)) {
|
|
120
|
+
changedFiles.push(normalizeAppRelativePath(context.appRoot, clientComponentPath));
|
|
121
|
+
}
|
|
122
|
+
if (writeFileIfChanged(appRoot, analyticsModulePath, nextAnalyticsModule)) {
|
|
123
|
+
changedFiles.push(normalizeAppRelativePath(context.appRoot, analyticsModulePath));
|
|
124
|
+
}
|
|
125
|
+
return {
|
|
126
|
+
changedFiles,
|
|
127
|
+
warnings: []
|
|
128
|
+
};
|
|
129
|
+
},
|
|
130
|
+
uninstall(context) {
|
|
131
|
+
const appRoot = context.appRoot === "." ? context.root : join(context.root, context.appRoot);
|
|
132
|
+
const removedFiles = [];
|
|
133
|
+
const restoredFiles = [];
|
|
134
|
+
const warnings = [];
|
|
135
|
+
for (const managedPath of [clientComponentPath, analyticsModulePath]) {
|
|
136
|
+
if (hasExistingUnmanagedFile(appRoot, managedPath)) {
|
|
137
|
+
throw new Error(`Refusing to remove ${managedPath} because it no longer looks managed by Infinite. Remove it manually if it should go.`);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
let wiringFullyRemoved = true;
|
|
141
|
+
const layoutAbsolutePath = join(appRoot, layoutFilePath);
|
|
142
|
+
if (!existsSync(layoutAbsolutePath)) {
|
|
143
|
+
warnings.push(`Managed layout already absent: ${layoutFilePath}`);
|
|
144
|
+
}
|
|
145
|
+
else {
|
|
146
|
+
const currentLayout = readFileSync(layoutAbsolutePath, "utf8");
|
|
147
|
+
const nextLayout = removeLayoutWiring(currentLayout);
|
|
148
|
+
if (nextLayout !== currentLayout) {
|
|
149
|
+
if (!context.dryRun) {
|
|
150
|
+
writeFileIfChanged(appRoot, layoutFilePath, nextLayout);
|
|
151
|
+
}
|
|
152
|
+
restoredFiles.push(normalizeAppRelativePath(context.appRoot, layoutFilePath));
|
|
153
|
+
}
|
|
154
|
+
if (nextLayout.includes(clientImportLine) || nextLayout.includes(clientTag)) {
|
|
155
|
+
wiringFullyRemoved = false;
|
|
156
|
+
warnings.push(`Could not remove all InfiniteAnalyticsClient wiring from ${layoutFilePath} automatically. Remove the leftover lines manually.`);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
if (wiringFullyRemoved) {
|
|
160
|
+
for (const managedPath of [clientComponentPath, analyticsModulePath]) {
|
|
161
|
+
const removal = removeManagedFile(appRoot, managedPath, context.dryRun);
|
|
162
|
+
if (removal.removed) {
|
|
163
|
+
removedFiles.push(normalizeAppRelativePath(context.appRoot, managedPath));
|
|
164
|
+
}
|
|
165
|
+
if (removal.warning) {
|
|
166
|
+
warnings.push(removal.warning);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return { removedFiles, restoredFiles, warnings };
|
|
171
|
+
}
|
|
172
|
+
};
|
|
173
|
+
function removeLayoutWiring(source) {
|
|
174
|
+
let next = source.replace(`${clientImportLine}\n`, "");
|
|
175
|
+
next = next.replace(`\n ${clientTag}`, "");
|
|
176
|
+
return next;
|
|
177
|
+
}
|
|
178
|
+
function upsertLayoutSource(source) {
|
|
179
|
+
let next = source;
|
|
180
|
+
if (!next.includes(clientImportLine)) {
|
|
181
|
+
next = `${clientImportLine}\n${next}`;
|
|
182
|
+
}
|
|
183
|
+
if (!next.includes(clientTag)) {
|
|
184
|
+
next = next.replace(/<body\b[^>]*>/, (match) => `${match}\n ${clientTag}`);
|
|
185
|
+
}
|
|
186
|
+
return next;
|
|
187
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { buildAnalyticsModuleSource, buildClientComponentSource, hasExistingUnmanagedFile, isManagedInfiniteFile, removeManagedFile } from "./managed-files.js";
|
|
4
|
+
import { firstExistingPath, hasDependency, normalizeAppRelativePath, readRequiredFile, writeFileIfChanged } from "./shared.js";
|
|
5
|
+
const appCandidates = ["pages/_app.tsx", "pages/_app.ts", "pages/_app.jsx", "pages/_app.js"];
|
|
6
|
+
const indexCandidates = ["pages/index.tsx", "pages/index.ts", "pages/index.jsx", "pages/index.js"];
|
|
7
|
+
const appFilePath = "pages/_app.tsx";
|
|
8
|
+
const clientComponentPath = "lib/infinite-analytics-client.tsx";
|
|
9
|
+
const analyticsModulePath = "lib/infinite-analytics.ts";
|
|
10
|
+
const clientImportLine = 'import { InfiniteAnalyticsClient } from "../lib/infinite-analytics-client"';
|
|
11
|
+
const clientTag = "<InfiniteAnalyticsClient />";
|
|
12
|
+
const missingAppBlocker = "Next.js Pages Router apply requires pages/_app.* so the managed client component can be mounted safely.";
|
|
13
|
+
export const nextPagesRouterAdapter = {
|
|
14
|
+
id: "next-pages-router",
|
|
15
|
+
displayName: "Next.js Pages Router",
|
|
16
|
+
detect(root) {
|
|
17
|
+
if (!hasDependency(root, "next")) {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
const appFile = firstExistingPath(root, appCandidates);
|
|
21
|
+
const indexFile = firstExistingPath(root, indexCandidates);
|
|
22
|
+
if (!appFile && !indexFile) {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
return {
|
|
26
|
+
framework: "next-pages-router",
|
|
27
|
+
confidence: 0.9,
|
|
28
|
+
files: [appFile ?? "pages/_app.tsx", indexFile ?? "pages/index.tsx", "lib/infinite-analytics.ts"],
|
|
29
|
+
assumptions: ["Next.js pages router wiring will target pages/_app.*."]
|
|
30
|
+
};
|
|
31
|
+
},
|
|
32
|
+
plan(root) {
|
|
33
|
+
const detected = this.detect(root);
|
|
34
|
+
const appFile = firstExistingPath(root, appCandidates);
|
|
35
|
+
if (!appFile) {
|
|
36
|
+
return {
|
|
37
|
+
files: [appFilePath, clientComponentPath, analyticsModulePath],
|
|
38
|
+
applyMode: "plan-only",
|
|
39
|
+
instructions: [],
|
|
40
|
+
assumptions: ["Next.js pages router wiring will target pages/_app.*."],
|
|
41
|
+
blockers: [missingAppBlocker],
|
|
42
|
+
confidence: detected?.confidence ?? 0.88
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
const appSource = readRequiredFile(root, appFile);
|
|
46
|
+
const componentMatches = appSource.match(/<Component\b[^>]*\/>/g) ?? [];
|
|
47
|
+
const blockers = [];
|
|
48
|
+
if (appFile !== appFilePath) {
|
|
49
|
+
blockers.push("Next.js Pages Router apply currently supports pages/_app.tsx only. Other custom App entrypoints remain plan-only.");
|
|
50
|
+
}
|
|
51
|
+
if (componentMatches.length !== 1) {
|
|
52
|
+
blockers.push("Next.js Pages Router apply requires pages/_app.tsx to render <Component {...pageProps} /> exactly once.");
|
|
53
|
+
}
|
|
54
|
+
if (hasExistingUnmanagedFile(root, clientComponentPath)) {
|
|
55
|
+
blockers.push("Next.js Pages Router apply will not overwrite an existing unmanaged lib/infinite-analytics-client.tsx file.");
|
|
56
|
+
}
|
|
57
|
+
if (hasExistingUnmanagedFile(root, analyticsModulePath)) {
|
|
58
|
+
blockers.push("Next.js Pages Router apply will not overwrite an existing unmanaged lib/infinite-analytics.ts file.");
|
|
59
|
+
}
|
|
60
|
+
return {
|
|
61
|
+
files: [appFilePath, clientComponentPath, analyticsModulePath],
|
|
62
|
+
applyMode: blockers.length === 0 ? "supported" : "plan-only",
|
|
63
|
+
instructions: [
|
|
64
|
+
{
|
|
65
|
+
path: appFilePath,
|
|
66
|
+
action: "modify",
|
|
67
|
+
description: "Import and mount the managed InfiniteAnalyticsClient from pages/_app.tsx.",
|
|
68
|
+
snippet: `${clientImportLine}\n\n${clientTag}`
|
|
69
|
+
}
|
|
70
|
+
],
|
|
71
|
+
assumptions: [
|
|
72
|
+
"Next.js pages router placement points are inferred from the pages/ tree."
|
|
73
|
+
],
|
|
74
|
+
blockers,
|
|
75
|
+
confidence: detected?.confidence ?? 0.88
|
|
76
|
+
};
|
|
77
|
+
},
|
|
78
|
+
apply(context) {
|
|
79
|
+
const appRoot = context.appRoot === "." ? context.root : join(context.root, context.appRoot);
|
|
80
|
+
const currentApp = readRequiredFile(appRoot, appFilePath);
|
|
81
|
+
if ((currentApp.match(/<Component\b[^>]*\/>/g) ?? []).length !== 1) {
|
|
82
|
+
throw new Error("Next.js Pages Router apply requires pages/_app.tsx to render <Component {...pageProps} /> exactly once.");
|
|
83
|
+
}
|
|
84
|
+
const analyticsModuleAbsolutePath = join(appRoot, analyticsModulePath);
|
|
85
|
+
if (existsSync(analyticsModuleAbsolutePath) &&
|
|
86
|
+
!isManagedInfiniteFile(readFileSync(analyticsModuleAbsolutePath, "utf8"))) {
|
|
87
|
+
throw new Error("Refusing to overwrite existing unmanaged analytics module at lib/infinite-analytics.ts.");
|
|
88
|
+
}
|
|
89
|
+
const clientComponentAbsolutePath = join(appRoot, clientComponentPath);
|
|
90
|
+
if (existsSync(clientComponentAbsolutePath) &&
|
|
91
|
+
!isManagedInfiniteFile(readFileSync(clientComponentAbsolutePath, "utf8"))) {
|
|
92
|
+
throw new Error("Refusing to overwrite existing unmanaged client component at lib/infinite-analytics-client.tsx.");
|
|
93
|
+
}
|
|
94
|
+
const nextApp = upsertAppSource(currentApp);
|
|
95
|
+
const nextClientComponent = buildClientComponentSource();
|
|
96
|
+
const nextAnalyticsModule = buildAnalyticsModuleSource(context.plan);
|
|
97
|
+
const changedFiles = [];
|
|
98
|
+
if (writeFileIfChanged(appRoot, appFilePath, nextApp)) {
|
|
99
|
+
changedFiles.push(normalizeAppRelativePath(context.appRoot, appFilePath));
|
|
100
|
+
}
|
|
101
|
+
if (writeFileIfChanged(appRoot, clientComponentPath, nextClientComponent)) {
|
|
102
|
+
changedFiles.push(normalizeAppRelativePath(context.appRoot, clientComponentPath));
|
|
103
|
+
}
|
|
104
|
+
if (writeFileIfChanged(appRoot, analyticsModulePath, nextAnalyticsModule)) {
|
|
105
|
+
changedFiles.push(normalizeAppRelativePath(context.appRoot, analyticsModulePath));
|
|
106
|
+
}
|
|
107
|
+
return {
|
|
108
|
+
changedFiles,
|
|
109
|
+
warnings: []
|
|
110
|
+
};
|
|
111
|
+
},
|
|
112
|
+
uninstall(context) {
|
|
113
|
+
const appRoot = context.appRoot === "." ? context.root : join(context.root, context.appRoot);
|
|
114
|
+
const removedFiles = [];
|
|
115
|
+
const restoredFiles = [];
|
|
116
|
+
const warnings = [];
|
|
117
|
+
for (const managedPath of [clientComponentPath, analyticsModulePath]) {
|
|
118
|
+
if (hasExistingUnmanagedFile(appRoot, managedPath)) {
|
|
119
|
+
throw new Error(`Refusing to remove ${managedPath} because it no longer looks managed by Infinite. Remove it manually if it should go.`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
let wiringFullyRemoved = true;
|
|
123
|
+
const appAbsolutePath = join(appRoot, appFilePath);
|
|
124
|
+
if (!existsSync(appAbsolutePath)) {
|
|
125
|
+
warnings.push(`Managed app entrypoint already absent: ${appFilePath}`);
|
|
126
|
+
}
|
|
127
|
+
else {
|
|
128
|
+
const currentApp = readFileSync(appAbsolutePath, "utf8");
|
|
129
|
+
const nextApp = removeAppWiring(currentApp);
|
|
130
|
+
if (nextApp !== currentApp) {
|
|
131
|
+
if (!context.dryRun) {
|
|
132
|
+
writeFileIfChanged(appRoot, appFilePath, nextApp);
|
|
133
|
+
}
|
|
134
|
+
restoredFiles.push(normalizeAppRelativePath(context.appRoot, appFilePath));
|
|
135
|
+
}
|
|
136
|
+
if (nextApp.includes(clientImportLine) || nextApp.includes(clientTag)) {
|
|
137
|
+
wiringFullyRemoved = false;
|
|
138
|
+
warnings.push(`Could not remove all InfiniteAnalyticsClient wiring from ${appFilePath} automatically. Remove the leftover lines manually.`);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
if (wiringFullyRemoved) {
|
|
142
|
+
for (const managedPath of [clientComponentPath, analyticsModulePath]) {
|
|
143
|
+
const removal = removeManagedFile(appRoot, managedPath, context.dryRun);
|
|
144
|
+
if (removal.removed) {
|
|
145
|
+
removedFiles.push(normalizeAppRelativePath(context.appRoot, managedPath));
|
|
146
|
+
}
|
|
147
|
+
if (removal.warning) {
|
|
148
|
+
warnings.push(removal.warning);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return { removedFiles, restoredFiles, warnings };
|
|
153
|
+
}
|
|
154
|
+
};
|
|
155
|
+
function removeAppWiring(source) {
|
|
156
|
+
let next = source.replace(`${clientImportLine}\n`, "");
|
|
157
|
+
next = next.replace(/<>\n {6}<InfiniteAnalyticsClient \/>\n {6}(<Component\b[^>]*\/>)\n {4}<\/>/, (_match, component) => component);
|
|
158
|
+
return next;
|
|
159
|
+
}
|
|
160
|
+
function upsertAppSource(source) {
|
|
161
|
+
let next = source;
|
|
162
|
+
if (!next.includes(clientImportLine)) {
|
|
163
|
+
next = `${clientImportLine}\n${next}`;
|
|
164
|
+
}
|
|
165
|
+
if (!next.includes(clientTag)) {
|
|
166
|
+
next = next.replace(/<Component\b[^>]*\/>/, (match) => `<>\n ${clientTag}\n ${match}\n </>`);
|
|
167
|
+
}
|
|
168
|
+
return next;
|
|
169
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
interface WorkspacePackageJson {
|
|
2
|
+
dependencies?: Record<string, string>;
|
|
3
|
+
devDependencies?: Record<string, string>;
|
|
4
|
+
}
|
|
5
|
+
export declare function fileExists(root: string, relativePath: string): boolean;
|
|
6
|
+
export declare function firstExistingPath(root: string, candidates: string[]): string | null;
|
|
7
|
+
export declare function readWorkspacePackageJson(root: string): WorkspacePackageJson | null;
|
|
8
|
+
export declare function hasDependency(root: string, dependencyName: string): boolean;
|
|
9
|
+
export declare function readRequiredFile(root: string, relativePath: string): string;
|
|
10
|
+
export declare function resolveConfinedAppRoot(root: string, appRoot: string): string;
|
|
11
|
+
export declare function assertConfinedManifestFileEntry(root: string, relativePath: string): void;
|
|
12
|
+
export declare function assertWriteTargetInsideRoot(root: string, absolutePath: string): void;
|
|
13
|
+
export declare function writeFileIfChanged(root: string, relativePath: string, contents: string): boolean;
|
|
14
|
+
export declare function writeFileAtomic(absolutePath: string, contents: string): void;
|
|
15
|
+
export declare function indentBlock(source: string, spaces: number): string;
|
|
16
|
+
export declare function normalizeAppRelativePath(appRoot: string, relativePath: string): string;
|
|
17
|
+
export {};
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
4
|
+
export function fileExists(root, relativePath) {
|
|
5
|
+
return existsSync(join(root, relativePath));
|
|
6
|
+
}
|
|
7
|
+
export function firstExistingPath(root, candidates) {
|
|
8
|
+
for (const candidate of candidates) {
|
|
9
|
+
if (fileExists(root, candidate)) {
|
|
10
|
+
return candidate;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
export function readWorkspacePackageJson(root) {
|
|
16
|
+
const packageJsonPath = join(root, "package.json");
|
|
17
|
+
if (!existsSync(packageJsonPath)) {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
return JSON.parse(readFileSync(packageJsonPath, "utf8"));
|
|
21
|
+
}
|
|
22
|
+
export function hasDependency(root, dependencyName) {
|
|
23
|
+
const packageJson = readWorkspacePackageJson(root);
|
|
24
|
+
if (!packageJson) {
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
return Boolean(packageJson.dependencies?.[dependencyName] ?? packageJson.devDependencies?.[dependencyName]);
|
|
28
|
+
}
|
|
29
|
+
export function readRequiredFile(root, relativePath) {
|
|
30
|
+
return readFileSync(join(root, relativePath), "utf8");
|
|
31
|
+
}
|
|
32
|
+
function realpathOrSelf(path) {
|
|
33
|
+
try {
|
|
34
|
+
return realpathSync(path);
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return path;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
function escapesRoot(root, target) {
|
|
41
|
+
const relativePath = relative(root, target);
|
|
42
|
+
return relativePath.startsWith("..") || isAbsolute(relativePath);
|
|
43
|
+
}
|
|
44
|
+
// Realpath the deepest existing ancestor, then re-append the missing tail, so
|
|
45
|
+
// non-existent paths still compare correctly when the root itself sits behind
|
|
46
|
+
// a symlink (e.g. macOS /var/folders -> /private/var/folders).
|
|
47
|
+
function realpathNearestExistingAncestor(path) {
|
|
48
|
+
let current = path;
|
|
49
|
+
const missingTail = [];
|
|
50
|
+
while (!existsSync(current)) {
|
|
51
|
+
const parent = dirname(current);
|
|
52
|
+
if (parent === current) {
|
|
53
|
+
break;
|
|
54
|
+
}
|
|
55
|
+
missingTail.unshift(basename(current));
|
|
56
|
+
current = parent;
|
|
57
|
+
}
|
|
58
|
+
return join(realpathOrSelf(current), ...missingTail);
|
|
59
|
+
}
|
|
60
|
+
export function resolveConfinedAppRoot(root, appRoot) {
|
|
61
|
+
const resolvedRoot = resolve(root);
|
|
62
|
+
const resolvedAppRoot = resolve(resolvedRoot, appRoot);
|
|
63
|
+
if (escapesRoot(resolvedRoot, resolvedAppRoot)) {
|
|
64
|
+
throw new Error(`Refusing to use app root "${appRoot}" because it escapes the workspace root.`);
|
|
65
|
+
}
|
|
66
|
+
if (escapesRoot(realpathOrSelf(resolvedRoot), realpathNearestExistingAncestor(resolvedAppRoot))) {
|
|
67
|
+
throw new Error(`Refusing to use app root "${appRoot}" because it resolves outside the workspace root through a symlink.`);
|
|
68
|
+
}
|
|
69
|
+
return resolvedAppRoot;
|
|
70
|
+
}
|
|
71
|
+
export function assertConfinedManifestFileEntry(root, relativePath) {
|
|
72
|
+
if (isAbsolute(relativePath)) {
|
|
73
|
+
throw new Error(`Refusing to use manifest file entry "${relativePath}" because absolute paths are not allowed.`);
|
|
74
|
+
}
|
|
75
|
+
const resolvedRoot = resolve(root);
|
|
76
|
+
if (escapesRoot(resolvedRoot, resolve(resolvedRoot, relativePath))) {
|
|
77
|
+
throw new Error(`Refusing to use manifest file entry "${relativePath}" because it escapes the workspace root.`);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
export function assertWriteTargetInsideRoot(root, absolutePath) {
|
|
81
|
+
const resolvedRoot = resolve(root);
|
|
82
|
+
const resolvedTarget = resolve(absolutePath);
|
|
83
|
+
if (escapesRoot(resolvedRoot, resolvedTarget)) {
|
|
84
|
+
throw new Error(`Refusing to write outside the workspace root: ${absolutePath}`);
|
|
85
|
+
}
|
|
86
|
+
let nearestExistingAncestor = dirname(resolvedTarget);
|
|
87
|
+
while (!existsSync(nearestExistingAncestor)) {
|
|
88
|
+
const parent = dirname(nearestExistingAncestor);
|
|
89
|
+
if (parent === nearestExistingAncestor) {
|
|
90
|
+
break;
|
|
91
|
+
}
|
|
92
|
+
nearestExistingAncestor = parent;
|
|
93
|
+
}
|
|
94
|
+
if (escapesRoot(realpathOrSelf(resolvedRoot), realpathOrSelf(nearestExistingAncestor))) {
|
|
95
|
+
throw new Error(`Refusing to write through a path that resolves outside the workspace root: ${absolutePath}`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
export function writeFileIfChanged(root, relativePath, contents) {
|
|
99
|
+
const absolutePath = join(root, relativePath);
|
|
100
|
+
assertWriteTargetInsideRoot(root, absolutePath);
|
|
101
|
+
const existing = existsSync(absolutePath) ? readFileSync(absolutePath, "utf8") : null;
|
|
102
|
+
if (existing === contents) {
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
writeFileAtomic(absolutePath, contents);
|
|
106
|
+
return true;
|
|
107
|
+
}
|
|
108
|
+
export function writeFileAtomic(absolutePath, contents) {
|
|
109
|
+
const stats = lstatSync(absolutePath, { throwIfNoEntry: false });
|
|
110
|
+
if (stats?.isSymbolicLink()) {
|
|
111
|
+
throw new Error(`Refusing to write through a symlink at ${absolutePath}. Replace the symlink with a regular file first.`);
|
|
112
|
+
}
|
|
113
|
+
mkdirSync(dirname(absolutePath), { recursive: true });
|
|
114
|
+
const tempPath = `${absolutePath}.${randomBytes(6).toString("hex")}.tmp`;
|
|
115
|
+
try {
|
|
116
|
+
writeFileSync(tempPath, contents);
|
|
117
|
+
renameSync(tempPath, absolutePath);
|
|
118
|
+
}
|
|
119
|
+
catch (error) {
|
|
120
|
+
rmSync(tempPath, { force: true });
|
|
121
|
+
throw error;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
export function indentBlock(source, spaces) {
|
|
125
|
+
const prefix = " ".repeat(spaces);
|
|
126
|
+
return source
|
|
127
|
+
.split("\n")
|
|
128
|
+
.map((line) => (line.length > 0 ? `${prefix}${line}` : line))
|
|
129
|
+
.join("\n");
|
|
130
|
+
}
|
|
131
|
+
export function normalizeAppRelativePath(appRoot, relativePath) {
|
|
132
|
+
if (appRoot === "." || appRoot.length === 0) {
|
|
133
|
+
return relativePath;
|
|
134
|
+
}
|
|
135
|
+
return `${appRoot}/${relativePath}`;
|
|
136
|
+
}
|