Skip to content

Commit 50b91a5

Browse files
committed
Merge branch 'main' of github.com:connortessaro/shopify-web-replicator
2 parents 2317f60 + 0ff837a commit 50b91a5

File tree

13 files changed

+527
-17
lines changed

13 files changed

+527
-17
lines changed

GEMINI.md

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
# GEMINI.md
2+
3+
## Project Context
4+
Shopify Web Replicator is a monorepo containing a replication engine, an MCP server, a companion API, and a React frontend. It automates the replication of Shopify storefronts into a local theme workspace.
5+
6+
## Critical Mandates
7+
- **Build Order:** You MUST build packages in the following order: `packages/shared``packages/engine``apps/api` | `apps/web` | `apps/mcp`.
8+
- **Logic Duplication:** `apps/api/src/services/` contains intentional duplicates of services in `packages/engine`. When modifying engine logic, you MUST check if the corresponding service in `apps/api` needs mirroring.
9+
- **Stable Artifacts:** Never change the filenames of generated artifacts defined as constants in `packages/shared/src/job.ts`.
10+
- **Preflight Checks:** The MCP server (`apps/mcp`) runs preflight checks on every tool call. Ensure any new dependencies or system requirements are added to `runtime-preflight.ts`.
11+
12+
## Development Workflows
13+
14+
### Build & Verify
15+
- **Full Verification:** `pnpm build && pnpm test && pnpm typecheck && pnpm theme:check`
16+
- **Install:** `pnpm install --frozen-lockfile --ignore-scripts`
17+
- **Build All:** `pnpm build`
18+
- **Type-check:** `pnpm typecheck`
19+
- **Theme Linting:** `pnpm theme:check`
20+
21+
### Testing
22+
- **Run All Tests:** `pnpm test`
23+
- **Package Specific:** `pnpm --filter <package-name> test` (e.g., `@shopify-web-replicator/engine`)
24+
- **Single File:** `pnpm --filter <package-name> exec vitest run <path-to-test>`
25+
26+
### Local Execution
27+
- **Dev Mode:** `pnpm dev` (starts watchers and servers in parallel)
28+
- **Database:** SQLite is used, located at `.data/replicator.db` by default.
29+
30+
## Architectural Patterns
31+
- **Services:** All services depend on interfaces in `src/services/types.ts` to allow for easy testing with doubles.
32+
- **Persistence:** `SqliteJobRepository` handles job state persistence after each pipeline stage.
33+
- **Pipeline:** Deterministic sequence: `intake``analysis``mapping``theme_generation``store_setup``commerce_wiring``validation``integration_check``review`.
34+
35+
## Environment Variables
36+
| Variable | Default | Purpose |
37+
|---|---|---|
38+
| `REPLICATOR_DB_PATH` | `.data/replicator.db` | SQLite database path |
39+
| `THEME_WORKSPACE_PATH` | `packages/theme-workspace` | Target Shopify theme directory |
40+
| `REPLICATOR_CAPTURE_ROOT` | `.data/captures` | Playwright capture storage |
41+
| `PORT` | `8787` | API server port |

apps/api/src/services/integration-report-generator.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,7 @@ describe("ShopifyIntegrationReportGenerator", () => {
154154
summary: "All deterministic integration checks passed for Example Storefront."
155155
});
156156
await expect(readFile(join(themeRoot, "config/generated-integration-report.json"), "utf8")).resolves.toContain(
157-
"\"generated_artifacts\""
157+
"\"generatedArtifacts\""
158158
);
159159
});
160160

apps/api/src/services/replication-pipeline.test.ts

