chore: optimize cms startup, refactor scripts and implement real-time dev mode

This commit is contained in:
2026-02-16 18:10:52 +01:00
parent 67750c886e
commit 8f32c80801
33 changed files with 1185 additions and 618 deletions

View File

@@ -21,6 +21,8 @@ services:
- "traefik.enable=true" - "traefik.enable=true"
- "traefik.http.routers.sample-website.rule=Host(`${TRAEFIK_HOST:-sample-website.localhost}`)" - "traefik.http.routers.sample-website.rule=Host(`${TRAEFIK_HOST:-sample-website.localhost}`)"
- "traefik.http.services.sample-website.loadbalancer.server.port=3000" - "traefik.http.services.sample-website.loadbalancer.server.port=3000"
- "caddy=http://${TRAEFIK_HOST:-acquisition.localhost}"
- "caddy.reverse_proxy={{upstreams 3000}}"
directus: directus:
image: registry.infra.mintel.me/mintel/directus:${IMAGE_TAG:-latest} image: registry.infra.mintel.me/mintel/directus:${IMAGE_TAG:-latest}
@@ -58,6 +60,8 @@ services:
- "traefik.enable=true" - "traefik.enable=true"
- "traefik.http.routers.sample-website-directus.rule=Host(`${DIRECTUS_HOST:-cms.sample-website.localhost}`)" - "traefik.http.routers.sample-website-directus.rule=Host(`${DIRECTUS_HOST:-cms.sample-website.localhost}`)"
- "traefik.http.services.sample-website-directus.loadbalancer.server.port=8055" - "traefik.http.services.sample-website-directus.loadbalancer.server.port=8055"
- "caddy=http://${DIRECTUS_HOST:-cms.at.localhost}"
- "caddy.reverse_proxy={{upstreams 8055}}"
at-mintel-directus-db: at-mintel-directus-db:
image: postgres:15-alpine image: postgres:15-alpine

View File

@@ -10,15 +10,16 @@
"changeset": "changeset", "changeset": "changeset",
"version-packages": "changeset version", "version-packages": "changeset version",
"sync-versions": "tsx scripts/sync-versions.ts --", "sync-versions": "tsx scripts/sync-versions.ts --",
"cms:push:infra": "./scripts/sync-directus.sh push infra", "cms:dev": "pnpm --filter @mintel/cms-infra dev",
"cms:pull:infra": "./scripts/sync-directus.sh pull infra", "cms:up": "pnpm --filter @mintel/cms-infra up",
"cms:down": "pnpm --filter @mintel/cms-infra down",
"cms:logs": "pnpm --filter @mintel/cms-infra logs",
"cms:schema:snapshot": "./scripts/cms-snapshot.sh", "cms:schema:snapshot": "./scripts/cms-snapshot.sh",
"cms:schema:apply": "./scripts/cms-apply.sh local", "cms:schema:apply": "./scripts/cms-apply.sh local",
"cms:schema:apply:infra": "./scripts/cms-apply.sh infra", "cms:schema:apply:infra": "./scripts/cms-apply.sh infra",
"cms:up": "cd packages/cms-infra && npm run up -- --force-recreate", "cms:sync:push": "./scripts/sync-directus.sh push infra",
"cms:down": "cd packages/cms-infra && npm run down", "cms:sync:pull": "./scripts/sync-directus.sh pull infra",
"cms:logs": "cd packages/cms-infra && npm run logs", "build:extensions": "./scripts/sync-extensions.sh",
"dev:infra": "docker-compose up -d directus directus-db",
"release": "pnpm build && changeset publish", "release": "pnpm build && changeset publish",
"release:tag": "pnpm build && pnpm -r publish --no-git-checks --access public", "release:tag": "pnpm build && pnpm -r publish --no-git-checks --access public",
"prepare": "husky" "prepare": "husky"
@@ -63,4 +64,4 @@
"@sentry/nextjs": "10.38.0" "@sentry/nextjs": "10.38.0"
} }
} }
} }

View File

@@ -20,9 +20,11 @@
"build": "directus-extension build", "build": "directus-extension build",
"dev": "directus-extension build -w" "dev": "directus-extension build -w"
}, },
"dependencies": {
"@mintel/directus-extension-toolkit": "workspace:*"
},
"devDependencies": { "devDependencies": {
"@directus/extensions-sdk": "11.0.2", "@directus/extensions-sdk": "11.0.2",
"@mintel/directus-extension-toolkit": "workspace:*",
"vue": "^3.4.0" "vue": "^3.4.0"
} }
} }

View File

@@ -72,6 +72,15 @@
<v-button v-if="selectedLead?.audit_pdf_path" secondary icon v-tooltip.bottom="'PDF öffnen'" @click="openPdf"> <v-button v-if="selectedLead?.audit_pdf_path" secondary icon v-tooltip.bottom="'PDF öffnen'" @click="openPdf">
<v-icon name="open_in_new" /> <v-icon name="open_in_new" />
</v-button> </v-button>
<v-button
v-if="selectedLead && !isCustomer(selectedLead.company)"
secondary
@click="linkAsCustomer"
>
<v-icon name="handshake" left />
Kunde verlinken
</v-button>
<v-button <v-button
v-if="selectedLead?.audit_pdf_path" v-if="selectedLead?.audit_pdf_path"
@@ -182,9 +191,12 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, computed } from 'vue'; import { ref, onMounted, computed } from 'vue';
import { useApi } from '@directus/extensions-sdk'; import { useApi } from '@directus/extensions-sdk';
import { useRoute, useRouter } from 'vue-router';
import { MintelManagerLayout, MintelSelect, MintelStatCard } from '@mintel/directus-extension-toolkit'; import { MintelManagerLayout, MintelSelect, MintelStatCard } from '@mintel/directus-extension-toolkit';
const api = useApi(); const api = useApi();
const route = useRoute();
const router = useRouter();
const leads = ref<any[]>([]); const leads = ref<any[]>([]);
const selectedLeadId = ref<string | null>(null); const selectedLeadId = ref<string | null>(null);
const loadingAudit = ref(false); const loadingAudit = ref(false);
@@ -204,6 +216,7 @@ const newLead = ref({
const companies = ref<any[]>([]); const companies = ref<any[]>([]);
const people = ref<any[]>([]); const people = ref<any[]>([]);
const customers = ref<any[]>([]);
const companyOptions = computed(() => const companyOptions = computed(() =>
companies.value.map(c => ({ companies.value.map(c => ({
@@ -242,7 +255,7 @@ const selectedLead = computed(() => leads.value.find(l => l.id === selectedLeadI
async function fetchData() { async function fetchData() {
try { try {
const [leadsResp, peopleResp, companiesResp] = await Promise.all([ const [leadsResp, peopleResp, companiesResp, customersResp] = await Promise.all([
api.get('/items/leads', { api.get('/items/leads', {
params: { params: {
sort: '-date_created', sort: '-date_created',
@@ -250,11 +263,13 @@ async function fetchData() {
} }
}), }),
api.get('/items/people', { params: { sort: 'last_name' } }), api.get('/items/people', { params: { sort: 'last_name' } }),
api.get('/items/companies', { params: { sort: 'name' } }) api.get('/items/companies', { params: { sort: 'name' } }),
api.get('/items/customers', { params: { fields: ['company'] } })
]); ]);
leads.value = leadsResp.data.data; leads.value = leadsResp.data.data;
people.value = peopleResp.data.data; people.value = peopleResp.data.data;
companies.value = companiesResp.data.data; companies.value = companiesResp.data.data;
customers.value = customersResp.data.data;
if (!selectedLeadId.value && leads.value.length > 0) { if (!selectedLeadId.value && leads.value.length > 0) {
selectedLeadId.value = leads.value[0].id; selectedLeadId.value = leads.value[0].id;
@@ -264,6 +279,33 @@ async function fetchData() {
} }
} }
function isCustomer(companyId: string | any) {
if (!companyId) return false;
const id = typeof companyId === 'object' ? companyId.id : companyId;
return customers.value.some(c => (typeof c.company === 'object' ? c.company.id : c.company) === id);
}
async function linkAsCustomer() {
if (!selectedLead.value) return;
const companyId = selectedLead.value.company
? (typeof selectedLead.value.company === 'object' ? selectedLead.value.company.id : selectedLead.value.company)
: null;
const personId = selectedLead.value.contact_person
? (typeof selectedLead.value.contact_person === 'object' ? selectedLead.value.contact_person.id : selectedLead.value.contact_person)
: null;
router.push({
name: 'module-customer-manager',
query: {
create: 'true',
company: companyId,
contact_person: personId
}
});
}
async function fetchLeads() { async function fetchLeads() {
await fetchData(); await fetchData();
} }
@@ -391,7 +433,12 @@ function getStatusColor(status: string) {
} }
} }
onMounted(fetchData); onMounted(async () => {
await fetchData();
if (route.query.create === 'true') {
openCreateDrawer();
}
});
</script> </script>
<style scoped> <style scoped>

View File

@@ -11,7 +11,9 @@ const outfile = resolve(__dirname, 'dist/index.js');
try { try {
mkdirSync(dirname(outfile), { recursive: true }); mkdirSync(dirname(outfile), { recursive: true });
} catch (e) { } } catch {
// ignore
}
console.log(`Building from ${entryPoint} to ${outfile}...`); console.log(`Building from ${entryPoint} to ${outfile}...`);
@@ -22,12 +24,16 @@ build({
target: 'node18', target: 'node18',
outfile: outfile, outfile: outfile,
jsx: 'automatic', jsx: 'automatic',
format: 'esm',
// footer: {
// js: "module.exports = module.exports.default || module.exports;",
// },
loader: { loader: {
'.tsx': 'tsx', '.tsx': 'tsx',
'.ts': 'ts', '.ts': 'ts',
'.js': 'js', '.js': 'js',
}, },
external: ["@react-pdf/renderer", "react", "react-dom", "jsdom", "jsdom/*", "jquery", "jquery/*", "canvas", "fs", "path", "os", "http", "https", "zlib", "stream", "util", "url", "net", "tls", "crypto"], external: ["canvas", "fs", "path", "os", "http", "https", "zlib", "stream", "util", "url", "net", "tls", "crypto"],
plugins: [{ plugins: [{
name: 'mock-canvas', name: 'mock-canvas',
setup(build) { setup(build) {

View File

@@ -24,4 +24,4 @@
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4" "react-dom": "^19.2.4"
} }
} }

View File

@@ -1,176 +1,236 @@
import { defineEndpoint } from "@directus/extensions-sdk"; import { defineEndpoint } from "@directus/extensions-sdk";
import { AcquisitionService, PdfEngine } from "@mintel/pdf/server"; import { AcquisitionService, PdfEngine } from "@mintel/pdf/server";
import { render, SiteAuditTemplate, ProjectEstimateTemplate } from "@mintel/mail"; import {
render,
SiteAuditTemplate,
ProjectEstimateTemplate,
} from "@mintel/mail";
import { createElement } from "react"; import { createElement } from "react";
import * as path from "path"; import * as path from "path";
import * as fs from "fs"; import * as fs from "fs";
export default defineEndpoint((router, { services, env }) => { export default defineEndpoint((router, { services, env }) => {
const { ItemsService, MailService } = services;
router.get("/ping", (req, res) => res.send("pong"));
router.post("/audit/:id", async (req: any, res: any) => {
const { id } = req.params;
const leadsService = new ItemsService("leads", {
schema: req.schema,
accountability: req.accountability,
});
try {
const lead = await leadsService.readOne(id);
if (!lead) return res.status(404).send({ error: "Lead not found" });
await leadsService.updateOne(id, { status: "auditing" });
const acqService = new AcquisitionService(env.OPENROUTER_API_KEY);
const result = await acqService.runFullSequence(
lead.website_url,
lead.briefing,
lead.comments,
);
await leadsService.updateOne(id, {
status: "audit_ready",
ai_state: result.state,
audit_context: JSON.stringify(result.usage),
});
res.send({ success: true, result });
} catch (error: any) {
console.error("Audit failed:", error);
await leadsService.updateOne(id, {
status: "new",
comments: `Audit failed: ${error.message}`,
});
res.status(500).send({ error: error.message });
}
});
router.post("/audit-email/:id", async (req: any, res: any) => {
const { id } = req.params;
const { ItemsService, MailService } = services; const { ItemsService, MailService } = services;
const leadsService = new ItemsService("leads", {
router.get("/ping", (req, res) => res.send("pong")); schema: req.schema,
accountability: req.accountability,
router.post("/audit/:id", async (req: any, res: any) => { });
const { id } = req.params; const _peopleService = new ItemsService("people", {
const leadsService = new ItemsService("leads", { schema: req.schema, accountability: req.accountability }); schema: req.schema,
accountability: req.accountability,
try { });
const lead = await leadsService.readOne(id); const companiesService = new ItemsService("companies", {
if (!lead) return res.status(404).send({ error: "Lead not found" }); schema: req.schema,
accountability: req.accountability,
await leadsService.updateOne(id, { status: "auditing" }); });
const mailService = new MailService({
const acqService = new AcquisitionService(env.OPENROUTER_API_KEY); schema: req.schema,
const result = await acqService.runFullSequence(lead.website_url, lead.briefing, lead.comments); accountability: req.accountability,
await leadsService.updateOne(id, {
status: "audit_ready",
ai_state: result.state,
audit_context: JSON.stringify(result.usage),
});
res.send({ success: true, result });
} catch (error: any) {
console.error("Audit failed:", error);
await leadsService.updateOne(id, { status: "new", comments: `Audit failed: ${error.message}` });
res.status(500).send({ error: error.message });
}
}); });
router.post("/audit-email/:id", async (req: any, res: any) => { try {
const { id } = req.params; const lead = await leadsService.readOne(id, {
const { ItemsService, MailService } = services; fields: ["*", "company.*", "contact_person.*"],
const leadsService = new ItemsService("leads", { schema: req.schema, accountability: req.accountability }); });
const peopleService = new ItemsService("people", { schema: req.schema, accountability: req.accountability }); if (!lead || !lead.ai_state)
const companiesService = new ItemsService("companies", { schema: req.schema, accountability: req.accountability }); return res.status(400).send({ error: "Lead or Audit not ready" });
const mailService = new MailService({ schema: req.schema, accountability: req.accountability });
try { let recipientEmail = lead.contact_email;
const lead = await leadsService.readOne(id, { fields: ["*", "company.*", "contact_person.*"] }); let companyName = lead.company?.name || lead.company_name;
if (!lead || !lead.ai_state) return res.status(400).send({ error: "Lead or Audit not ready" });
let recipientEmail = lead.contact_email; if (lead.contact_person) {
let companyName = lead.company?.name || lead.company_name; recipientEmail = lead.contact_person.email || recipientEmail;
if (lead.contact_person) { if (lead.contact_person.company) {
recipientEmail = lead.contact_person.email || recipientEmail; const personCompany = await companiesService.readOne(
lead.contact_person.company,
if (lead.contact_person.company) { );
const personCompany = await companiesService.readOne(lead.contact_person.company); companyName = personCompany?.name || companyName;
companyName = personCompany?.name || companyName;
}
}
if (!recipientEmail) return res.status(400).send({ error: "No recipient email found" });
const auditHighlights = [
`Projekt-Typ: ${lead.ai_state.projectType === "website" ? "Website" : "Web App"}`,
...(lead.ai_state.sitemap || []).slice(0, 3).map((item: any) => `Potenzial in: ${item.category}`),
];
const html = await render(createElement(SiteAuditTemplate, {
companyName: companyName,
websiteUrl: lead.website_url,
auditHighlights
}));
await mailService.send({
to: recipientEmail,
subject: `Analyse Ihrer Webpräsenz: ${companyName}`,
html
});
await leadsService.updateOne(id, {
status: "contacted",
last_contacted_at: new Date().toISOString(),
});
res.send({ success: true });
} catch (error: any) {
console.error("Audit Email failed:", error);
res.status(500).send({ error: error.message });
} }
}
if (!recipientEmail)
return res.status(400).send({ error: "No recipient email found" });
const auditHighlights = [
`Projekt-Typ: ${lead.ai_state.projectType === "website" ? "Website" : "Web App"}`,
...(lead.ai_state.sitemap || [])
.slice(0, 3)
.map((item: any) => `Potenzial in: ${item.category}`),
];
const html = await render(
createElement(SiteAuditTemplate, {
companyName: companyName,
websiteUrl: lead.website_url,
auditHighlights,
}),
);
await mailService.send({
to: recipientEmail,
subject: `Analyse Ihrer Webpräsenz: ${companyName}`,
html,
});
await leadsService.updateOne(id, {
status: "contacted",
last_contacted_at: new Date().toISOString(),
});
res.send({ success: true });
} catch (error: any) {
console.error("Audit Email failed:", error);
res.status(500).send({ error: error.message });
}
});
router.post("/estimate/:id", async (req: any, res: any) => {
const { id } = req.params;
const leadsService = new ItemsService("leads", {
schema: req.schema,
accountability: req.accountability,
}); });
router.post("/estimate/:id", async (req: any, res: any) => { try {
const { id } = req.params; const lead = await leadsService.readOne(id);
const leadsService = new ItemsService("leads", { schema: req.schema, accountability: req.accountability }); if (!lead || !lead.ai_state)
return res.status(400).send({ error: "Lead or AI state not found" });
try { const pdfEngine = new PdfEngine();
const lead = await leadsService.readOne(id); const filename = `estimate_${id}_${Date.now()}.pdf`;
if (!lead || !lead.ai_state) return res.status(400).send({ error: "Lead or AI state not found" }); const storageRoot = env.STORAGE_LOCAL_ROOT || "./storage";
const outputPath = path.join(storageRoot, filename);
const pdfEngine = new PdfEngine(); await pdfEngine.generateEstimatePdf(lead.ai_state, outputPath);
const filename = `estimate_${id}_${Date.now()}.pdf`;
const storageRoot = env.STORAGE_LOCAL_ROOT || "./storage";
const outputPath = path.join(storageRoot, filename);
await pdfEngine.generateEstimatePdf(lead.ai_state, outputPath); await leadsService.updateOne(id, {
audit_pdf_path: filename,
});
await leadsService.updateOne(id, { res.send({ success: true, filename });
audit_pdf_path: filename, } catch (error: any) {
}); console.error("PDF Generation failed:", error);
res.status(500).send({ error: error.message });
}
});
res.send({ success: true, filename }); router.post("/estimate-email/:id", async (req: any, res: any) => {
} catch (error: any) { const { id } = req.params;
console.error("PDF Generation failed:", error); const leadsService = new ItemsService("leads", {
res.status(500).send({ error: error.message }); schema: req.schema,
} accountability: req.accountability,
});
const _peopleService = new ItemsService("people", {
schema: req.schema,
accountability: req.accountability,
});
const companiesService = new ItemsService("companies", {
schema: req.schema,
accountability: req.accountability,
});
const mailService = new MailService({
schema: req.schema,
accountability: req.accountability,
}); });
router.post("/estimate-email/:id", async (req: any, res: any) => { try {
const { id } = req.params; const lead = await leadsService.readOne(id, {
const leadsService = new ItemsService("leads", { schema: req.schema, accountability: req.accountability }); fields: ["*", "company.*", "contact_person.*"],
const peopleService = new ItemsService("people", { schema: req.schema, accountability: req.accountability }); });
const companiesService = new ItemsService("companies", { schema: req.schema, accountability: req.accountability }); if (!lead || !lead.audit_pdf_path)
const mailService = new MailService({ schema: req.schema, accountability: req.accountability }); return res.status(400).send({ error: "PDF not generated" });
try { let recipientEmail = lead.contact_email;
const lead = await leadsService.readOne(id, { fields: ["*", "company.*", "contact_person.*"] }); let companyName = lead.company?.name || lead.company_name;
if (!lead || !lead.audit_pdf_path) return res.status(400).send({ error: "PDF not generated" });
let recipientEmail = lead.contact_email; if (lead.contact_person) {
let companyName = lead.company?.name || lead.company_name; recipientEmail = lead.contact_person.email || recipientEmail;
if (lead.contact_person) { if (lead.contact_person.company) {
recipientEmail = lead.contact_person.email || recipientEmail; const personCompany = await companiesService.readOne(
lead.contact_person.company,
if (lead.contact_person.company) { );
const personCompany = await companiesService.readOne(lead.contact_person.company); companyName = personCompany?.name || companyName;
companyName = personCompany?.name || companyName;
}
}
if (!recipientEmail) return res.status(400).send({ error: "No recipient email found" });
const html = await render(createElement(ProjectEstimateTemplate, {
companyName: companyName,
}));
const storageRoot = env.STORAGE_LOCAL_ROOT || "./storage";
const attachmentPath = path.join(storageRoot, lead.audit_pdf_path);
await mailService.send({
to: recipientEmail,
subject: `Ihre Projekt-Schätzung: ${companyName}`,
html,
attachments: [
{
filename: `Angebot_${companyName}.pdf`,
content: fs.readFileSync(attachmentPath)
}
]
});
await leadsService.updateOne(id, {
status: "contacted",
last_contacted_at: new Date().toISOString(),
});
res.send({ success: true });
} catch (error: any) {
console.error("Estimate Email failed:", error);
res.status(500).send({ error: error.message });
} }
}); }
if (!recipientEmail)
return res.status(400).send({ error: "No recipient email found" });
const html = await render(
createElement(ProjectEstimateTemplate, {
companyName: companyName,
}),
);
const storageRoot = env.STORAGE_LOCAL_ROOT || "./storage";
const attachmentPath = path.join(storageRoot, lead.audit_pdf_path);
await mailService.send({
to: recipientEmail,
subject: `Ihre Projekt-Schätzung: ${companyName}`,
html,
attachments: [
{
filename: `Angebot_${companyName}.pdf`,
content: fs.readFileSync(attachmentPath),
},
],
});
await leadsService.updateOne(id, {
status: "contacted",
last_contacted_at: new Date().toISOString(),
});
res.send({ success: true });
} catch (error: any) {
console.error("Estimate Email failed:", error);
res.status(500).send({ error: error.message });
}
});
}); });

View File

@@ -41,7 +41,7 @@ program
`), `),
); );
execSync( execSync(
"docker-compose down --remove-orphans && docker-compose up app directus directus-db", "docker compose down --remove-orphans && docker compose up -d app directus at-mintel-directus-db",
{ stdio: "inherit" }, { stdio: "inherit" },
); );
}); });

View File

@@ -12,7 +12,9 @@ const entryPoints = [
try { try {
mkdirSync(resolve(__dirname, 'dist'), { recursive: true }); mkdirSync(resolve(__dirname, 'dist'), { recursive: true });
} catch (e) { } } catch {
// ignore
}
console.log(`Building entry point...`); console.log(`Building entry point...`);

View File

@@ -3,91 +3,96 @@ import fs from "node:fs";
import path from "node:path"; import path from "node:path";
export interface AssetMap { export interface AssetMap {
[originalUrl: string]: string; [originalUrl: string]: string;
} }
export class AssetManager { export class AssetManager {
private userAgent: string; private userAgent: string;
constructor(userAgent: string = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36") { constructor(
this.userAgent = userAgent; userAgent: string = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36",
) {
this.userAgent = userAgent;
}
public sanitizePath(rawPath: string): string {
return rawPath
.split("/")
.map((p) => p.replace(/[^a-z0-9._-]/gi, "_"))
.join("/");
}
public async downloadFile(
url: string,
assetsDir: string,
): Promise<string | null> {
if (url.startsWith("//")) url = `https:${url}`;
if (!url.startsWith("http")) return null;
try {
const u = new URL(url);
const relPath = this.sanitizePath(u.hostname + u.pathname);
const dest = path.join(assetsDir, relPath);
if (fs.existsSync(dest)) return `./assets/${relPath}`;
const res = await axios.get(url, {
responseType: "arraybuffer",
headers: { "User-Agent": this.userAgent },
timeout: 15000,
validateStatus: () => true,
});
if (res.status !== 200) return null;
if (!fs.existsSync(path.dirname(dest)))
fs.mkdirSync(path.dirname(dest), { recursive: true });
fs.writeFileSync(dest, Buffer.from(res.data));
return `./assets/${relPath}`;
} catch {
return null;
} }
}
public sanitizePath(rawPath: string): string { public async processCssRecursively(
return rawPath cssContent: string,
.split("/") cssUrl: string,
.map((p) => p.replace(/[^a-z0-9._-]/gi, "_")) assetsDir: string,
.join("/"); urlMap: AssetMap,
} depth = 0,
): Promise<string> {
if (depth > 5) return cssContent;
public async downloadFile(url: string, assetsDir: string): Promise<string | null> { const urlRegex = /(?:url\(["']?|@import\s+["'])([^"')]*)["']?\)?/gi;
if (url.startsWith("//")) url = `https:${url}`; let match;
if (!url.startsWith("http")) return null; let newContent = cssContent;
try { while ((match = urlRegex.exec(cssContent)) !== null) {
const u = new URL(url); const originalUrl = match[1];
const relPath = this.sanitizePath(u.hostname + u.pathname); if (originalUrl.startsWith("data:") || originalUrl.startsWith("blob:"))
const dest = path.join(assetsDir, relPath); continue;
if (fs.existsSync(dest)) return `./assets/${relPath}`; try {
const absUrl = new URL(originalUrl, cssUrl).href;
const local = await this.downloadFile(absUrl, assetsDir);
const res = await axios.get(url, { if (local) {
responseType: "arraybuffer", const u = new URL(cssUrl);
headers: { "User-Agent": this.userAgent }, const cssPath = u.hostname + u.pathname;
timeout: 15000, const assetPath = new URL(absUrl).hostname + new URL(absUrl).pathname;
validateStatus: () => true,
});
if (res.status !== 200) return null; const rel = path.relative(
path.dirname(this.sanitizePath(cssPath)),
this.sanitizePath(assetPath),
);
if (!fs.existsSync(path.dirname(dest))) newContent = newContent.split(originalUrl).join(rel);
fs.mkdirSync(path.dirname(dest), { recursive: true }); urlMap[absUrl] = local;
fs.writeFileSync(dest, Buffer.from(res.data));
return `./assets/${relPath}`;
} catch {
return null;
} }
} catch {
// Ignore
}
} }
return newContent;
public async processCssRecursively( }
cssContent: string,
cssUrl: string,
assetsDir: string,
urlMap: AssetMap,
depth = 0,
): Promise<string> {
if (depth > 5) return cssContent;
const urlRegex = /(?:url\(["']?|@import\s+["'])([^"'\)]+)["']?\)?/gi;
let match;
let newContent = cssContent;
while ((match = urlRegex.exec(cssContent)) !== null) {
const originalUrl = match[1];
if (originalUrl.startsWith("data:") || originalUrl.startsWith("blob:"))
continue;
try {
const absUrl = new URL(originalUrl, cssUrl).href;
const local = await this.downloadFile(absUrl, assetsDir);
if (local) {
const u = new URL(cssUrl);
const cssPath = u.hostname + u.pathname;
const assetPath = new URL(absUrl).hostname + new URL(absUrl).pathname;
const rel = path.relative(
path.dirname(this.sanitizePath(cssPath)),
this.sanitizePath(assetPath),
);
newContent = newContent.split(originalUrl).join(rel);
urlMap[absUrl] = local;
}
} catch {
// Ignore
}
}
return newContent;
}
} }

View File

@@ -1,184 +1,256 @@
import { chromium, Browser, BrowserContext, Page } from "playwright"; import { chromium } from "playwright";
import fs from "node:fs"; import fs from "node:fs";
import path from "node:path"; import path from "node:path";
import axios from "axios"; import axios from "axios";
import { AssetManager, AssetMap } from "./AssetManager.js"; import { AssetManager, AssetMap } from "./AssetManager.js";
export interface PageClonerOptions { export interface PageClonerOptions {
outputDir: string; outputDir: string;
userAgent?: string; userAgent?: string;
} }
export class PageCloner { export class PageCloner {
private options: PageClonerOptions; private options: PageClonerOptions;
private assetManager: AssetManager; private assetManager: AssetManager;
private userAgent: string; private userAgent: string;
constructor(options: PageClonerOptions) { constructor(options: PageClonerOptions) {
this.options = options; this.options = options;
this.userAgent = options.userAgent || "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36"; this.userAgent =
this.assetManager = new AssetManager(this.userAgent); options.userAgent ||
} "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36";
this.assetManager = new AssetManager(this.userAgent);
}
public async clone(targetUrl: string): Promise<string> { public async clone(targetUrl: string): Promise<string> {
const urlObj = new URL(targetUrl); const urlObj = new URL(targetUrl);
const domainSlug = urlObj.hostname.replace("www.", ""); const domainSlug = urlObj.hostname.replace("www.", "");
const domainDir = path.resolve(this.options.outputDir, domainSlug); const domainDir = path.resolve(this.options.outputDir, domainSlug);
const assetsDir = path.join(domainDir, "assets"); const assetsDir = path.join(domainDir, "assets");
if (!fs.existsSync(assetsDir)) fs.mkdirSync(assetsDir, { recursive: true }); if (!fs.existsSync(assetsDir)) fs.mkdirSync(assetsDir, { recursive: true });
let pageSlug = urlObj.pathname.split("/").filter(Boolean).join("-"); let pageSlug = urlObj.pathname.split("/").filter(Boolean).join("-");
if (!pageSlug) pageSlug = "index"; if (!pageSlug) pageSlug = "index";
const htmlFilename = `${pageSlug}.html`; const htmlFilename = `${pageSlug}.html`;
console.log(`🚀 INDUSTRIAL CLONE: ${targetUrl}`); console.log(`🚀 INDUSTRIAL CLONE: ${targetUrl}`);
const browser = await chromium.launch({ headless: true }); const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({ const context = await browser.newContext({
userAgent: this.userAgent, userAgent: this.userAgent,
viewport: { width: 1920, height: 1080 }, viewport: { width: 1920, height: 1080 },
}); });
const page = await context.newPage(); const page = await context.newPage();
const urlMap: AssetMap = {}; const urlMap: AssetMap = {};
const foundAssets = new Set<string>(); const foundAssets = new Set<string>();
page.on("response", (response) => { page.on("response", (response) => {
if (response.status() === 200) { if (response.status() === 200) {
const url = response.url(); const url = response.url();
if (url.match(/\.(css|js|png|jpg|jpeg|gif|svg|woff2?|ttf|otf|mp4|webm|webp|ico)/i)) { if (
foundAssets.add(url); url.match(
} /\.(css|js|png|jpg|jpeg|gif|svg|woff2?|ttf|otf|mp4|webm|webp|ico)/i,
} )
}); ) {
foundAssets.add(url);
try {
await page.goto(targetUrl, { waitUntil: "networkidle", timeout: 90000 });
// Scroll Wave
await page.evaluate(async () => {
await new Promise((resolve) => {
let totalHeight = 0;
const distance = 400;
const timer = setInterval(() => {
const scrollHeight = document.body.scrollHeight;
window.scrollBy(0, distance);
totalHeight += distance;
if (totalHeight >= scrollHeight) {
clearInterval(timer);
window.scrollTo(0, 0);
resolve(true);
}
}, 100);
});
});
const fullHeight = await page.evaluate(() => document.body.scrollHeight);
await page.setViewportSize({ width: 1920, height: fullHeight + 1000 });
await page.waitForTimeout(3000);
// Sanitization
await page.evaluate(() => {
const assetPattern = /\.(jpg|jpeg|png|gif|svg|webp|mp4|webm|woff2?|ttf|otf)/i;
document.querySelectorAll("*").forEach((el) => {
if (["META", "LINK", "HEAD", "SCRIPT", "STYLE", "SVG", "PATH"].includes(el.tagName)) return;
const htmlEl = el as HTMLElement;
const style = window.getComputedStyle(htmlEl);
if (style.opacity === "0" || style.visibility === "hidden") {
htmlEl.style.setProperty("opacity", "1", "important");
htmlEl.style.setProperty("visibility", "visible", "important");
}
for (const attr of Array.from(el.attributes)) {
const name = attr.name.toLowerCase();
const val = attr.value;
if (assetPattern.test(val) || name.includes("src") || name.includes("image")) {
if (el.tagName === "IMG") {
const img = el as HTMLImageElement;
if (name.includes("srcset")) img.srcset = val;
else if (!img.src || img.src.includes("data:")) img.src = val;
}
if (el.tagName === "SOURCE") (el as HTMLSourceElement).srcset = val;
if (el.tagName === "VIDEO" || el.tagName === "AUDIO") (el as HTMLMediaElement).src = val;
if (val.match(/^(https?:\/\/|\/\/|\/)/) && !name.includes("href")) {
const bg = htmlEl.style.backgroundImage;
if (!bg || bg === "none") htmlEl.style.backgroundImage = `url('${val}')`;
}
}
}
});
if (document.body) {
document.body.style.setProperty("opacity", "1", "important");
document.body.style.setProperty("visibility", "visible", "important");
}
});
await page.waitForLoadState("networkidle");
await page.waitForTimeout(1000);
let content = await page.content();
const regexPatterns = [
/(?:src|href|url|data-[a-z-]+|srcset)=["']([^"'<>\s]+?\.(?:css|js|png|jpg|jpeg|gif|svg|woff2?|ttf|otf|mp4|webm|webp|ico)(?:\?[^"']*)?)["']/gi,
/url\(["']?([^"'\)]+)["']?\)/gi,
];
for (const pattern of regexPatterns) {
let match;
while ((match = pattern.exec(content)) !== null) {
try { foundAssets.add(new URL(match[1], targetUrl).href); } catch { }
}
}
for (const url of foundAssets) {
const local = await this.assetManager.downloadFile(url, assetsDir);
if (local) {
urlMap[url] = local;
const clean = url.split("?")[0];
urlMap[clean] = local;
if (clean.endsWith(".css")) {
try {
const { data } = await axios.get(url, { headers: { "User-Agent": this.userAgent } });
const processedCss = await this.assetManager.processCssRecursively(data, url, assetsDir, urlMap);
const relPath = this.assetManager.sanitizePath(new URL(url).hostname + new URL(url).pathname);
fs.writeFileSync(path.join(assetsDir, relPath), processedCss);
} catch { }
}
}
}
let finalContent = content;
const sortedUrls = Object.keys(urlMap).sort((a, b) => b.length - a.length);
if (sortedUrls.length > 0) {
const escaped = sortedUrls.map((u) => u.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
const masterRegex = new RegExp(`(${escaped.join("|")})`, "g");
finalContent = finalContent.replace(masterRegex, (match) => urlMap[match] || match);
}
const commonDirs = ["/wp-content/", "/wp-includes/", "/assets/", "/static/", "/images/"];
for (const dir of commonDirs) {
const localDir = `./assets/${urlObj.hostname}${dir}`;
finalContent = finalContent.split(`"${dir}`).join(`"${localDir}`).split(`'${dir}`).join(`'${localDir}`).split(`(${dir}`).join(`(${localDir}`);
}
const domainPattern = new RegExp(`https?://(www\\.)?${urlObj.hostname.replace(/\./g, "\\.")}[^"']*`, "gi");
finalContent = finalContent.replace(domainPattern, () => "./");
finalContent = finalContent.replace(/<script\b[^>]*>([\s\S]*?)<\/script>/gi, (match, scriptContent) => {
const lower = scriptContent.toLowerCase();
return (lower.includes("google-analytics") || lower.includes("gtag") || lower.includes("fbq") || lower.includes("lazy") || lower.includes("tracker")) ? "" : match;
});
const headEnd = finalContent.indexOf("</head>");
if (headEnd > -1) {
const stabilityCss = `\n<style>* { transition: none !important; animation: none !important; scroll-behavior: auto !important; } [data-aos], .reveal, .lazypath, .lazy-load, [data-src] { opacity: 1 !important; visibility: visible !important; transform: none !important; clip-path: none !important; } img, video, iframe { max-width: 100%; display: block; } a { pointer-events: none; cursor: default; } </style>`;
finalContent = finalContent.slice(0, headEnd) + stabilityCss + finalContent.slice(headEnd);
}
const finalPath = path.join(domainDir, htmlFilename);
fs.writeFileSync(finalPath, finalContent);
return finalPath;
} finally {
await browser.close();
} }
}
});
try {
await page.goto(targetUrl, { waitUntil: "networkidle", timeout: 90000 });
// Scroll Wave
await page.evaluate(async () => {
await new Promise((resolve) => {
let totalHeight = 0;
const distance = 400;
const timer = setInterval(() => {
const scrollHeight = document.body.scrollHeight;
window.scrollBy(0, distance);
totalHeight += distance;
if (totalHeight >= scrollHeight) {
clearInterval(timer);
window.scrollTo(0, 0);
resolve(true);
}
}, 100);
});
});
const fullHeight = await page.evaluate(() => document.body.scrollHeight);
await page.setViewportSize({ width: 1920, height: fullHeight + 1000 });
await page.waitForTimeout(3000);
// Sanitization
await page.evaluate(() => {
const assetPattern =
/\.(jpg|jpeg|png|gif|svg|webp|mp4|webm|woff2?|ttf|otf)/i;
document.querySelectorAll("*").forEach((el) => {
if (
["META", "LINK", "HEAD", "SCRIPT", "STYLE", "SVG", "PATH"].includes(
el.tagName,
)
)
return;
const htmlEl = el as HTMLElement;
const style = window.getComputedStyle(htmlEl);
if (style.opacity === "0" || style.visibility === "hidden") {
htmlEl.style.setProperty("opacity", "1", "important");
htmlEl.style.setProperty("visibility", "visible", "important");
}
for (const attr of Array.from(el.attributes)) {
const name = attr.name.toLowerCase();
const val = attr.value;
if (
assetPattern.test(val) ||
name.includes("src") ||
name.includes("image")
) {
if (el.tagName === "IMG") {
const img = el as HTMLImageElement;
if (name.includes("srcset")) img.srcset = val;
else if (!img.src || img.src.includes("data:")) img.src = val;
}
if (el.tagName === "SOURCE")
(el as HTMLSourceElement).srcset = val;
if (el.tagName === "VIDEO" || el.tagName === "AUDIO")
(el as HTMLMediaElement).src = val;
if (
val.match(/^(https?:\/\/|\/\/|\/)/) &&
!name.includes("href")
) {
const bg = htmlEl.style.backgroundImage;
if (!bg || bg === "none")
htmlEl.style.backgroundImage = `url('${val}')`;
}
}
}
});
if (document.body) {
document.body.style.setProperty("opacity", "1", "important");
document.body.style.setProperty("visibility", "visible", "important");
}
});
await page.waitForLoadState("networkidle");
await page.waitForTimeout(1000);
const content = await page.content();
const regexPatterns = [
/(?:src|href|url|data-[a-z-]+|srcset)=["']([^"'<>\s]+?\.(?:css|js|png|jpg|jpeg|gif|svg|woff2?|ttf|otf|mp4|webm|webp|ico)(?:\?[^"']*)?)["']/gi,
/url\(["']?([^"')]*)["']?\)/gi,
];
for (const pattern of regexPatterns) {
let match;
while ((match = pattern.exec(content)) !== null) {
try {
foundAssets.add(new URL(match[1], targetUrl).href);
} catch {
// Ignore invalid URLs
}
}
}
for (const url of foundAssets) {
const local = await this.assetManager.downloadFile(url, assetsDir);
if (local) {
urlMap[url] = local;
const clean = url.split("?")[0];
urlMap[clean] = local;
if (clean.endsWith(".css")) {
try {
const { data } = await axios.get(url, {
headers: { "User-Agent": this.userAgent },
});
const processedCss =
await this.assetManager.processCssRecursively(
data,
url,
assetsDir,
urlMap,
);
const relPath = this.assetManager.sanitizePath(
new URL(url).hostname + new URL(url).pathname,
);
fs.writeFileSync(path.join(assetsDir, relPath), processedCss);
} catch {
// Ignore stylesheet download/process failures
}
}
}
}
let finalContent = content;
const sortedUrls = Object.keys(urlMap).sort(
(a, b) => b.length - a.length,
);
if (sortedUrls.length > 0) {
const escaped = sortedUrls.map((u) =>
u.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"),
);
const masterRegex = new RegExp(`(${escaped.join("|")})`, "g");
finalContent = finalContent.replace(
masterRegex,
(match) => urlMap[match] || match,
);
}
const commonDirs = [
"/wp-content/",
"/wp-includes/",
"/assets/",
"/static/",
"/images/",
];
for (const dir of commonDirs) {
const localDir = `./assets/${urlObj.hostname}${dir}`;
finalContent = finalContent
.split(`"${dir}`)
.join(`"${localDir}`)
.split(`'${dir}`)
.join(`'${localDir}`)
.split(`(${dir}`)
.join(`(${localDir}`);
}
const domainPattern = new RegExp(
`https?://(www\\.)?${urlObj.hostname.replace(/\./g, "\\.")}[^"']*`,
"gi",
);
finalContent = finalContent.replace(domainPattern, () => "./");
finalContent = finalContent.replace(
/<script\b[^>]*>([\s\S]*?)<\/script>/gi,
(match, scriptContent) => {
const lower = scriptContent.toLowerCase();
return lower.includes("google-analytics") ||
lower.includes("gtag") ||
lower.includes("fbq") ||
lower.includes("lazy") ||
lower.includes("tracker")
? ""
: match;
},
);
const headEnd = finalContent.indexOf("</head>");
if (headEnd > -1) {
const stabilityCss = `\n<style>* { transition: none !important; animation: none !important; scroll-behavior: auto !important; } [data-aos], .reveal, .lazypath, .lazy-load, [data-src] { opacity: 1 !important; visibility: visible !important; transform: none !important; clip-path: none !important; } img, video, iframe { max-width: 100%; display: block; } a { pointer-events: none; cursor: default; } </style>`;
finalContent =
finalContent.slice(0, headEnd) +
stabilityCss +
finalContent.slice(headEnd);
}
const finalPath = path.join(domainDir, htmlFilename);
fs.writeFileSync(finalPath, finalContent);
return finalPath;
} finally {
await browser.close();
} }
}
} }

View File

@@ -1,123 +1,150 @@
import { PlaywrightCrawler, RequestQueue } from 'crawlee'; import { PlaywrightCrawler, RequestQueue } from "crawlee";
import * as path from 'node:path'; import * as path from "node:path";
import * as fs from 'node:fs'; import * as fs from "node:fs";
import { execSync } from 'node:child_process'; import { execSync } from "node:child_process";
export interface WebsiteClonerOptions { export interface WebsiteClonerOptions {
baseOutputDir: string; baseOutputDir: string;
maxRequestsPerCrawl?: number; maxRequestsPerCrawl?: number;
maxConcurrency?: number; maxConcurrency?: number;
} }
export class WebsiteCloner { export class WebsiteCloner {
private options: WebsiteClonerOptions; private options: WebsiteClonerOptions;
constructor(options: WebsiteClonerOptions) { constructor(options: WebsiteClonerOptions) {
this.options = { this.options = {
maxRequestsPerCrawl: 100, maxRequestsPerCrawl: 100,
maxConcurrency: 3, maxConcurrency: 3,
...options ...options,
}; };
}
public async clone(
targetUrl: string,
outputDirName?: string,
): Promise<string> {
const urlObj = new URL(targetUrl);
const domain = urlObj.hostname;
const finalOutputDirName = outputDirName || domain.replace(/\./g, "-");
const baseOutputDir = path.resolve(
this.options.baseOutputDir,
finalOutputDirName,
);
if (fs.existsSync(baseOutputDir)) {
fs.rmSync(baseOutputDir, { recursive: true, force: true });
} }
fs.mkdirSync(baseOutputDir, { recursive: true });
public async clone(targetUrl: string, outputDirName?: string): Promise<string> { console.log(`🚀 Starting perfect recursive clone of ${targetUrl}...`);
const urlObj = new URL(targetUrl); console.log(`📂 Output: ${baseOutputDir}`);
const domain = urlObj.hostname;
const finalOutputDirName = outputDirName || domain.replace(/\./g, '-');
const baseOutputDir = path.resolve(this.options.baseOutputDir, finalOutputDirName);
if (fs.existsSync(baseOutputDir)) { const requestQueue = await RequestQueue.open();
fs.rmSync(baseOutputDir, { recursive: true, force: true }); await requestQueue.addRequest({ url: targetUrl });
}
fs.mkdirSync(baseOutputDir, { recursive: true });
console.log(`🚀 Starting perfect recursive clone of ${targetUrl}...`); const crawler = new PlaywrightCrawler({
console.log(`📂 Output: ${baseOutputDir}`); requestQueue,
maxRequestsPerCrawl: this.options.maxRequestsPerCrawl,
maxConcurrency: this.options.maxConcurrency,
const requestQueue = await RequestQueue.open(); async requestHandler({ request, enqueueLinks, log }) {
await requestQueue.addRequest({ url: targetUrl }); const url = request.url;
log.info(`Capturing ${url}...`);
const crawler = new PlaywrightCrawler({ const u = new URL(url);
requestQueue, let relPath = u.pathname;
maxRequestsPerCrawl: this.options.maxRequestsPerCrawl, if (relPath === "/" || relPath === "") relPath = "/index.html";
maxConcurrency: this.options.maxConcurrency, if (!relPath.endsWith(".html") && !path.extname(relPath))
relPath += "/index.html";
if (relPath.startsWith("/")) relPath = relPath.substring(1);
async requestHandler({ request, enqueueLinks, log }) { const fullPath = path.join(baseOutputDir, relPath);
const url = request.url; fs.mkdirSync(path.dirname(fullPath), { recursive: true });
log.info(`Capturing ${url}...`);
const u = new URL(url); try {
let relPath = u.pathname; // Note: This assumes single-file-cli is available in the environment
if (relPath === '/' || relPath === '') relPath = '/index.html'; execSync(
if (!relPath.endsWith('.html') && !path.extname(relPath)) relPath += '/index.html'; `npx single-file-cli "${url}" "${fullPath}" --browser-headless=true --browser-wait-until=networkidle0`,
if (relPath.startsWith('/')) relPath = relPath.substring(1); {
stdio: "inherit",
const fullPath = path.join(baseOutputDir, relPath);
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
try {
// Note: This assumes single-file-cli is available in the environment
execSync(`npx single-file-cli "${url}" "${fullPath}" --browser-headless=true --browser-wait-until=networkidle0`, {
stdio: 'inherit'
});
} catch (e) {
log.error(`Failed to capture ${url} with SingleFile`);
}
await enqueueLinks({
strategy: 'same-domain',
transformRequestFunction: (req) => {
if (/\.(download|pdf|zip|gz|exe|png|jpg|jpeg|gif|svg|css|js)$/i.test(req.url)) return false;
return req;
}
});
}, },
);
} catch (_e) {
log.error(`Failed to capture ${url} with SingleFile`);
}
await enqueueLinks({
strategy: "same-domain",
transformRequestFunction: (req) => {
if (
/\.(download|pdf|zip|gz|exe|png|jpg|jpeg|gif|svg|css|js)$/i.test(
req.url,
)
)
return false;
return req;
},
}); });
},
});
await crawler.run(); await crawler.run();
console.log('🔗 Rewriting internal links for offline navigation...'); console.log("🔗 Rewriting internal links for offline navigation...");
const allFiles = this.getFiles(baseOutputDir).filter(f => f.endsWith('.html')); const allFiles = this.getFiles(baseOutputDir).filter((f) =>
f.endsWith(".html"),
);
for (const file of allFiles) { for (const file of allFiles) {
let content = fs.readFileSync(file, 'utf8'); let content = fs.readFileSync(file, "utf8");
const fileRelToRoot = path.relative(baseOutputDir, file); const fileRelToRoot = path.relative(baseOutputDir, file);
content = content.replace(/href="([^"]+)"/g, (match, href) => { content = content.replace(/href="([^"]+)"/g, (match, href) => {
if (href.startsWith(targetUrl) || href.startsWith('/') || (!href.includes('://') && !href.startsWith('data:'))) { if (
try { href.startsWith(targetUrl) ||
const linkUrl = new URL(href, targetUrl); href.startsWith("/") ||
if (linkUrl.hostname === domain) { (!href.includes("://") && !href.startsWith("data:"))
let linkPath = linkUrl.pathname; ) {
if (linkPath === '/' || linkPath === '') linkPath = '/index.html'; try {
if (!linkPath.endsWith('.html') && !path.extname(linkPath)) linkPath += '/index.html'; const linkUrl = new URL(href, targetUrl);
if (linkPath.startsWith('/')) linkPath = linkPath.substring(1); if (linkUrl.hostname === domain) {
let linkPath = linkUrl.pathname;
if (linkPath === "/" || linkPath === "") linkPath = "/index.html";
if (!linkPath.endsWith(".html") && !path.extname(linkPath))
linkPath += "/index.html";
if (linkPath.startsWith("/")) linkPath = linkPath.substring(1);
const relativeLink = path.relative(path.dirname(fileRelToRoot), linkPath); const relativeLink = path.relative(
return `href="${relativeLink}"`; path.dirname(fileRelToRoot),
} linkPath,
} catch (e) { } );
} return `href="${relativeLink}"`;
return match;
});
fs.writeFileSync(file, content);
}
console.log(`\n✅ Done! Perfect clone complete in: ${baseOutputDir}`);
return baseOutputDir;
}
private getFiles(dir: string, fileList: string[] = []) {
const files = fs.readdirSync(dir);
for (const file of files) {
const name = path.join(dir, file);
if (fs.statSync(name).isDirectory()) {
this.getFiles(name, fileList);
} else {
fileList.push(name);
} }
} catch (_e) {
// Ignore link rewriting failures
}
} }
return fileList; return match;
});
fs.writeFileSync(file, content);
} }
console.log(`\n✅ Done! Perfect clone complete in: ${baseOutputDir}`);
return baseOutputDir;
}
private getFiles(dir: string, fileList: string[] = []) {
const files = fs.readdirSync(dir);
for (const file of files) {
const name = path.join(dir, file);
if (fs.statSync(name).isDirectory()) {
this.getFiles(name, fileList);
} else {
fileList.push(name);
}
}
return fileList;
}
} }

Binary file not shown.

View File

@@ -39,9 +39,9 @@ services:
labels: labels:
- "traefik.enable=true" - "traefik.enable=true"
- "traefik.http.routers.at-mintel-infra-cms.rule=Host(`cms.localhost`)" - "traefik.http.routers.at-mintel-infra-cms.rule=Host(`cms.localhost`)"
- "traefik.http.services.at-mintel-infra-cms.loadbalancer.server.port=8055"
- "traefik.http.services.at-mintel-infra-cms.loadbalancer.healthcheck.path=/server/health"
- "traefik.docker.network=infra" - "traefik.docker.network=infra"
- "caddy=cms.localhost"
- "caddy.reverse_proxy={{upstreams 8055}}"
networks: networks:
default: default:

View File

@@ -4,7 +4,8 @@
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
"up": "npm run build:extensions && docker compose up -d", "dev": "npm run up -- --link",
"up": "../../scripts/cms-up.sh",
"down": "docker compose down", "down": "docker compose down",
"logs": "docker compose logs -f", "logs": "docker compose logs -f",
"build:extensions": "../../scripts/sync-extensions.sh", "build:extensions": "../../scripts/sync-extensions.sh",
@@ -14,4 +15,4 @@
"sync:push": "../../scripts/sync-directus.sh push infra", "sync:push": "../../scripts/sync-directus.sh push infra",
"sync:pull": "../../scripts/sync-directus.sh pull infra" "sync:pull": "../../scripts/sync-directus.sh pull infra"
} }
} }

View File

@@ -20,9 +20,11 @@
"build": "directus-extension build", "build": "directus-extension build",
"dev": "directus-extension build -w" "dev": "directus-extension build -w"
}, },
"dependencies": {
"@mintel/directus-extension-toolkit": "workspace:*"
},
"devDependencies": { "devDependencies": {
"@directus/extensions-sdk": "11.0.2", "@directus/extensions-sdk": "11.0.2",
"@mintel/directus-extension-toolkit": "workspace:*",
"vue": "^3.4.0" "vue": "^3.4.0"
} }
} }

View File

@@ -104,9 +104,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue'; import { ref, onMounted } from 'vue';
import { useApi } from '@directus/extensions-sdk'; import { useApi } from '@directus/extensions-sdk';
import { useRoute } from 'vue-router';
import { MintelManagerLayout } from '@mintel/directus-extension-toolkit'; import { MintelManagerLayout } from '@mintel/directus-extension-toolkit';
const api = useApi(); const api = useApi();
const route = useRoute();
const companies = ref([]); const companies = ref([]);
const selectedCompany = ref(null); const selectedCompany = ref(null);
const feedback = ref(null); const feedback = ref(null);
@@ -201,7 +203,12 @@ async function deleteCompany() {
} }
} }
onMounted(fetchData); onMounted(async () => {
await fetchData();
if (route.query.create === 'true') {
openCreateDrawer();
}
});
</script> </script>
<style scoped> <style scoped>