Lines changed: 85 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,8 +83,12 @@ describe("ReplicationPipeline", () => {
8383

8484
const repository = new SqliteJobRepository(join(dataRoot, "replicator.db"));
8585
const job = createReplicationJob({
86+
<<<<<<< HEAD
8687
referenceUrl: "https://example.com",
8788
destinationStore: "local-dev-store",
89+
=======
90+
referenceUrl: "https://example.com/offer",
91+
>>>>>>> 0ff837ae2df3782ab4b72a9b6d93d92b7f7d8110
8892
notes: "Landing page MVP"
8993
});
9094

@@ -246,7 +250,7 @@ describe("ReplicationPipeline", () => {
246250
readFile(join(themeRoot, "templates/page.generated-reference.json"), "utf8")
247251
).resolves.toContain('"type": "generated-reference"');
248252
await expect(readFile(join(themeRoot, "config/generated-store-setup.json"), "utf8")).resolves.toContain(
249-
"\"example-storefront\""
253+
"\"example-heading\""
250254
);
251255
await expect(readFile(join(themeRoot, "snippets/generated-commerce-wiring.liquid"), "utf8")).resolves.toContain(
252256
"/checkout"
@@ -750,4 +754,84 @@ describe("ReplicationPipeline", () => {
750754
])
751755
});
752756
});
757+
758+
it("still writes the integration report when validation fails (Bug 3)", async () => {
759+
const dataRoot = await mkdtemp(join(tmpdir(), "shopify-web-replicator-jobs-"));
760+
const themeRoot = await mkdtemp(join(tmpdir(), "shopify-web-replicator-theme-"));
761+
tempDirectories.push(dataRoot, themeRoot);
762+
763+
const repository = new SqliteJobRepository(join(dataRoot, "replicator.db"));
764+
const job = createReplicationJob({
765+
referenceUrl: "https://example.com",
766+
notes: "Landing page MVP"
767+
});
768+
769+
await repository.save(job);
770+
771+
const pipeline = new ReplicationPipeline({
772+
repository,
773+
analyzer: {
774+
async analyze({ referenceUrl, notes }) {
775+
return {
776+
sourceUrl: referenceUrl,
777+
pageType: "landing_page",
778+
title: "Example Storefront",
779+
summary: notes
780+
? `Prepared deterministic analysis for Example Storefront. Operator notes: ${notes}`
781+
: "Prepared deterministic analysis for Example Storefront.",
782+
analyzedAt: "2026-03-20T12:01:00.000Z",
783+
recommendedSections: ["hero", "cta"]
784+
} satisfies ReferenceAnalysis;
785+
}
786+
},
787+
mapper: {
788+
async map({ analysis, referenceUrl, notes }) {
789+
return {
790+
sourceUrl: referenceUrl,
791+
title: analysis.title,
792+
summary: notes
793+
? `Mapped ${analysis.title} into the stable generated reference section. Operator notes: ${notes}`
794+
: `Mapped ${analysis.title} into the stable generated reference section.`,
795+
mappedAt: "2026-03-20T12:02:00.000Z",
796+
templatePath: "templates/page.generated-reference.json",
797+
sectionPath: "sections/generated-reference.liquid",
798+
sections: [
799+
{
800+
id: "hero-1",
801+
type: "hero",
802+
heading: "Example heading",
803+
body: "Example body copy",
804+
ctaLabel: "Review generated output",
805+
ctaHref: "/pages/generated-reference"
806+
}
807+
]
808+
} satisfies ThemeMapping;
809+
}
810+
},
811+
generator: new ShopifyThemeGenerator(themeRoot),
812+
storeSetupGenerator: new ShopifyStoreSetupGenerator(themeRoot),
813+
commerceGenerator: new ShopifyCommerceWiringGenerator(themeRoot),
814+
integrationGenerator: new ShopifyIntegrationReportGenerator(themeRoot),
815+
themeValidator: {
816+
async validate() {
817+
return {
818+
status: "failed",
819+
summary: "Theme check failed: missing required liquid tag.",
820+
checkedAt: "2026-03-20T12:03:00.000Z"
821+
} satisfies ThemeCheckResult;
822+
}
823+
}
824+
});
825+
826+
await pipeline.process(job.id);
827+
828+
const savedJob = await repository.getById(job.id);
829+
830+
expect(savedJob?.status).toBe("failed");
831+
expect(savedJob?.integration).toBeDefined();
832+
833+
await expect(
834+
readFile(join(themeRoot, "config/generated-integration-report.json"), "utf8")
835+
).resolves.toContain("\"commerce_snippet_reference\"");
836+
});
753837
});

apps/api/src/services/theme-generator.test.ts

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,9 @@ describe("ShopifyThemeGenerator", () => {
8484
await expect(readFile(join(themeRoot, "templates/page.generated-reference.json"), "utf8")).resolves.toContain(
8585
'"type": "generated-reference"'
8686
);
87+
await expect(readFile(join(themeRoot, "templates/page.generated-reference.json"), "utf8")).resolves.not.toContain(
88+
'"mapping_summary"'
89+
);
8790
});
8891