View File

@@ -20,9 +20,11 @@
"build": "directus-extension build", "build": "directus-extension build",
"dev": "directus-extension build -w" "dev": "directus-extension build -w"
}, },
"dependencies": {
"@mintel/directus-extension-toolkit": "workspace:*"
},
"devDependencies": { "devDependencies": {
"@directus/extensions-sdk": "11.0.2", "@directus/extensions-sdk": "11.0.2",
"@mintel/directus-extension-toolkit": "workspace:*",
"vue": "^3.4.0" "vue": "^3.4.0"
} }
} }

View File

@@ -193,15 +193,69 @@
</div> </div>
</div> </div>
</v-drawer> </v-drawer>
<!-- Drawer: Quick Add Company -->
<v-drawer
v-model="quickAddCompanyActive"
title="Firma schnell anlegen"
icon="business"
@cancel="quickAddCompanyActive = false"
>
<div class="drawer-content">
<div class="form-section">
<div class="field">
<span class="label">Firmenname</span>
<v-input v-model="quickCompanyForm.name" placeholder="z.B. Schmidt GmbH" autofocus />
</div>
<div class="field">
<span class="label">Domain / Website</span>
<v-input v-model="quickCompanyForm.domain" placeholder="example.com" />
</div>
</div>
<div class="drawer-actions">
<v-button primary block :loading="savingQuick" @click="saveQuickCompany">Firma speichern</v-button>
</div>
</div>
</v-drawer>
<!-- Drawer: Quick Add Person -->
<v-drawer
v-model="quickAddPersonActive"
title="Person schnell anlegen"
icon="person"
@cancel="quickAddPersonActive = false"
>
<div class="drawer-content">
<div class="form-section">
<div class="field">
<span class="label">Vorname</span>
<v-input v-model="quickPersonForm.first_name" placeholder="Vorname" autofocus />
</div>
<div class="field">
<span class="label">Nachname</span>
<v-input v-model="quickPersonForm.last_name" placeholder="Nachname" />
</div>
<div class="field">
<span class="label">E-Mail</span>
<v-input v-model="quickPersonForm.email" placeholder="email@example.com" type="email" />
</div>
</div>
<div class="drawer-actions">
<v-button primary block :loading="savingQuick" @click="saveQuickPerson">Person speichern</v-button>
</div>
</div>
</v-drawer>
</MintelManagerLayout> </MintelManagerLayout>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, nextTick, computed } from 'vue'; import { ref, onMounted, nextTick, computed, watch } from 'vue';
import { useApi } from '@directus/extensions-sdk'; import { useApi } from '@directus/extensions-sdk';
import { useRoute } from 'vue-router';
import { MintelManagerLayout, MintelSelect } from '@mintel/directus-extension-toolkit'; import { MintelManagerLayout, MintelSelect } from '@mintel/directus-extension-toolkit';
const api = useApi(); const api = useApi();
const route = useRoute();
const items = ref<any[]>([]); const items = ref<any[]>([]);
const selectedItem = ref<any>(null); const selectedItem = ref<any>(null);
@@ -222,6 +276,12 @@ const drawerUserActive = ref(false);
const isEditingUser = ref(false); const isEditingUser = ref(false);
const userForm = ref({ id: '', first_name: '', last_name: '', email: '', person: null, temporary_password: '' }); const userForm = ref({ id: '', first_name: '', last_name: '', email: '', person: null, temporary_password: '' });
const quickAddCompanyActive = ref(false);
const quickAddPersonActive = ref(false);
const savingQuick = ref(false);
const quickCompanyForm = ref({ name: '', domain: '' });
const quickPersonForm = ref({ first_name: '', last_name: '', email: '' });
const tableHeaders = [ const tableHeaders = [
{ text: 'Name', value: 'name', sortable: true }, { text: 'Name', value: 'name', sortable: true },
{ text: 'E-Mail', value: 'email', sortable: true }, { text: 'E-Mail', value: 'email', sortable: true },
@@ -284,7 +344,10 @@ function openEditDrawer() {
} }
async function saveItem() { async function saveItem() {
if (!form.value.company) return; if (!form.value.company) {
notice.value = { type: 'danger', message: 'Bitte wählen Sie eine Firma aus.' };
return;
}
saving.value = true; saving.value = true;
try { try {
if (isEditing.value) { if (isEditing.value) {
@@ -301,7 +364,10 @@ async function saveItem() {
if (updated) selectItem(updated); if (updated) selectItem(updated);
} }
} catch (e: any) { } catch (e: any) {
notice.value = { type: 'danger', message: e.message }; notice.value = {
type: 'danger',
message: e.response?.data?.errors?.[0]?.message || e.message || 'Speichern fehlgeschlagen'
};
} finally { } finally {
saving.value = false; saving.value = false;
} }
@@ -365,9 +431,45 @@ async function inviteUser(user: any) {
} }
function openQuickAdd(type: 'company' | 'person') { function openQuickAdd(type: 'company' | 'person') {
// Quick add logic can involve opening another drawer or navigating if (type === 'company') {
// For now, we'll just show a notice quickCompanyForm.value = { name: '', domain: '' };
notice.value = { type: 'info', message: `${type === 'company' ? 'Firma' : 'Person'} im jeweiligen Manager anlegen.` }; quickAddCompanyActive.value = true;
} else {
quickPersonForm.value = { first_name: '', last_name: '', email: '' };
quickAddPersonActive.value = true;
}
}
async function saveQuickCompany() {
if (!quickCompanyForm.value.name) return;
savingQuick.value = true;
try {
const res = await api.post('/items/companies', quickCompanyForm.value);
await fetchData();
form.value.company = res.data.data.id;
quickAddCompanyActive.value = false;
notice.value = { type: 'success', message: 'Firma angelegt und ausgewählt.' };
} catch (e: any) {
notice.value = { type: 'danger', message: e.message };
} finally {
savingQuick.value = false;
}
}
async function saveQuickPerson() {
if (!quickPersonForm.value.first_name || !quickPersonForm.value.last_name) return;
savingQuick.value = true;
try {
const res = await api.post('/items/people', quickPersonForm.value);
await fetchData();
form.value.contact_person = res.data.data.id;
quickAddPersonActive.value = false;
notice.value = { type: 'success', message: 'Person angelegt und ausgewählt.' };
} catch (e: any) {
notice.value = { type: 'danger', message: e.message };
} finally {
savingQuick.value = false;
}
} }
function formatDate(dateStr: string) { function formatDate(dateStr: string) {
@@ -377,7 +479,30 @@ function formatDate(dateStr: string) {
}); });
} }
onMounted(fetchData); function handleDeepLink() {
if (route.query.create === 'true') {
// Only open if not already open to avoid resetting form if user is already typing
if (!drawerActive.value) {
openCreateDrawer();
}
if (route.query.company) {
form.value.company = route.query.company as any;
}
if (route.query.contact_person) {
form.value.contact_person = route.query.contact_person as any;
}
}
}
watch(() => route.query.create, (newVal) => {
if (newVal === 'true') handleDeepLink();
});
onMounted(async () => {
await fetchData();
handleDeepLink();
});
</script> </script>
<style scoped> <style scoped>

View File

@@ -2,40 +2,37 @@ import nextPlugin from "@next/eslint-plugin-next";
import reactPlugin from "eslint-plugin-react"; import reactPlugin from "eslint-plugin-react";
import hooksPlugin from "eslint-plugin-react-hooks"; import hooksPlugin from "eslint-plugin-react-hooks";
import tseslint from "typescript-eslint"; import tseslint from "typescript-eslint";
import js from "@eslint/js";
/** /**
* Mintel Next.js ESLint Configuration (Flat Config) * Mintel Next.js ESLint Configuration (Flat Config)
* *
* This configuration replaces the legacy 'eslint-config-next' which * This configuration replaces the legacy 'eslint-config-next' which
* relies on @rushstack/eslint-patch and causes issues in ESLint 9. * relies on @rushstack/eslint-patch and causes issues in ESLint 9.
*/ */
export const nextConfig = tseslint.config( export const nextConfig = tseslint.config({
{ plugins: {
plugins: { react: reactPlugin,
"react": reactPlugin, "react-hooks": hooksPlugin,
"react-hooks": hooksPlugin, "@next/next": nextPlugin,
"@next/next": nextPlugin, },
languageOptions: {
globals: {
// Add common browser/node globals if needed,
// though usually handled by base configs
}, },
languageOptions: { },
globals: { rules: {
// Add common browser/node globals if needed, ...reactPlugin.configs.recommended.rules,
// though usually handled by base configs ...hooksPlugin.configs.recommended.rules,
}, ...nextPlugin.configs.recommended.rules,
...nextPlugin.configs["core-web-vitals"].rules,
"react/react-in-jsx-scope": "off",
"react/no-unescaped-entities": "off",
"@next/next/no-img-element": "warn",
},
settings: {
react: {
version: "detect",
}, },
rules: { },
...reactPlugin.configs.recommended.rules, });
...hooksPlugin.configs.recommended.rules,
...nextPlugin.configs.recommended.rules,
...nextPlugin.configs["core-web-vitals"].rules,
"react/react-in-jsx-scope": "off",
"react/no-unescaped-entities": "off",
"@next/next/no-img-element": "warn",
},
settings: {
react: {
version: "detect",
},
},
}
);