8992
it("writes product-page outputs with a product form into product-specific stable files", async () => {
@@ -242,4 +245,102 @@ describe("ShopifyThemeGenerator", () => {
242245
'"type": "generated-reference"'
243246
);
244247
});
248+
249+
it("guards the product-page eyebrow with a blank check, includes a product image, and omits mapping_summary", async () => {
250+
const themeRoot = await mkdtemp(join(tmpdir(), "shopify-web-replicator-theme-"));
251+
tempDirectories.push(themeRoot);
252+
253+
const generator = new ShopifyThemeGenerator(themeRoot);
254+
const analysis: ReferenceAnalysis = {
255+
sourceUrl: "https://example.com/products/trail-pack",
256+
referenceHost: "example.com",
257+
pageType: "product_page",
258+
title: "Trail Pack",
259+
summary: "Product page summary.",
260+
analyzedAt: "2026-03-20T12:00:00.000Z",
261+
recommendedSections: ["product_detail"]
262+
};
263+
const mapping: ThemeMapping = {
264+
sourceUrl: "https://example.com/products/trail-pack",
265+
title: "Trail Pack",
266+
summary: "Product mapping summary.",
267+
mappedAt: "2026-03-20T12:01:00.000Z",
268+
templatePath: "templates/product.generated-reference.json",
269+
sectionPath: "sections/generated-product-reference.liquid",
270+
sections: [
271+
{
272+
id: "product-hero-1",
273+
type: "product_detail",
274+
heading: "Trail Pack",
275+
body: "Durable pack copy",
276+
ctaLabel: "Add to cart",
277+
ctaHref: "/cart"
278+
}
279+
]
280+
};
281+
282+
await generator.generate({ analysis, mapping });
283+
284+
const sectionContent = await readFile(join(themeRoot, "sections/generated-product-reference.liquid"), "utf8");
285+
const templateContent = await readFile(join(themeRoot, "templates/product.generated-reference.json"), "utf8");
286+
287+
// Bug 4 fix: eyebrow is wrapped in a blank check
288+
expect(sectionContent).toContain("{% if section.settings.eyebrow != blank %}");
289+
290+
// Feature 2 fix: product image is rendered
291+
expect(sectionContent).toContain("product.featured_image");
292+
293+
// Feature 1 fix: mapping_summary is absent from section HTML and schema settings
294+
expect(sectionContent).not.toContain("section.settings.mapping_summary");
295+
expect(sectionContent).not.toContain('"mapping_summary"');
296+
297+
// Feature 1 fix: mapping_summary is absent from the template JSON
298+
expect(templateContent).not.toContain('"mapping_summary"');
299+
});
300+
301+
it("renders collection-page product cards as linked <a> elements and omits mapping_summary", async () => {
302+
const themeRoot = await mkdtemp(join(tmpdir(), "shopify-web-replicator-theme-"));
303+
tempDirectories.push(themeRoot);
304+
305+
const generator = new ShopifyThemeGenerator(themeRoot);
306+
const analysis: ReferenceAnalysis = {
307+
sourceUrl: "https://example.com/collections/summer-gear",
308+
referenceHost: "example.com",
309+
pageType: "collection_page",
310+
title: "Summer Gear",
311+
summary: "Collection page summary.",
312+
analyzedAt: "2026-03-20T12:00:00.000Z",
313+
recommendedSections: ["collection_grid"]
314+
};
315+
const mapping: ThemeMapping = {
316+
sourceUrl: "https://example.com/collections/summer-gear",
317+
title: "Summer Gear",
318+
summary: "Collection mapping summary.",
319+
mappedAt: "2026-03-20T12:01:00.000Z",
320+
templatePath: "templates/collection.generated-reference.json",
321+
sectionPath: "sections/generated-collection-reference.liquid",
322+
sections: [
323+
{
324+
id: "collection-1",
325+
type: "collection_grid",
326+
heading: "Summer Gear",
327+
body: "Collection copy"
328+
}
329+
]
330+
};
331+
332+
await generator.generate({ analysis, mapping });
333+
334+
const sectionContent = await readFile(join(themeRoot, "sections/generated-collection-reference.liquid"), "utf8");
335+
const templateContent = await readFile(join(themeRoot, "templates/collection.generated-reference.json"), "utf8");
336+
337+
// Bug 5 fix: cards use <a href="{{ product.url }}"> instead of <article>
338+
expect(sectionContent).toContain('<a class="generated-collection-reference__card" href="{{ product.url }}">');
339+
expect(sectionContent).not.toContain("<article");
340+
341+
// Feature 1 fix: mapping_summary is absent from section HTML, schema settings, and template JSON
342+
expect(sectionContent).not.toContain("section.settings.mapping_summary");
343+
expect(sectionContent).not.toContain('"mapping_summary"');
344+
expect(templateContent).not.toContain('"mapping_summary"');
345+
});
245346
});