View File

@@ -24,4 +24,4 @@
"@directus/extensions-sdk": "11.0.2", "@directus/extensions-sdk": "11.0.2",
"vue": "^3.4.0" "vue": "^3.4.0"
} }
} }

View File

@@ -1,4 +1,3 @@
/* global module */
module.exports = { module.exports = {
plugins: { plugins: {
tailwindcss: {}, tailwindcss: {},

View File

@@ -1,4 +1,3 @@
import path from "node:path";
/** @type {import('tailwindcss').Config} */ /** @type {import('tailwindcss').Config} */
module.exports = { module.exports = {
content: [ content: [

View File

@@ -1,4 +1,3 @@
/* global process */
import path from "node:path"; import path from "node:path";
const buildLintCommand = (filenames) => { const buildLintCommand = (filenames) => {

View File

@@ -35,9 +35,9 @@ DB_USER="directus"
DB_NAME="directus" DB_NAME="directus"
echo "🔍 Detecting local database..." echo "🔍 Detecting local database..."
LOCAL_DB_CONTAINER=$(docker compose ps -q directus-db) LOCAL_DB_CONTAINER=$(docker compose ps -q at-mintel-directus-db)
if [ -z "$LOCAL_DB_CONTAINER" ]; then if [ -z "$LOCAL_DB_CONTAINER" ]; then
echo "❌ Local directus-db container not found. Running?" echo "❌ Local at-mintel-directus-db container not found. Running?"
exit 1 exit 1
fi fi

View File

@@ -20,9 +20,11 @@
"build": "directus-extension build", "build": "directus-extension build",
"dev": "directus-extension build -w" "dev": "directus-extension build -w"
}, },
"dependencies": {
"@mintel/directus-extension-toolkit": "workspace:*"
},
"devDependencies": { "devDependencies": {
"@directus/extensions-sdk": "11.0.2", "@directus/extensions-sdk": "11.0.2",
"@mintel/directus-extension-toolkit": "workspace:*",
"vue": "^3.4.0" "vue": "^3.4.0"
} }
} }

View File

@@ -126,9 +126,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, computed, nextTick } from 'vue'; import { ref, onMounted, computed, nextTick } from 'vue';
import { useApi } from '@directus/extensions-sdk'; import { useApi } from '@directus/extensions-sdk';
import { useRoute } from 'vue-router';
import { MintelManagerLayout, MintelSelect } from '@mintel/directus-extension-toolkit'; import { MintelManagerLayout, MintelSelect } from '@mintel/directus-extension-toolkit';
const api = useApi(); const api = useApi();
const route = useRoute();
const people = ref([]); const people = ref([]);
const companies = ref([]); const companies = ref([]);
const selectedPerson = ref(null); const selectedPerson = ref(null);
@@ -256,7 +258,12 @@ function openQuickAdd(type: string) {
feedback.value = { type: 'info', message: `Firma im Company Manager anlegen.` }; feedback.value = { type: 'info', message: `Firma im Company Manager anlegen.` };
} }
onMounted(fetchData); onMounted(async () => {
await fetchData();
if (route.query.create === 'true') {
openCreateDrawer();
}
});
</script> </script>
<style scoped> <style scoped>

View File

@@ -24,4 +24,4 @@
"@directus/extensions-sdk": "11.0.2", "@directus/extensions-sdk": "11.0.2",
"vue": "^3.4.0" "vue": "^3.4.0"
} }
} }

View File

@@ -7,7 +7,7 @@
</header> </header>
<div class="stats-grid"> <div class="stats-grid">
<div class="stat-card" @click="navigateTo('/company-manager')"> <div class="stat-card" @click="navigateTo('company-manager')">
<div class="stat-icon"><v-icon name="business" large /></div> <div class="stat-icon"><v-icon name="business" large /></div>
<div class="stat-content"> <div class="stat-content">
<span class="stat-label">Firmen</span> <span class="stat-label">Firmen</span>
@@ -16,7 +16,7 @@
<v-icon name="chevron_right" class="arrow" /> <v-icon name="chevron_right" class="arrow" />
</div> </div>
<div class="stat-card" @click="navigateTo('/people-manager')"> <div class="stat-card" @click="navigateTo('people-manager')">
<div class="stat-icon"><v-icon name="person" large /></div> <div class="stat-icon"><v-icon name="person" large /></div>
<div class="stat-content"> <div class="stat-content">
<span class="stat-label">Personen</span> <span class="stat-label">Personen</span>
@@ -25,7 +25,7 @@
<v-icon name="chevron_right" class="arrow" /> <v-icon name="chevron_right" class="arrow" />
</div> </div>
<div class="stat-card" @click="navigateTo('/acquisition-manager')"> <div class="stat-card" @click="navigateTo('acquisition-manager')">
<div class="stat-icon"><v-icon name="auto_awesome" large /></div> <div class="stat-icon"><v-icon name="auto_awesome" large /></div>
<div class="stat-content"> <div class="stat-content">
<span class="stat-label">Leads</span> <span class="stat-label">Leads</span>
@@ -38,14 +38,18 @@
<div class="recent-activity"> <div class="recent-activity">
<h2 class="section-title">Schnellzugriff</h2> <h2 class="section-title">Schnellzugriff</h2>
<div class="action-grid"> <div class="action-grid">
<v-button secondary block @click="navigateTo('/people-manager?create=true')"> <v-button secondary block @click="navigateTo('people-manager', { create: 'true' })">
<v-icon name="person_add" left /> <v-icon name="person_add" left />
Neue Person anlegen Neue Person anlegen
</v-button> </v-button>
<v-button secondary block @click="navigateTo('/acquisition-manager?create=true')"> <v-button secondary block @click="navigateTo('acquisition-manager', { create: 'true' })">
<v-icon name="add_link" left /> <v-icon name="add_link" left />
Neuen Lead registrieren Neuen Lead registrieren
</v-button> </v-button>
<v-button secondary block @click="navigateTo('customer-manager', { create: 'true' })">
<v-icon name="handshake" left />
Kunden verlinken
</v-button>
</div> </div>
</div> </div>
</div> </div>
@@ -83,8 +87,8 @@ async function fetchStats() {
} }
} }
function navigateTo(path: string) { function navigateTo(id: string, query?: any) {
router.push(path); router.push({ name: `module-${id}`, query });
} }
onMounted(fetchStats); onMounted(fetchStats);

148
pnpm-lock.yaml generated
View File