packages/engine/src/orchestrator.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import type {
22
AppRuntimeConfig,
3+
<<<<<<< HEAD
34
DestinationStoreProfile,
5+
=======
6+
>>>>>>> 0ff837ae2df3782ab4b72a9b6d93d92b7f7d8110
47
ReferenceIntake,
58
ReplicationJob,
69
ReplicationJobSummary
@@ -24,6 +27,7 @@ import { ShopifyStoreSetupGenerator } from "./services/store-setup-generator.js"
2427
import { DeterministicThemeMapper } from "./services/theme-mapper.js";
2528
import { ShopifyThemeGenerator } from "./services/theme-generator.js";
2629
import { ShopifyThemeValidator } from "./services/theme-validator.js";
30+
<<<<<<< HEAD
2731
import type {
2832
AdminReplicationService,
2933
Analyzer,
@@ -40,6 +44,9 @@ import type {
4044
StoreSetupGenerator,
4145
ThemeValidator
4246
} from "./services/types.js";
47+
=======
48+
import type { Analyzer, CommerceGenerator, Generator, IntegrationGenerator, Mapper, StoreSetupGenerator, ThemeValidator } from "./services/types.js";
49+
>>>>>>> 0ff837ae2df3782ab4b72a9b6d93d92b7f7d8110
4350

4451
export interface ReplicationHandoff {
4552
job: ReplicationJob;
@@ -155,6 +162,50 @@ export class ReplicationOrchestrator {
155162
return job;
156163
}
157164

165+
async retryJob(jobId: string): Promise<ReplicationHandoff> {
166+
const job = await this.repository.getById(jobId);
167+
168+
if (!job) {
169+
throw new Error(`Missing job ${jobId}`);
170+
}
171+
172+
if (job.status !== "failed") {
173+
throw new Error(`Job ${jobId} is not in a failed state`);
174+
}
175+
176+
for (const stage of job.stages) {
177+
if (stage.status === "failed") {
178+
stage.status = "pending";
179+
delete stage.errorMessage;
180+
delete stage.summary;
181+
}
182+
}
183+
184+
const firstPendingStage = job.stages.find((stage) => stage.status === "pending");
185+
if (firstPendingStage) {
186+
firstPendingStage.status = "current";
187+
job.currentStage = firstPendingStage.name;
188+
}
189+
190+
job.status = "in_progress";
191+
delete job.error;
192+
193+
job.artifacts = job.artifacts.map((artifact) =>
194+
artifact.status === "failed" ? { ...artifact, status: "pending" } : artifact
195+
);
196+
197+
job.updatedAt = new Date().toISOString();
198+
await this.repository.save(job);
199+
200+
const updatedJob = await this.runJob(jobId);
201+
202+
return {
203+
job: updatedJob,
204+
runtime: this.#runtime,
205+
nextActions: buildNextActions(updatedJob)
206+
};
207+
}
208+
158209
async replicateStorefront(intake: ReferenceIntake): Promise<ReplicationHandoff> {
159210
const created = await this.createJob(intake);
160211
const job = await this.runJob(created.jobId);

packages/engine/src/services/integration-report-generator.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,7 +226,11 @@ export class ShopifyIntegrationReportGenerator {
226226
`${JSON.stringify(
227227
{
228228
generatedBy: "Shopify Web Replicator",
229+
<<<<<<< HEAD
229230
checkedAt,
231+
=======
232+
checkedAt: checkedAt,
233+
>>>>>>> 0ff837ae2df3782ab4b72a9b6d93d92b7f7d8110
230234
sourceUrl: analysis.sourceUrl,
231235
pageType: analysis.pageType,
232236
title: analysis.title,

0 commit comments

Comments
 (0)