@@ -164,7 +164,7 @@ importers:
devDependencies: devDependencies:
'@directus/extensions-sdk': '@directus/extensions-sdk':
specifier: 11.0.2 specifier: 11.0.2
version: 11.0.2(@types/node@22.19.10)(@unhead/vue@1.11.20(vue@3.5.28(typescript@5.9.3)))(knex@3.1.0)(lightningcss@1.30.2)(pinia@2.3.1(typescript@5.9.3)(vue@3.5.28(typescript@5.9.3)))(pino@10.3.1)(sass@1.97.3)(terser@5.46.0)(typescript@5.9.3) version: 11.0.2(@types/node@22.19.10)(@unhead/vue@1.11.20(vue@3.4.21(typescript@5.9.3)))(knex@3.1.0)(lightningcss@1.30.2)(pinia@2.3.1(typescript@5.9.3)(vue@3.4.21(typescript@5.9.3)))(pino@10.3.1)(sass@1.97.3)(terser@5.46.0)(typescript@5.9.3)
'@mintel/mail': '@mintel/mail':
specifier: workspace:* specifier: workspace:*
version: link:../mail version: link:../mail
@@ -179,13 +179,14 @@ importers:
version: 5.9.3 version: 5.9.3
packages/acquisition-manager: packages/acquisition-manager:
dependencies:
'@mintel/directus-extension-toolkit':
specifier: workspace:*
version: link:../directus-extension-toolkit
devDependencies: devDependencies:
'@directus/extensions-sdk': '@directus/extensions-sdk':
specifier: 11.0.2 specifier: 11.0.2
version: 11.0.2(@types/node@22.19.10)(@unhead/vue@1.11.20(vue@3.5.28(typescript@5.9.3)))(knex@3.1.0)(lightningcss@1.30.2)(pinia@2.3.1(typescript@5.9.3)(vue@3.5.28(typescript@5.9.3)))(pino@10.3.1)(sass@1.97.3)(terser@5.46.0)(typescript@5.9.3) version: 11.0.2(@types/node@22.19.10)(@unhead/vue@1.11.20(vue@3.5.28(typescript@5.9.3)))(knex@3.1.0)(lightningcss@1.30.2)(pinia@2.3.1(typescript@5.9.3)(vue@3.5.28(typescript@5.9.3)))(pino@10.3.1)(sass@1.97.3)(terser@5.46.0)(typescript@5.9.3)
'@mintel/directus-extension-toolkit':
specifier: workspace:*
version: link:../directus-extension-toolkit
vue: vue:
specifier: ^3.4.0 specifier: ^3.4.0
version: 3.5.28(typescript@5.9.3) version: 3.5.28(typescript@5.9.3)
@@ -249,25 +250,27 @@ importers:
packages/cms-infra: {} packages/cms-infra: {}
packages/company-manager: packages/company-manager:
dependencies:
'@mintel/directus-extension-toolkit':
specifier: workspace:*
version: link:../directus-extension-toolkit
devDependencies: devDependencies:
'@directus/extensions-sdk': '@directus/extensions-sdk':
specifier: 11.0.2 specifier: 11.0.2
version: 11.0.2(@types/node@22.19.10)(@unhead/vue@1.11.20(vue@3.5.28(typescript@5.9.3)))(knex@3.1.0)(lightningcss@1.30.2)(pinia@2.3.1(typescript@5.9.3)(vue@3.5.28(typescript@5.9.3)))(pino@10.3.1)(sass@1.97.3)(terser@5.46.0)(typescript@5.9.3) version: 11.0.2(@types/node@22.19.10)(@unhead/vue@1.11.20(vue@3.5.28(typescript@5.9.3)))(knex@3.1.0)(lightningcss@1.30.2)(pinia@2.3.1(typescript@5.9.3)(vue@3.5.28(typescript@5.9.3)))(pino@10.3.1)(sass@1.97.3)(terser@5.46.0)(typescript@5.9.3)
'@mintel/directus-extension-toolkit':
specifier: workspace:*
version: link:../directus-extension-toolkit
vue: vue:
specifier: ^3.4.0 specifier: ^3.4.0
version: 3.5.28(typescript@5.9.3) version: 3.5.28(typescript@5.9.3)
packages/customer-manager: packages/customer-manager:
dependencies:
'@mintel/directus-extension-toolkit':
specifier: workspace:*
version: link:../directus-extension-toolkit
devDependencies: devDependencies:
'@directus/extensions-sdk': '@directus/extensions-sdk':
specifier: 11.0.2 specifier: 11.0.2
version: 11.0.2(@types/node@22.19.10)(@unhead/vue@1.11.20(vue@3.5.28(typescript@5.9.3)))(knex@3.1.0)(lightningcss@1.30.2)(pinia@2.3.1(typescript@5.9.3)(vue@3.5.28(typescript@5.9.3)))(pino@10.3.1)(sass@1.97.3)(terser@5.46.0)(typescript@5.9.3) version: 11.0.2(@types/node@22.19.10)(@unhead/vue@1.11.20(vue@3.5.28(typescript@5.9.3)))(knex@3.1.0)(lightningcss@1.30.2)(pinia@2.3.1(typescript@5.9.3)(vue@3.5.28(typescript@5.9.3)))(pino@10.3.1)(sass@1.97.3)(terser@5.46.0)(typescript@5.9.3)
'@mintel/directus-extension-toolkit':
specifier: workspace:*
version: link:../directus-extension-toolkit
vue: vue:
specifier: ^3.4.0 specifier: ^3.4.0
version: 3.5.28(typescript@5.9.3) version: 3.5.28(typescript@5.9.3)
@@ -440,7 +443,7 @@ importers:
version: 5.9.3 version: 5.9.3
vitest: vitest:
specifier: ^3.0.4 specifier: ^3.0.4
version: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.10)(@vitest/ui@4.0.18(vitest@4.0.18))(happy-dom@20.5.3)(jsdom@27.4.0)(lightningcss@1.30.2)(sass@1.97.3)(terser@5.46.0) version: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.10)(@vitest/ui@4.0.18)(happy-dom@20.5.3)(jsdom@27.4.0)(lightningcss@1.30.2)(sass@1.97.3)(terser@5.46.0)
packages/next-config: packages/next-config:
dependencies: dependencies:
@@ -602,7 +605,7 @@ importers:
version: 5.9.3 version: 5.9.3
vitest: vitest:
specifier: ^2.0.0 specifier: ^2.0.0
version: 2.1.9(@types/node@22.19.10)(@vitest/ui@4.0.18(vitest@4.0.18))(happy-dom@20.5.3)(jsdom@27.4.0)(lightningcss@1.30.2)(sass@1.97.3)(terser@5.46.0) version: 2.1.9(@types/node@22.19.10)(@vitest/ui@4.0.18)(happy-dom@20.5.3)(jsdom@27.4.0)(lightningcss@1.30.2)(sass@1.97.3)(terser@5.46.0)
packages/pdf-library: packages/pdf-library:
dependencies: dependencies:
@@ -639,13 +642,14 @@ importers:
version: 5.9.3 version: 5.9.3
packages/people-manager: packages/people-manager:
dependencies:
'@mintel/directus-extension-toolkit':
specifier: workspace:*
version: link:../directus-extension-toolkit
devDependencies: devDependencies:
'@directus/extensions-sdk': '@directus/extensions-sdk':
specifier: 11.0.2 specifier: 11.0.2
version: 11.0.2(@types/node@22.19.10)(@unhead/vue@1.11.20(vue@3.5.28(typescript@5.9.3)))(knex@3.1.0)(lightningcss@1.30.2)(pinia@2.3.1(typescript@5.9.3)(vue@3.5.28(typescript@5.9.3)))(pino@10.3.1)(sass@1.97.3)(terser@5.46.0)(typescript@5.9.3) version: 11.0.2(@types/node@22.19.10)(@unhead/vue@1.11.20(vue@3.5.28(typescript@5.9.3)))(knex@3.1.0)(lightningcss@1.30.2)(pinia@2.3.1(typescript@5.9.3)(vue@3.5.28(typescript@5.9.3)))(pino@10.3.1)(sass@1.97.3)(terser@5.46.0)(typescript@5.9.3)
'@mintel/directus-extension-toolkit':
specifier: workspace:*
version: link:../directus-extension-toolkit
vue: vue:
specifier: ^3.4.0 specifier: ^3.4.0
version: 3.5.28(typescript@5.9.3) version: 3.5.28(typescript@5.9.3)
@@ -8822,6 +8826,57 @@ snapshots:
'@directus/constants@11.0.3': {} '@directus/constants@11.0.3': {}
'@directus/extensions-sdk@11.0.2(@types/node@22.19.10)(@unhead/vue@1.11.20(vue@3.4.21(typescript@5.9.3)))(knex@3.1.0)(lightningcss@1.30.2)(pinia@2.3.1(typescript@5.9.3)(vue@3.4.21(typescript@5.9.3)))(pino@10.3.1)(sass@1.97.3)(terser@5.46.0)(typescript@5.9.3)':
dependencies:
'@directus/composables': 10.1.12(vue@3.4.21(typescript@5.9.3))
'@directus/constants': 11.0.3
'@directus/extensions': 1.0.2(@unhead/vue@1.11.20(vue@3.4.21(typescript@5.9.3)))(knex@3.1.0)(pinia@2.3.1(typescript@5.9.3)(vue@3.4.21(typescript@5.9.3)))(pino@10.3.1)(vue@3.4.21(typescript@5.9.3))
'@directus/themes': 0.3.6(@unhead/vue@1.11.20(vue@3.4.21(typescript@5.9.3)))(pinia@2.3.1(typescript@5.9.3)(vue@3.4.21(typescript@5.9.3)))(vue@3.4.21(typescript@5.9.3))
'@directus/types': 11.0.8(knex@3.1.0)(vue@3.4.21(typescript@5.9.3))
'@directus/utils': 11.0.7(vue@3.4.21(typescript@5.9.3))
'@rollup/plugin-commonjs': 25.0.7(rollup@3.29.4)
'@rollup/plugin-json': 6.1.0(rollup@3.29.4)
'@rollup/plugin-node-resolve': 15.2.3(rollup@3.29.4)
'@rollup/plugin-replace': 5.0.5(rollup@3.29.4)
'@rollup/plugin-terser': 0.4.4(rollup@3.29.4)
'@rollup/plugin-virtual': 3.0.2(rollup@3.29.4)
'@vitejs/plugin-vue': 4.6.2(vite@4.5.2(@types/node@22.19.10)(lightningcss@1.30.2)(sass@1.97.3)(terser@5.46.0))(vue@3.4.21(typescript@5.9.3))
chalk: 5.3.0
commander: 10.0.1
esbuild: 0.17.19
execa: 7.2.0
fs-extra: 11.2.0
inquirer: 9.2.16
ora: 6.3.1
rollup: 3.29.4
rollup-plugin-esbuild: 5.0.0(esbuild@0.17.19)(rollup@3.29.4)
rollup-plugin-styles: 4.0.0(rollup@3.29.4)
vite: 4.5.2(@types/node@22.19.10)(lightningcss@1.30.2)(sass@1.97.3)(terser@5.46.0)
vue: 3.4.21(typescript@5.9.3)
transitivePeerDependencies:
- '@types/node'
- '@unhead/vue'
- better-sqlite3
- debug
- knex
- less
- lightningcss
- mysql
- mysql2
- pg
- pg-native
- pinia
- pino
- sass
- sqlite3
- stylus
- sugarss
- supports-color
- tedious
- terser
- typescript
- vue-router
'@directus/extensions-sdk@11.0.2(@types/node@22.19.10)(@unhead/vue@1.11.20(vue@3.5.28(typescript@5.9.3)))(knex@3.1.0)(lightningcss@1.30.2)(pinia@2.3.1(typescript@5.9.3)(vue@3.5.28(typescript@5.9.3)))(pino@10.3.1)(sass@1.97.3)(terser@5.46.0)(typescript@5.9.3)': '@directus/extensions-sdk@11.0.2(@types/node@22.19.10)(@unhead/vue@1.11.20(vue@3.5.28(typescript@5.9.3)))(knex@3.1.0)(lightningcss@1.30.2)(pinia@2.3.1(typescript@5.9.3)(vue@3.5.28(typescript@5.9.3)))(pino@10.3.1)(sass@1.97.3)(terser@5.46.0)(typescript@5.9.3)':
dependencies: dependencies:
'@directus/composables': 10.1.12(vue@3.4.21(typescript@5.9.3)) '@directus/composables': 10.1.12(vue@3.4.21(typescript@5.9.3))
@@ -8873,6 +8928,32 @@ snapshots:
- typescript - typescript
- vue-router - vue-router
'@directus/extensions@1.0.2(@unhead/vue@1.11.20(vue@3.4.21(typescript@5.9.3)))(knex@3.1.0)(pinia@2.3.1(typescript@5.9.3)(vue@3.4.21(typescript@5.9.3)))(pino@10.3.1)(vue@3.4.21(typescript@5.9.3))':
dependencies:
'@directus/constants': 11.0.3
'@directus/themes': 0.3.6(@unhead/vue@1.11.20(vue@3.4.21(typescript@5.9.3)))(pinia@2.3.1(typescript@5.9.3)(vue@3.4.21(typescript@5.9.3)))(vue@3.4.21(typescript@5.9.3))
'@directus/types': 11.0.8(knex@3.1.0)(vue@3.4.21(typescript@5.9.3))
'@directus/utils': 11.0.7(vue@3.4.21(typescript@5.9.3))
'@types/express': 4.17.21
fs-extra: 11.2.0
lodash-es: 4.17.21
zod: 3.22.4
optionalDependencies:
knex: 3.1.0
pino: 10.3.1
vue: 3.4.21(typescript@5.9.3)
transitivePeerDependencies:
- '@unhead/vue'
- better-sqlite3
- mysql
- mysql2
- pg
- pg-native
- pinia
- sqlite3
- supports-color
- tedious
'@directus/extensions@1.0.2(@unhead/vue@1.11.20(vue@3.5.28(typescript@5.9.3)))(knex@3.1.0)(pinia@2.3.1(typescript@5.9.3)(vue@3.5.28(typescript@5.9.3)))(pino@10.3.1)(vue@3.4.21(typescript@5.9.3))': '@directus/extensions@1.0.2(@unhead/vue@1.11.20(vue@3.5.28(typescript@5.9.3)))(knex@3.1.0)(pinia@2.3.1(typescript@5.9.3)(vue@3.5.28(typescript@5.9.3)))(pino@10.3.1)(vue@3.4.21(typescript@5.9.3))':
dependencies: dependencies:
'@directus/constants': 11.0.3 '@directus/constants': 11.0.3
@@ -8916,6 +8997,17 @@ snapshots:
'@directus/system-data@1.0.2': {} '@directus/system-data@1.0.2': {}
'@directus/themes@0.3.6(@unhead/vue@1.11.20(vue@3.4.21(typescript@5.9.3)))(pinia@2.3.1(typescript@5.9.3)(vue@3.4.21(typescript@5.9.3)))(vue@3.4.21(typescript@5.9.3))':
dependencies:
'@directus/utils': 11.0.7(vue@3.4.21(typescript@5.9.3))
'@sinclair/typebox': 0.32.15
'@unhead/vue': 1.11.20(vue@3.4.21(typescript@5.9.3))
decamelize: 6.0.0
flat: 6.0.1
lodash-es: 4.17.21
pinia: 2.3.1(typescript@5.9.3)(vue@3.4.21(typescript@5.9.3))
vue: 3.4.21(typescript@5.9.3)
'@directus/themes@0.3.6(@unhead/vue@1.11.20(vue@3.5.28(typescript@5.9.3)))(pinia@2.3.1(typescript@5.9.3)(vue@3.5.28(typescript@5.9.3)))(vue@3.4.21(typescript@5.9.3))': '@directus/themes@0.3.6(@unhead/vue@1.11.20(vue@3.5.28(typescript@5.9.3)))(pinia@2.3.1(typescript@5.9.3)(vue@3.5.28(typescript@5.9.3)))(vue@3.4.21(typescript@5.9.3))':
dependencies: dependencies:
'@directus/utils': 11.0.7(vue@3.4.21(typescript@5.9.3)) '@directus/utils': 11.0.7(vue@3.4.21(typescript@5.9.3))
@@ -11007,6 +11099,14 @@ snapshots:
'@unhead/schema': 1.11.20 '@unhead/schema': 1.11.20
packrup: 0.1.2 packrup: 0.1.2
'@unhead/vue@1.11.20(vue@3.4.21(typescript@5.9.3))':
dependencies:
'@unhead/schema': 1.11.20
'@unhead/shared': 1.11.20
hookable: 5.5.3
unhead: 1.11.20
vue: 3.4.21(typescript@5.9.3)
'@unhead/vue@1.11.20(vue@3.5.28(typescript@5.9.3))': '@unhead/vue@1.11.20(vue@3.5.28(typescript@5.9.3))':
dependencies: dependencies:
'@unhead/schema': 1.11.20 '@unhead/schema': 1.11.20
@@ -14439,6 +14539,16 @@ snapshots:
pify@4.0.1: {} pify@4.0.1: {}
pinia@2.3.1(typescript@5.9.3)(vue@3.4.21(typescript@5.9.3)):
dependencies:
'@vue/devtools-api': 6.6.4
vue: 3.4.21(typescript@5.9.3)
vue-demi: 0.14.10(vue@3.4.21(typescript@5.9.3))
optionalDependencies:
typescript: 5.9.3
transitivePeerDependencies:
- '@vue/composition-api'
pinia@2.3.1(typescript@5.9.3)(vue@3.5.28(typescript@5.9.3)): pinia@2.3.1(typescript@5.9.3)(vue@3.5.28(typescript@5.9.3)):
dependencies: dependencies:
'@vue/devtools-api': 6.6.4 '@vue/devtools-api': 6.6.4
@@ -15919,7 +16029,7 @@ snapshots:
tsx: 4.21.0 tsx: 4.21.0
yaml: 2.8.2 yaml: 2.8.2
vitest@2.1.9(@types/node@22.19.10)(@vitest/ui@4.0.18(vitest@4.0.18))(happy-dom@20.5.3)(jsdom@27.4.0)(lightningcss@1.30.2)(sass@1.97.3)(terser@5.46.0): vitest@2.1.9(@types/node@22.19.10)(@vitest/ui@4.0.18)(happy-dom@20.5.3)(jsdom@27.4.0)(lightningcss@1.30.2)(sass@1.97.3)(terser@5.46.0):
dependencies: dependencies:
'@vitest/expect': 2.1.9 '@vitest/expect': 2.1.9
'@vitest/mocker': 2.1.9(vite@5.4.21(@types/node@22.19.10)(lightningcss@1.30.2)(sass@1.97.3)(terser@5.46.0)) '@vitest/mocker': 2.1.9(vite@5.4.21(@types/node@22.19.10)(lightningcss@1.30.2)(sass@1.97.3)(terser@5.46.0))
@@ -15957,7 +16067,7 @@ snapshots:
- supports-color - supports-color
- terser - terser
vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.19.10)(@vitest/ui@4.0.18(vitest@4.0.18))(happy-dom@20.5.3)(jsdom@27.4.0)(lightningcss@1.30.2)(sass@1.97.3)(terser@5.46.0): vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.19.10)(@vitest/ui@4.0.18)(happy-dom@20.5.3)(jsdom@27.4.0)(lightningcss@1.30.2)(sass@1.97.3)(terser@5.46.0):
dependencies: dependencies:
'@types/chai': 5.2.3 '@types/chai': 5.2.3
'@vitest/expect': 3.2.4 '@vitest/expect': 3.2.4
@@ -16040,6 +16150,10 @@ snapshots:
- tsx - tsx
- yaml - yaml
vue-demi@0.14.10(vue@3.4.21(typescript@5.9.3)):
dependencies:
vue: 3.4.21(typescript@5.9.3)
vue-demi@0.14.10(vue@3.5.28(typescript@5.9.3)): vue-demi@0.14.10(vue@3.5.28(typescript@5.9.3)):
dependencies: dependencies:
vue: 3.5.28(typescript@5.9.3) vue: 3.5.28(typescript@5.9.3)

27
scripts/cms-up.sh Executable file
View File

@@ -0,0 +1,27 @@
#!/bin/bash
# Configuration
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
echo "🚀 Starting CMS infrastructure..."
# 1. Build extensions (pass all arguments to handle flags like --link)
"$SCRIPT_DIR/sync-extensions.sh" "$@"
# Filter out --link before passing to docker compose
DOCKER_ARGS=()
for arg in "$@"; do
if [ "$arg" != "--link" ]; then
DOCKER_ARGS+=("$arg")
fi
done
# 2. Docker compose up with arguments
cd "$REPO_ROOT/packages/cms-infra"
docker compose up -d "${DOCKER_ARGS[@]}"
# 3. Apply core patch
"$SCRIPT_DIR/patch-cms.sh"
echo "✨ CMS is up and patched!"

66
scripts/patch-cms.sh Executable file
View File

@@ -0,0 +1,66 @@
#!/bin/bash
# Configuration
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
# Define potential container names
CONTAINERS=("cms-infra-infra-cms-1" "at-mintel-directus-1")
echo "🔧 Checking for Directus containers to patch..."
for CONTAINER in "${CONTAINERS[@]}"; do
# Check if container exists and is running
if [ "$(docker ps -q -f name=^/${CONTAINER}$)" ]; then
echo "🔧 Applying core patch to Directus container: $CONTAINER..."
docker exec "$CONTAINER" node -e '
const fs = require("node:fs");
// Try multiple potential paths for the node_modules location
const searchPaths = [
"/directus/node_modules/.pnpm/@directus+extensions@file+packages+extensions_deep-diff@1.0.2_express@4.21.2_graphql@16_244b87fbecd929c2d2240e7b3abc1fe4/node_modules/@directus/extensions/dist/node.js",
"/directus/node_modules/@directus/extensions/dist/node.js"
];
let targetPath = null;
for (const p of searchPaths) {
if (fs.existsSync(p)) {
targetPath = p;
break;
}
}
if (targetPath) {
let content = fs.readFileSync(targetPath, "utf8");
// Patch the filter: allow string entrypoints for modules
const filterPatch = "extension.host === \"app\" && (extension.entrypoint.app || extension.entrypoint)";
if (!content.includes(filterPatch)) {
content = content.replace(
/extension\.host === \"app\" && !!extension\.entrypoint\.app/g,
filterPatch
);
}
// Patch all imports: handle string entrypoints
if (!content.includes("(extension.entrypoint.app || extension.entrypoint)")) {
content = content.replace(
/extension\.entrypoint\.app/g,
"(extension.entrypoint.app || extension.entrypoint)"
);
}
fs.writeFileSync(targetPath, content);
console.log(`✅ Core patched successfully at ${targetPath}.`);
} else {
console.error("⚠️ Could not find @directus/extensions node.js to patch!");
}
'
echo "🔄 Restarting Directus container: $CONTAINER..."
docker restart "$CONTAINER"
else
echo " Container $CONTAINER is not running or not found. Skipping patch."
fi
done
echo "✨ Patching process finished."

View File

@@ -22,6 +22,14 @@ EXTENSION_PACKAGES=(
"unified-dashboard" "unified-dashboard"
) )
# Parse flags
LINK_MODE=false
for arg in "$@"; do
if [ "$arg" == "--link" ]; then
LINK_MODE=true
fi
done
echo "🚀 Starting isolated extension sync..." echo "🚀 Starting isolated extension sync..."
# Ensure target directories exist # Ensure target directories exist
@@ -59,13 +67,22 @@ for PKG in "${EXTENSION_PACKAGES[@]}"; do
rm -rf "${FINAL_TARGET:?}"/* rm -rf "${FINAL_TARGET:?}"/*
# Copy build artifacts # Copy build artifacts
if [ -f "$PKG_PATH/dist/index.js" ]; then if [ "$LINK_MODE" = true ]; then
cp "$PKG_PATH/dist/index.js" "$FINAL_TARGET/index.js" if [ -f "$PKG_PATH/dist/index.js" ]; then
elif [ -f "$PKG_PATH/index.js" ]; then ln -sf "$PKG_PATH/dist/index.js" "$FINAL_TARGET/index.js"
cp "$PKG_PATH/index.js" "$FINAL_TARGET/index.js" elif [ -f "$PKG_PATH/index.js" ]; then
ln -sf "$PKG_PATH/index.js" "$FINAL_TARGET/index.js"
fi
else
if [ -f "$PKG_PATH/dist/index.js" ]; then
cp "$PKG_PATH/dist/index.js" "$FINAL_TARGET/index.js"
elif [ -f "$PKG_PATH/index.js" ]; then
cp "$PKG_PATH/index.js" "$FINAL_TARGET/index.js"
fi
fi fi
if [ -f "$PKG_PATH/package.json" ]; then if [ -f "$PKG_PATH/package.json" ]; then
# We ALWAYS copy and patch package.json to avoid messing with source
cp "$PKG_PATH/package.json" "$FINAL_TARGET/" cp "$PKG_PATH/package.json" "$FINAL_TARGET/"
# We force the registration path to index.js and ensure host/source are set # We force the registration path to index.js and ensure host/source are set
node -e " node -e "
@@ -88,7 +105,11 @@ for PKG in "${EXTENSION_PACKAGES[@]}"; do
fi fi
if [ -d "$PKG_PATH/dist" ]; then if [ -d "$PKG_PATH/dist" ]; then
cp -r "$PKG_PATH/dist" "$FINAL_TARGET/" if [ "$LINK_MODE" = true ]; then
ln -sf "$PKG_PATH/dist" "$FINAL_TARGET/dist"
else
cp -r "$PKG_PATH/dist" "$FINAL_TARGET/"
fi
fi fi
done done
@@ -118,38 +139,7 @@ for TARGET_BASE in "${TARGET_DIRS[@]}"; do
done done
done done
echo "🔧 Applying core patch to Directus 11.15.2 bundler..." # Container patching is now handled by scripts/patch-cms.sh
docker exec cms-infra-infra-cms-1 node -e ' # which should be run AFTER the containers are up.
const fs = require("node:fs");
const path = "/directus/node_modules/.pnpm/@directus+extensions@file+packages+extensions_deep-diff@1.0.2_express@4.21.2_graphql@16_244b87fbecd929c2d2240e7b3abc1fe4/node_modules/@directus/extensions/dist/node.js";
if (fs.existsSync(path)) {
let content = fs.readFileSync(path, "utf8");
// Patch the filter: allow string entrypoints for modules
const filterPatch = "extension.host === \"app\" && (extension.entrypoint.app || extension.entrypoint)";
if (!content.includes(filterPatch)) {
content = content.replace(
/extension\.host === \"app\" && !!extension\.entrypoint\.app/g,
filterPatch
);
}
// Patch all imports: handle string entrypoints (replace all occurrences of .app where it might fail) echo "✨ Sync complete! Extensions are in packages/cms-infra/extensions."
if (!content.includes("(extension.entrypoint.app || extension.entrypoint)")) {
content = content.replace(
/extension\.entrypoint\.app/g,
"(extension.entrypoint.app || extension.entrypoint)"
);
}
fs.writeFileSync(path, content);
console.log("✅ Core patched successfully.");
} else {
console.error("⚠️ Could not find node.js to patch!");
}
'
echo "🔄 Restarting Directus container..."
docker restart cms-infra-infra-cms-1 2>/dev/null || true
echo "✨ Sync complete! Extensions are in packages/cms-infra/extensions and core is patched."