@stack-mode/site-manager-kit 0.0.0-stage → 1.44.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +606 -3
- package/audit/ts-hooks.mjs +68 -0
- package/audit/wiring-audit.d.mts +37 -0
- package/audit/wiring-audit.mjs +604 -0
- package/bin/site-manager-kit.mjs +10 -0
- package/package.json +72 -6
- package/src/analytics.tsx +23 -0
- package/src/announcements.ts +13 -0
- package/src/attribution-fields.ts +52 -0
- package/src/attribution.tsx +47 -0
- package/src/blog.ts +122 -0
- package/src/chat-route.ts +136 -0
- package/src/chat-types.ts +76 -0
- package/src/client.ts +116 -0
- package/src/config.ts +78 -0
- package/src/copy-keys-route.ts +21 -0
- package/src/copy-keys.ts +77 -0
- package/src/data.ts +189 -0
- package/src/downloads.ts +7 -0
- package/src/faq.ts +21 -0
- package/src/floor-plans.ts +45 -0
- package/src/form-guard.tsx +37 -0
- package/src/form-token.ts +33 -0
- package/src/form-types.ts +13 -0
- package/src/forms.ts +76 -0
- package/src/glossary.ts +19 -0
- package/src/health-route.ts +34 -0
- package/src/image.ts +12 -0
- package/src/index.ts +36 -0
- package/src/indexnow.ts +40 -0
- package/src/listings.ts +38 -0
- package/src/metadata.ts +55 -0
- package/src/money.ts +10 -0
- package/src/not-found-reporter.tsx +16 -0
- package/src/not-found.ts +53 -0
- package/src/payment.ts +70 -0
- package/src/phone-input.tsx +26 -0
- package/src/phone.ts +37 -0
- package/src/preview-banner.tsx +49 -0
- package/src/preview-route.ts +48 -0
- package/src/preview.ts +94 -0
- package/src/redirects.ts +57 -0
- package/src/residents.ts +23 -0
- package/src/revalidate-route.ts +34 -0
- package/src/robots.ts +26 -0
- package/src/rss-route.ts +26 -0
- package/src/services.ts +52 -0
- package/src/signature.ts +23 -0
- package/src/site-chat-panel.tsx +537 -0
- package/src/site-chat.tsx +26 -0
- package/src/sitemap.ts +26 -0
- package/src/structured-data.tsx +207 -0
- package/src/types.ts +580 -0
- package/src/version.ts +1 -0
- package/src/visual-accents.ts +126 -0
- package/src/visual-bridge-client.tsx +1070 -0
- package/src/visual-bridge.tsx +30 -0
- package/src/visual-clicks.ts +13 -0
- package/src/visual-lists.ts +37 -0
- package/src/visual-match.ts +77 -0
- package/src/visual-messages.ts +160 -0
- package/src/visual-mode.ts +18 -0
- package/src/visual-preview.ts +69 -0
- package/src/visual-tags.ts +90 -0
- package/src/visual.ts +59 -0
- package/src/waitlist-form.ts +39 -0
- package/src/waitlist.ts +48 -0
|
@@ -0,0 +1,604 @@
|
|
|
1
|
+
// The wiring audit: compares what a website's code uses, what Site Manager has for the site, and values the code keeps
|
|
2
|
+
// its own copy of. Run in the website's folder with `npx site-manager-kit audit`. Read only: it never writes to Site
|
|
3
|
+
// Manager. It writes .wiring/report.md (and report.json) and exits 1 when anything is Not wired or missing in Site
|
|
4
|
+
// Manager, so it can gate CI. docs/wiring.md in Site Manager explains the process this checks.
|
|
5
|
+
//
|
|
6
|
+
// Plain JavaScript with no dependencies, so it runs before anything is built.
|
|
7
|
+
|
|
8
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
|
|
9
|
+
import { createRequire, register } from "node:module";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
import { pathToFileURL } from "node:url";
|
|
12
|
+
|
|
13
|
+
export const STATUS = {
|
|
14
|
+
wired: "Wired",
|
|
15
|
+
not_wired: "Not wired",
|
|
16
|
+
missing: "Missing in Site Manager",
|
|
17
|
+
hard_coded: "Hard-coded",
|
|
18
|
+
unknown: "Unknown",
|
|
19
|
+
info: "Note",
|
|
20
|
+
};
|
|
21
|
+
const FAILING = new Set(["not_wired", "missing"]);
|
|
22
|
+
|
|
23
|
+
// Which kit readers (or API paths) show that a site reads a Site Manager section. Sections not listed here have
|
|
24
|
+
// nothing a website reads (ADMIN_ONLY), or are new to the audit (reported as Unknown until added here).
|
|
25
|
+
export const MODULE_READERS = {
|
|
26
|
+
business_info: { names: ["getSite"], paths: ["/site"] },
|
|
27
|
+
site_copy: { names: ["getCopy", "copyText", "copyImage", "copyLink", "copyRecords"], paths: ["/copy"] },
|
|
28
|
+
reviews: { names: ["getReviews", "getReviewPlatforms"], paths: ["/reviews"] },
|
|
29
|
+
floor_plans: { names: ["getFloorPlans", "getFloorPlan", "getUnitMaps"], paths: ["/floor-plans", "/unit-maps"] },
|
|
30
|
+
amenities: { names: ["getAmenities"], paths: ["/amenities"] },
|
|
31
|
+
fees: { names: ["getFees"], paths: ["/fees"] },
|
|
32
|
+
specials: { names: ["getSpecials"], paths: ["/specials"] },
|
|
33
|
+
forms: { names: ["getForm", "submitForm", "formAction"], paths: ["/forms"] },
|
|
34
|
+
bookings: { names: ["getBookingSlots"], paths: ["/slots"] },
|
|
35
|
+
events: { names: ["getEvents", "getEvent"], paths: ["/events"] },
|
|
36
|
+
blog: { names: ["getPosts", "getPost", "getBlogCategories", "getBlogCategory", "getBlogFeed", "getBlogTags", "rssRoute"], paths: ["/blog"] },
|
|
37
|
+
locations: { names: ["getLocations"], paths: ["/locations"] },
|
|
38
|
+
nearby_places: { names: ["getNearbyPlaces"], paths: ["/nearby-places"] },
|
|
39
|
+
seo: { names: ["pageMetadata", "buildMetadata", "defaultMetadata", "buildSitemap", "buildRobots"], paths: ["/pages/meta", "/sitemap", "/robots"] },
|
|
40
|
+
legal: { names: ["getLegal", "getLegalDoc"], paths: ["/legal"] },
|
|
41
|
+
services: { names: ["getServices", "getService"], paths: ["/services"] },
|
|
42
|
+
pet_policy: { names: ["getPetPolicy"], paths: ["/pet-policy"] },
|
|
43
|
+
parking: { names: ["getParking"], paths: ["/parking"] },
|
|
44
|
+
waitlist: { names: ["getWaitlistOptions", "joinWaitlist", "waitlistAction"], paths: ["/waitlist"] },
|
|
45
|
+
listings: { names: ["getListings", "getListing", "getSoldListings", "getPaymentDefaults"], paths: ["/listings", "/payment-defaults"] },
|
|
46
|
+
team_members: { names: ["getTeam", "getTeamMember"], paths: ["/team"] },
|
|
47
|
+
areas: { names: ["getAreas", "getArea"], paths: ["/areas"] },
|
|
48
|
+
faq: { names: ["getFaq", "getFaqSet"], paths: ["/faq"] },
|
|
49
|
+
glossary: { names: ["getGlossary", "getGlossaryTerm"], paths: ["/glossary"] },
|
|
50
|
+
announcements: { names: ["getAnnouncements"], paths: ["/announcements"] },
|
|
51
|
+
downloads: { names: ["getDownloads"], paths: ["/downloads"] },
|
|
52
|
+
policies: { names: ["getPolicies"], paths: ["/policies"] },
|
|
53
|
+
resident_resources: { names: ["getResidentResources"], paths: ["/resident-resources"] },
|
|
54
|
+
visual_editor: { names: ["VisualEditing", "visualFrameHeaders", "allowVisualFrames", "withSiteManager"], paths: [] },
|
|
55
|
+
site_chat: { names: ["SiteChat"], paths: ["/chat"], imports: ["site-manager-kit/chat-route"] },
|
|
56
|
+
analytics: { names: ["SiteAnalytics"], paths: [] },
|
|
57
|
+
};
|
|
58
|
+
export const ADMIN_ONLY = new Set(["users", "media", "support", "texting", "leads", "review_requests", "calendar", "publishing", "brand_voice", "pms_sync", "rent_roll", "reports", "portfolio", "tour", "connections", "integrations", "backups", "email", "setup", "sites"]);
|
|
59
|
+
|
|
60
|
+
const CODE_EXT = /\.(ts|tsx|js|jsx|mjs|cjs|json)$/;
|
|
61
|
+
const IMAGE_EXT = /\.(png|jpe?g|webp|avif|gif|svg|ico)$/i;
|
|
62
|
+
const SKIP_DIRS = new Set(["node_modules", ".next", ".git", ".wiring", ".vercel", "out", "build", "dist", "coverage", ".turbo"]);
|
|
63
|
+
const MAX_WHERE = 15;
|
|
64
|
+
|
|
65
|
+
// ---------------------------------------------------------------- small pure helpers (tested)
|
|
66
|
+
|
|
67
|
+
/** BLUEPRINT_* values from a .env file's text. Quotes are removed; other variables are ignored. */
|
|
68
|
+
export function parseEnv(text) {
|
|
69
|
+
const out = {};
|
|
70
|
+
for (const line of text.split(/\r?\n/)) {
|
|
71
|
+
const m = line.match(/^\s*(?:export\s+)?(BLUEPRINT_[A-Z0-9_]+)\s*=\s*(.*)\s*$/);
|
|
72
|
+
if (!m) continue;
|
|
73
|
+
let v = m[2].trim();
|
|
74
|
+
if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) v = v.slice(1, -1);
|
|
75
|
+
out[m[1]] = v;
|
|
76
|
+
}
|
|
77
|
+
return out;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
81
|
+
|
|
82
|
+
/** A pattern for a US phone number however it's typed: (775) 544-9262, 775.544.9262, +17755449262. */
|
|
83
|
+
export function phonePattern(phone) {
|
|
84
|
+
let d = String(phone ?? "").replace(/\D/g, "");
|
|
85
|
+
if (d.length === 11 && d.startsWith("1")) d = d.slice(1);
|
|
86
|
+
if (d.length !== 10) return null;
|
|
87
|
+
const sep = "[\\s.\\-]*";
|
|
88
|
+
return new RegExp(`(?<!\\d)(?:\\+?1${sep})?\\(?${d.slice(0, 3)}\\)?${sep}${d.slice(3, 6)}${sep}${d.slice(6)}(?!\\d)`);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** A quoted string literal equal to the value, ignoring case. */
|
|
92
|
+
export const literalPattern = (value) => new RegExp(`(["'\`])${escapeRe(String(value).trim())}\\1`, "i");
|
|
93
|
+
|
|
94
|
+
/** The value anywhere in a line, ignoring case. */
|
|
95
|
+
export const containsPattern = (value) => new RegExp(escapeRe(String(value).trim()), "i");
|
|
96
|
+
|
|
97
|
+
/** A URL without its scheme, www or trailing slash, so https://www.x.com/a/ and x.com/a match. */
|
|
98
|
+
export function bareUrl(url) {
|
|
99
|
+
return String(url ?? "")
|
|
100
|
+
.trim()
|
|
101
|
+
.replace(/^https?:\/\//i, "")
|
|
102
|
+
.replace(/^www\./i, "")
|
|
103
|
+
.replace(/\/+$/, "");
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** A file name reduced to letters and digits, for matching a public/ file against a Site Manager photo's address. */
|
|
107
|
+
export function fileStem(name) {
|
|
108
|
+
const base = decodeURIComponent(String(name).split(/[?#]/)[0].split("/").pop() ?? "");
|
|
109
|
+
return base.replace(/\.[a-z0-9]+$/i, "").toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Copy key names a line asks for by name: COPY["a.b"], copyText("a.b"), <CopyText k="a.b">. */
|
|
113
|
+
export function keyRefsIn(line) {
|
|
114
|
+
const out = [];
|
|
115
|
+
const re = /(?:COPY\[\s*|\bcopy(?:Text|Image|Link|Records|Plain|Rich)\(\s*|\bk=\{?\s*)(["'`])([a-z0-9]+(?:[._-][a-z0-9]+)+)\1/g;
|
|
116
|
+
for (const m of line.matchAll(re)) out.push(m[2]);
|
|
117
|
+
return out;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** The declared copy keys a module exports: the kit's defineCopyKeys result, or a plain object of definitions. */
|
|
121
|
+
export function declaredFrom(mod) {
|
|
122
|
+
const candidates = [mod.COPY, mod.default, ...Object.values(mod)];
|
|
123
|
+
for (const c of candidates) {
|
|
124
|
+
if (!c || typeof c !== "object") continue;
|
|
125
|
+
const defs = c.__defs && typeof c.__defs === "object" ? c.__defs : c;
|
|
126
|
+
const entries = Object.entries(defs);
|
|
127
|
+
if (entries.length && entries.every(([, d]) => d && typeof d === "object" && typeof d.label === "string" && typeof d.kind === "string")) {
|
|
128
|
+
return new Map(entries.map(([k, d]) => [k, d]));
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ---------------------------------------------------------------- the site's files
|
|
135
|
+
|
|
136
|
+
function walk(dir, root, out, ignore) {
|
|
137
|
+
let entries;
|
|
138
|
+
try {
|
|
139
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
140
|
+
} catch {
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
for (const e of entries) {
|
|
144
|
+
const full = path.join(dir, e.name);
|
|
145
|
+
const rel = path.relative(root, full).split(path.sep).join("/");
|
|
146
|
+
if (ignore.some((p) => rel === p || rel.startsWith(p.replace(/\/?$/, "/")))) continue;
|
|
147
|
+
if (e.isDirectory()) {
|
|
148
|
+
if (!SKIP_DIRS.has(e.name)) walk(full, root, out, ignore);
|
|
149
|
+
} else out.push(rel);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** The site's code (not tests) as lines, and its files in public/. */
|
|
154
|
+
export function readSite(siteDir, opts = {}) {
|
|
155
|
+
const ignore = opts.ignore ?? [];
|
|
156
|
+
const all = [];
|
|
157
|
+
for (const r of opts.roots ?? ["src", "app", "components", "lib", "content", "config"]) {
|
|
158
|
+
if (existsSync(path.join(siteDir, r))) walk(path.join(siteDir, r), siteDir, all, ignore);
|
|
159
|
+
}
|
|
160
|
+
const files = [];
|
|
161
|
+
for (const rel of all) {
|
|
162
|
+
if (!CODE_EXT.test(rel) || /\.(test|spec)\.[a-z]+$/.test(rel) || rel.includes("__tests__/")) continue;
|
|
163
|
+
const text = readFileSync(path.join(siteDir, rel), "utf8");
|
|
164
|
+
files.push({ rel, text, lines: text.split(/\r?\n/), usesKit: text.includes("site-manager-kit") });
|
|
165
|
+
}
|
|
166
|
+
const pub = [];
|
|
167
|
+
if (existsSync(path.join(siteDir, "public"))) walk(path.join(siteDir, "public"), path.join(siteDir, "public"), pub, []);
|
|
168
|
+
return { files, publicFiles: pub.filter((p) => IMAGE_EXT.test(p)) };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Every line matching the pattern, as file:line. */
|
|
172
|
+
export function findAll(files, re, filter = () => true) {
|
|
173
|
+
const out = [];
|
|
174
|
+
for (const f of files) {
|
|
175
|
+
if (!filter(f)) continue;
|
|
176
|
+
f.lines.forEach((line, i) => {
|
|
177
|
+
if (re.test(line)) out.push({ file: f.rel, line: i + 1 });
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
return out;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const mentions = (files, re) => files.some((f) => re.test(f.text));
|
|
184
|
+
|
|
185
|
+
/** Whether the site's code reads a section: a kit reader imported or called, or its API path requested. */
|
|
186
|
+
export function readsModule(files, readers) {
|
|
187
|
+
const kitFiles = files.filter((f) => f.usesKit);
|
|
188
|
+
if (readers.names.some((n) => mentions(kitFiles, new RegExp(`\\b${n}\\b`)))) return true;
|
|
189
|
+
if ((readers.imports ?? []).some((i) => mentions(files, new RegExp(`["']${escapeRe(i)}["']`)))) return true;
|
|
190
|
+
return readers.paths.some((p) => mentions(kitFiles, new RegExp(`["'\`](?:/api/v1)?${escapeRe(p)}(?:[/?"'\`$])`)));
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// ---------------------------------------------------------------- loading the declared copy keys
|
|
194
|
+
|
|
195
|
+
function tsconfigAliases(siteDir, ts) {
|
|
196
|
+
const file = path.join(siteDir, "tsconfig.json");
|
|
197
|
+
if (!existsSync(file)) return [];
|
|
198
|
+
const parsed = ts.readConfigFile(file, ts.sys.readFile).config ?? {};
|
|
199
|
+
const base = path.resolve(siteDir, parsed.compilerOptions?.baseUrl ?? ".");
|
|
200
|
+
return Object.entries(parsed.compilerOptions?.paths ?? {}).map(([from, targets]) => ({
|
|
201
|
+
prefix: from.replace(/\*$/, ""),
|
|
202
|
+
targets: targets.map((t) => path.resolve(base, t.replace(/\*$/, "")) + (t.endsWith("/*") ? path.sep : "")),
|
|
203
|
+
}));
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
let hooksRegistered = false;
|
|
207
|
+
|
|
208
|
+
/** The keys the site declares in its copy keys file, loaded with the site's own TypeScript. */
|
|
209
|
+
export async function loadDeclaredKeys(siteDir, keysFile) {
|
|
210
|
+
const file = path.resolve(siteDir, keysFile);
|
|
211
|
+
if (!existsSync(file)) return { error: `${keysFile} doesn't exist.` };
|
|
212
|
+
let ts;
|
|
213
|
+
try {
|
|
214
|
+
ts = createRequire(path.join(siteDir, "package.json"))("typescript");
|
|
215
|
+
} catch {
|
|
216
|
+
return { error: "TypeScript isn't installed in this site (run npm install first)." };
|
|
217
|
+
}
|
|
218
|
+
if (!hooksRegistered) {
|
|
219
|
+
register(new URL("./ts-hooks.mjs", import.meta.url), { data: { siteDir, aliases: tsconfigAliases(siteDir, ts) } });
|
|
220
|
+
hooksRegistered = true;
|
|
221
|
+
}
|
|
222
|
+
try {
|
|
223
|
+
const mod = await import(pathToFileURL(file).href);
|
|
224
|
+
const declared = declaredFrom(mod);
|
|
225
|
+
return declared ? { declared } : { error: `${keysFile} doesn't export its copy keys (export const COPY = defineCopyKeys(...)).` };
|
|
226
|
+
} catch (e) {
|
|
227
|
+
return { error: `${keysFile} couldn't be loaded: ${String(e?.message ?? e).split("\n")[0]}` };
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// ---------------------------------------------------------------- Site Manager
|
|
232
|
+
|
|
233
|
+
export function makeApi(base, key, fetchImpl = fetch) {
|
|
234
|
+
return async (p) => {
|
|
235
|
+
const res = await fetchImpl(`${base.replace(/\/+$/, "")}/api/v1${p}`, { headers: { authorization: `Bearer ${key}` }, cache: "no-store" });
|
|
236
|
+
const json = await res.json().catch(() => null);
|
|
237
|
+
return { status: res.status, data: json?.data ?? null, meta: json?.meta ?? null, code: json?.error?.code ?? null };
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** Every photo address found anywhere in a Site Manager answer. */
|
|
242
|
+
function imageUrls(value, out = new Set()) {
|
|
243
|
+
if (typeof value === "string") {
|
|
244
|
+
if (/^https?:\/\//.test(value) && IMAGE_EXT.test(value.split(/[?#]/)[0])) out.add(value);
|
|
245
|
+
} else if (Array.isArray(value)) value.forEach((v) => imageUrls(v, out));
|
|
246
|
+
else if (value && typeof value === "object") Object.values(value).forEach((v) => imageUrls(v, out));
|
|
247
|
+
return out;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Posts the audit's items to Site Manager (POST /api/v1/wiring), where the site's Wiring tab, score and Portfolio
|
|
252
|
+
* column show them. Needs an audit key: website keys are refused (403 audit_key_required). Never throws.
|
|
253
|
+
*/
|
|
254
|
+
export async function uploadResult(base, key, result, kitVersion, fetchImpl = fetch) {
|
|
255
|
+
const body = { kitVersion, ranAt: new Date().toISOString(), items: result.items.slice(0, 3000) };
|
|
256
|
+
let res;
|
|
257
|
+
try {
|
|
258
|
+
res = await fetchImpl(`${base.replace(/\/+$/, "")}/api/v1/wiring`, { method: "POST", headers: { authorization: `Bearer ${key}`, "content-type": "application/json" }, body: JSON.stringify(body) });
|
|
259
|
+
} catch (e) {
|
|
260
|
+
return { ok: false, message: `Couldn't reach Site Manager to upload the result (${e.message}).` };
|
|
261
|
+
}
|
|
262
|
+
const json = await res.json().catch(() => null);
|
|
263
|
+
if (res.status === 201) {
|
|
264
|
+
const d = json?.data ?? {};
|
|
265
|
+
return { ok: true, message: `Uploaded to Site Manager${d.score === null || d.score === undefined ? "" : `: wired ${d.score}%, ${d.needsAttention} need attention`}.` };
|
|
266
|
+
}
|
|
267
|
+
const code = json?.error?.code;
|
|
268
|
+
if (code === "audit_key_required") return { ok: false, message: "Site Manager refused the upload: it needs a Wiring audit key (API Keys tab, Kind: Wiring audit) in BLUEPRINT_AUDIT_KEY." };
|
|
269
|
+
if (res.status === 401) return { ok: false, message: "Site Manager refused the key (401). Check BLUEPRINT_AUDIT_KEY." };
|
|
270
|
+
if (res.status === 429) return { ok: false, message: "Site Manager has had too many uploads for this site in the last hour. Try again later." };
|
|
271
|
+
return { ok: false, message: `Site Manager answered ${res.status}${code ? ` (${code})` : ""} to the upload.` };
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// ---------------------------------------------------------------- the audit
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Runs every check. `api` answers Site Manager paths; `declaredResult` is loadDeclaredKeys' answer. Pure apart from
|
|
278
|
+
* `api`, so tests give it fixtures.
|
|
279
|
+
*/
|
|
280
|
+
export async function audit({ site: siteFiles, api, declaredResult, keysFile, hasCopyKeysRoute }) {
|
|
281
|
+
const items = [];
|
|
282
|
+
const add = (area, status, name, detail, fix = null, where = []) => items.push({ area, status, name, detail, fix, where: where.slice(0, MAX_WHERE), more: Math.max(0, where.length - MAX_WHERE) });
|
|
283
|
+
const { files, publicFiles } = siteFiles;
|
|
284
|
+
|
|
285
|
+
const siteRes = await api("/site");
|
|
286
|
+
if (siteRes.status === 401) throw new Error("Site Manager refused the API key (401). Check BLUEPRINT_API_KEY.");
|
|
287
|
+
if (siteRes.status !== 200 || !siteRes.data) throw new Error(`Site Manager answered ${siteRes.status} for /site${siteRes.code ? ` (${siteRes.code})` : ""}.`);
|
|
288
|
+
const sm = siteRes.data;
|
|
289
|
+
const modules = new Set(sm.modules ?? []);
|
|
290
|
+
const smImages = imageUrls(sm);
|
|
291
|
+
|
|
292
|
+
// ---- Copy keys
|
|
293
|
+
const declared = declaredResult.declared ?? null;
|
|
294
|
+
if (!declared) add("Copy keys", "unknown", keysFile, `The site's copy keys couldn't be read: ${declaredResult.error}`, "Declare every piece of built-in text in src/copy-keys.ts with defineCopyKeys (see the site starter).");
|
|
295
|
+
if (modules.has("site_copy")) {
|
|
296
|
+
if (!hasCopyKeysRoute) add("Copy keys", "not_wired", "Copy keys route", "The website has no app/api/blueprint/copy-keys route, so Site Manager's Sync from Website can't read its keys.", 'Add app/api/blueprint/copy-keys/route.ts: export const GET = copyKeysRoute(COPY) (site-manager-kit/copy-keys-route).');
|
|
297
|
+
const copyRes = await api("/copy");
|
|
298
|
+
const smCopy = copyRes.status === 200 && copyRes.data ? copyRes.data : {};
|
|
299
|
+
imageUrls(smCopy, smImages);
|
|
300
|
+
const refs = new Map();
|
|
301
|
+
for (const f of files) f.lines.forEach((line, i) => keyRefsIn(line).forEach((k) => (refs.has(k) ? refs.get(k) : refs.set(k, []).get(k)).push({ file: f.rel, line: i + 1 })));
|
|
302
|
+
let wired = 0;
|
|
303
|
+
for (const [k, def] of declared ?? []) {
|
|
304
|
+
const v = smCopy[k];
|
|
305
|
+
if (!v) add("Copy keys", "missing", k, `Declared in ${keysFile} as ${def.kind} ("${def.label}") but Site Manager doesn't have it.`, def.kind === "link" || def.kind === "records" ? "Add it by hand in Site Manager's Copy Keys (links and record picks can't be synced)." : "Deploy, then press Sync from Website in Site Copy.");
|
|
306
|
+
else if (v.kind !== def.kind) add("Copy keys", "not_wired", k, `Site Manager has it as ${v.kind}, the website declares ${def.kind}, so the site can't show what's saved.`, "Change the kind in one place so they match (Copy Keys in Site Manager, or the declaration).");
|
|
307
|
+
else wired++;
|
|
308
|
+
}
|
|
309
|
+
for (const [k, where] of refs) {
|
|
310
|
+
if (declared?.has(k)) continue;
|
|
311
|
+
if (smCopy[k]) add("Copy keys", "hard_coded", k, "Used in code by name but not declared, so Sync from Website won't keep its label and starting text in step.", `Declare it in ${keysFile}.`, where);
|
|
312
|
+
else add("Copy keys", "missing", k, "Used in code by name, but neither declared nor in Site Manager. The page shows nothing (or its fallback) for it.", `Declare it in ${keysFile}, deploy, then Sync from Website.`, where);
|
|
313
|
+
}
|
|
314
|
+
for (const [k, v] of Object.entries(smCopy)) {
|
|
315
|
+
if (declared?.has(k) || refs.has(k)) continue;
|
|
316
|
+
const seen = findAll(files, literalPattern(k));
|
|
317
|
+
if (seen.length) {
|
|
318
|
+
wired++;
|
|
319
|
+
continue;
|
|
320
|
+
}
|
|
321
|
+
add("Copy keys", declared ? "not_wired" : "unknown", k, `In Site Manager (${v.kind}${v.pagePath ? `, page ${v.pagePath}` : ""}) but the website never asks for it, so edits to it show nowhere.`, "Show it on the page and declare it, or remove the key in Copy Keys if the text is gone from the site.");
|
|
322
|
+
}
|
|
323
|
+
add("Copy keys", "wired", `${wired} copy keys`, "Declared by the website and present in Site Manager with the same kind.");
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// ---- Business Info
|
|
327
|
+
const b = sm.business ?? {};
|
|
328
|
+
const bizFiles = files.filter((f) => /\bbusiness\b/.test(f.text));
|
|
329
|
+
const readsBusiness = readsModule(files, MODULE_READERS.business_info);
|
|
330
|
+
const readsField = (re) => readsBusiness && mentions(bizFiles, re);
|
|
331
|
+
const facts = [
|
|
332
|
+
{ name: "Business name", value: b.displayName, read: /[.?]displayName\b/, pattern: b.displayName && literalPattern(b.displayName) },
|
|
333
|
+
{ name: "Legal name", value: b.legalName, read: /[.?]legalName\b/, pattern: b.legalName && literalPattern(b.legalName) },
|
|
334
|
+
{ name: "Phone", value: b.phone, read: /[.?]phone\b/, pattern: phonePattern(b.phone) },
|
|
335
|
+
{ name: "Email", value: b.email, read: /[.?]email\b/, pattern: b.email && containsPattern(b.email) },
|
|
336
|
+
{ name: "Address", value: b.address?.line1, read: /[.?]address\b/, pattern: b.address?.line1 && containsPattern(b.address.line1) },
|
|
337
|
+
{ name: "Hours", value: b.hours || b.hoursNote, read: /[.?]hours(Note)?\b/, pattern: b.hoursNote && literalPattern(b.hoursNote) },
|
|
338
|
+
{ name: "Map position", value: b.geo, read: /[.?]geo\b/, pattern: null },
|
|
339
|
+
{ name: "Website address", value: sm.websiteUrl, read: /[.?]websiteUrl\b/, pattern: sm.websiteUrl && containsPattern(`://${bareUrl(sm.websiteUrl).replace(/\/.*$/, "")}`) },
|
|
340
|
+
];
|
|
341
|
+
for (const s of b.social ?? []) facts.push({ name: `Social: ${s.network}`, value: s.url, read: /[.?]social\b/, pattern: s.url && containsPattern(bareUrl(s.url)) });
|
|
342
|
+
for (const fact of facts) {
|
|
343
|
+
if (!fact.value || (typeof fact.value === "object" && !Object.keys(fact.value).length)) {
|
|
344
|
+
add("Business Info", "info", fact.name, "Not set in Site Manager.");
|
|
345
|
+
continue;
|
|
346
|
+
}
|
|
347
|
+
const read = readsField(fact.read);
|
|
348
|
+
const where = fact.pattern ? findAll(files, fact.pattern) : [];
|
|
349
|
+
if (!read) add("Business Info", "not_wired", fact.name, `Set in Site Manager but the site never reads it${where.length ? "; the site shows its own copy instead" : ""}.`, "Read it from getSite().business (or the site's settings helper) and delete the built-in copy.", where);
|
|
350
|
+
else if (where.length) add("Business Info", "hard_coded", fact.name, "Read from Site Manager, but the code also keeps its own copy (a fallback or a second place that shows it). If the value is cleared or changed in Site Manager, these places can show the old one.", "Show the Site Manager value everywhere and drop the built-in copy, or keep it only as a fallback that's never shown when Site Manager has a value.", where);
|
|
351
|
+
else add("Business Info", "wired", fact.name, "Read from Site Manager.");
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// ---- Sections (modules)
|
|
355
|
+
for (const key of [...modules].sort()) {
|
|
356
|
+
if (key === "seo") continue; // checked piece by piece below
|
|
357
|
+
const readers = MODULE_READERS[key];
|
|
358
|
+
if (!readers) {
|
|
359
|
+
add("Sections", ADMIN_ONLY.has(key) ? "info" : "unknown", key, ADMIN_ONLY.has(key) ? "On; nothing on the website reads it (admin only)." : "On, and this audit doesn't know how a website reads it yet.", ADMIN_ONLY.has(key) ? null : "Add it to MODULE_READERS in the kit's audit/wiring-audit.mjs.");
|
|
360
|
+
continue;
|
|
361
|
+
}
|
|
362
|
+
if (readsModule(files, readers)) add("Sections", "wired", key, "On in Site Manager and read by the site.");
|
|
363
|
+
else add("Sections", "not_wired", key, "On in Site Manager, but the site never reads it, so the client can edit it and nothing changes on the website.", "Show it on the site with the kit's reader, or switch the section off for this site.");
|
|
364
|
+
}
|
|
365
|
+
for (const [key, readers] of Object.entries(MODULE_READERS)) {
|
|
366
|
+
if (!modules.has(key) && key !== "seo" && readsModule(files, readers)) add("Sections", "info", key, "Off in Site Manager, but the code reads it. The kit answers module_off, so whatever shows comes from the site's own fallback.");
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// ---- SEO and redirects
|
|
370
|
+
if (modules.has("seo")) {
|
|
371
|
+
const seoChecks = [
|
|
372
|
+
{ name: "Page titles and descriptions", names: ["pageMetadata", "buildMetadata", "defaultMetadata"], paths: ["/pages/meta"] },
|
|
373
|
+
{ name: "Sitemap", names: ["buildSitemap", "sitemap"], paths: ["/sitemap"] },
|
|
374
|
+
{ name: "robots.txt and indexing", names: ["buildRobots", "robots"], paths: ["/robots"] },
|
|
375
|
+
{ name: "Redirects", names: ["handleRedirect", "withSiteManager", "buildRedirectMap"], paths: ["/redirects"] },
|
|
376
|
+
];
|
|
377
|
+
for (const c of seoChecks) {
|
|
378
|
+
if (readsModule(files, { names: c.names, paths: c.paths })) add("SEO", "wired", c.name, "Read from Site Manager.");
|
|
379
|
+
else add("SEO", "not_wired", c.name, "Search Listings has it, but the site doesn't read it.", "Use the kit's helper for it (see the kit README).");
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// ---- Forms
|
|
384
|
+
if (modules.has("forms")) {
|
|
385
|
+
const keys = new Map();
|
|
386
|
+
const re = /\b(?:getForm|submitForm|formAction|getBookingSlots)\(\s*["'`]([a-z0-9_-]+)["'`]|\b(?:form|formKey|[a-z][a-zA-Z0-9]*Form)\s*:\s*["']([a-z0-9][a-z0-9_-]*)["']/g;
|
|
387
|
+
for (const f of files) f.lines.forEach((line, i) => {
|
|
388
|
+
for (const m of line.matchAll(re)) {
|
|
389
|
+
const k = m[1] ?? m[2];
|
|
390
|
+
(keys.get(k) ?? keys.set(k, []).get(k)).push({ file: f.rel, line: i + 1 });
|
|
391
|
+
}
|
|
392
|
+
});
|
|
393
|
+
for (const [k, where] of keys) {
|
|
394
|
+
const r = await api(`/forms/${encodeURIComponent(k)}`);
|
|
395
|
+
if (r.status === 200) add("Forms", "wired", k, "The form exists in Site Manager.", null, where);
|
|
396
|
+
else if (r.status === 404) add("Forms", "missing", k, "The site sends to this form, but Site Manager has no form with this key, so submissions fail.", "Add the form in Site Manager's Forms with this key, or fix the key in code.", where);
|
|
397
|
+
else add("Forms", "unknown", k, `Site Manager answered ${r.status}.`, null, where);
|
|
398
|
+
}
|
|
399
|
+
if (!keys.size) add("Forms", "info", "Form keys", "No form key is written in the code by name, so the audit can't check them one by one. Check each form submits on the preview.");
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// ---- Lists the site keeps its own copy of: review platforms, blog and glossary categories
|
|
403
|
+
const lists = [];
|
|
404
|
+
if (modules.has("reviews")) {
|
|
405
|
+
const r = await api("/reviews");
|
|
406
|
+
imageUrls(r.data, smImages);
|
|
407
|
+
imageUrls(r.meta, smImages);
|
|
408
|
+
lists.push({ area: "Review platforms", names: (r.meta?.platforms ?? []).map((p) => p.label), reader: ["getReviewPlatforms"] });
|
|
409
|
+
}
|
|
410
|
+
if (modules.has("blog")) {
|
|
411
|
+
const r = await api("/blog/categories");
|
|
412
|
+
lists.push({ area: "Blog categories", names: (r.data ?? []).map((c) => c.name), reader: ["getBlogCategories", "/blog/categories"] });
|
|
413
|
+
}
|
|
414
|
+
if (modules.has("glossary")) {
|
|
415
|
+
const r = await api("/glossary");
|
|
416
|
+
lists.push({ area: "Glossary categories", names: (r.data?.categories ?? []).map((c) => c.name), reader: ["getGlossary", "/glossary"] });
|
|
417
|
+
}
|
|
418
|
+
for (const l of lists) {
|
|
419
|
+
const reads = readsModule(files, { names: l.reader.filter((n) => !n.startsWith("/")), paths: l.reader.filter((n) => n.startsWith("/")) });
|
|
420
|
+
for (const name of l.names.filter((n) => n && n.trim().length >= 3)) {
|
|
421
|
+
const where = findAll(files, literalPattern(name));
|
|
422
|
+
if (where.length) add(l.area, "hard_coded", name, `${reads ? "Read from Site Manager, but the" : "The"} code also has its own copy of this name${reads ? "" : " and never reads the list"}, so a rename, a new entry or a new order in Site Manager may not show.`, "Read the list (names and order) from Site Manager and drop the built-in list.", where);
|
|
423
|
+
else add(l.area, reads ? "wired" : "not_wired", name, reads ? "Read from Site Manager." : "In Site Manager, but the site never reads this list.", reads ? null : "Read the list from Site Manager.");
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// ---- Brand: Site Manager's website API doesn't send the logo or colors yet, so only the site's side is checked.
|
|
428
|
+
const logos = publicFiles.filter((p) => /logo/i.test(p));
|
|
429
|
+
const logoRefs = logos.flatMap((p) => findAll(files, containsPattern(`/${p}`)).map((w) => ({ ...w, p })));
|
|
430
|
+
add("Brand", "unknown", "Logo and colors", "Site Manager's website API doesn't send the logo or brand colors yet, so the site can't read them.", "Next step for Site Manager: add the logo and accent color to /api/v1/site (additive).");
|
|
431
|
+
if (logoRefs.length) add("Brand", "hard_coded", "Logo files in public/", `The site shows ${new Set(logoRefs.map((w) => w.p)).size} logo file(s) from public/.`, "Keep until Site Manager sends the logo; then read it from there.", logoRefs);
|
|
432
|
+
|
|
433
|
+
// ---- Photos in public/
|
|
434
|
+
const declaredImages = new Map([...(declared ?? [])].filter(([, d]) => d.kind === "image" && d.image).map(([k, d]) => [d.image, k]));
|
|
435
|
+
const smStems = new Set([...smImages].map(fileStem));
|
|
436
|
+
const inSm = (p) => {
|
|
437
|
+
const stem = fileStem(p);
|
|
438
|
+
return stem.length >= 4 && [...smStems].some((s) => s === stem || s.startsWith(stem));
|
|
439
|
+
};
|
|
440
|
+
let unused = 0;
|
|
441
|
+
const hard = [];
|
|
442
|
+
for (const p of publicFiles) {
|
|
443
|
+
if (/logo/i.test(p) || /^(favicon|icon|apple-icon|apple-touch-icon|og|opengraph)/i.test(p.split("/").pop())) continue;
|
|
444
|
+
const url = `/${p}`;
|
|
445
|
+
if (declaredImages.has(url)) {
|
|
446
|
+
add("Photos", "wired", url, `Starting photo of copy key ${declaredImages.get(url)}; Site Manager holds the photo after Sync from Website.`);
|
|
447
|
+
continue;
|
|
448
|
+
}
|
|
449
|
+
const where = findAll(files, containsPattern(url));
|
|
450
|
+
if (where.length) hard.push({ url, where, matched: inSm(p) });
|
|
451
|
+
else unused++;
|
|
452
|
+
}
|
|
453
|
+
for (const h of hard) add("Photos", "hard_coded", h.url, h.matched ? "Shown from public/, but Site Manager has a photo with the same name. The client's photo changes won't show." : "Shown from public/, so the client can't change it in Site Manager.", "Make it an image copy key (kind: \"image\") or read it from the section it belongs to.", h.where);
|
|
454
|
+
if (unused) add("Photos", "info", `${unused} files in public/`, "Not named anywhere in the code (used by a CSS file, built at run time, or no longer used).");
|
|
455
|
+
|
|
456
|
+
// ---- Site config: facts a config file keeps that Site Manager owns
|
|
457
|
+
const configFile = (f) => /(^|\/)(config\/[^/]+\.(ts|js|json)|site\.config\.(ts|js)|site\.ts)$/.test(f.rel);
|
|
458
|
+
const prop = /^\s*(name|siteName|url|siteUrl|baseUrl|phone|officePhone|email|address|hours|indexable|indexing|noindex|robots|social|profiles)\s*:/;
|
|
459
|
+
for (const f of files.filter(configFile)) {
|
|
460
|
+
f.lines.forEach((line, i) => {
|
|
461
|
+
const m = line.match(prop);
|
|
462
|
+
if (m) add("Site config", "hard_coded", `${f.rel}: ${m[1]}`, "A site fact kept in a config file. Site Manager owns it (Business Info, or Search Listings for indexing), so the site should read it from there.", "Read it from Site Manager and keep the config value only as a fallback, or delete it.", [{ file: f.rel, line: i + 1 }]);
|
|
463
|
+
});
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
return { site: { name: sm.name, websiteUrl: sm.websiteUrl, modules: [...modules] }, items };
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// ---------------------------------------------------------------- the report
|
|
470
|
+
|
|
471
|
+
export function summarize(items) {
|
|
472
|
+
const counts = Object.fromEntries(Object.keys(STATUS).map((s) => [s, 0]));
|
|
473
|
+
for (const i of items) counts[i.status] += i.status === "wired" && /^\d+ copy keys$/.test(i.name) ? Number(i.name.split(" ")[0]) : 1;
|
|
474
|
+
return { counts, failed: items.some((i) => FAILING.has(i.status)) };
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
const cell = (s) => String(s ?? "").replace(/\|/g, "\\|").replace(/\r?\n/g, " ");
|
|
478
|
+
|
|
479
|
+
export function renderReport(result, meta) {
|
|
480
|
+
const { counts, failed } = summarize(result.items);
|
|
481
|
+
const out = [];
|
|
482
|
+
out.push(`# Wiring audit: ${result.site.name}`, "");
|
|
483
|
+
out.push(`Run ${meta.when} against ${meta.host} (site ${meta.siteId}), site kit ${meta.kitVersion}. Read only.`, "");
|
|
484
|
+
out.push(failed ? "**Result: not wired.** Fix the Not wired and Missing in Site Manager items below." : "**Result: wired.** Nothing is Not wired or missing in Site Manager.", "");
|
|
485
|
+
out.push("| Wired | Not wired | Missing in Site Manager | Hard-coded | Unknown |", "| --- | --- | --- | --- | --- |");
|
|
486
|
+
out.push(`| ${counts.wired} | ${counts.not_wired} | ${counts.missing} | ${counts.hard_coded} | ${counts.unknown} |`, "");
|
|
487
|
+
out.push("Not wired: Site Manager has it, but editing it changes nothing on the website. Missing in Site Manager: the website uses it, but Site Manager has no such field. Hard-coded: the code keeps its own copy of a Site Manager value. Unknown: the audit couldn't tell.", "");
|
|
488
|
+
const order = ["not_wired", "missing", "hard_coded", "unknown", "info", "wired"];
|
|
489
|
+
const areas = [...new Set(result.items.map((i) => i.area))];
|
|
490
|
+
for (const area of areas) {
|
|
491
|
+
const rows = result.items.filter((i) => i.area === area && (meta.all || i.status !== "wired" || /^\d+ copy keys$/.test(i.name)));
|
|
492
|
+
const wiredHidden = result.items.filter((i) => i.area === area && i.status === "wired").length - rows.filter((i) => i.status === "wired").length;
|
|
493
|
+
out.push(`## ${area}`, "");
|
|
494
|
+
if (!rows.length && wiredHidden) {
|
|
495
|
+
out.push(`All ${wiredHidden} wired.`, "");
|
|
496
|
+
continue;
|
|
497
|
+
}
|
|
498
|
+
out.push("| Status | Item | Detail | Fix | Where |", "| --- | --- | --- | --- | --- |");
|
|
499
|
+
rows.sort((a, b) => order.indexOf(a.status) - order.indexOf(b.status));
|
|
500
|
+
for (const i of rows) {
|
|
501
|
+
const where = i.where.map((w) => `${w.file}:${w.line}`).join("<br>") + (i.more ? `<br>and ${i.more} more` : "");
|
|
502
|
+
out.push(`| ${STATUS[i.status]} | ${cell(i.name)} | ${cell(i.detail)} | ${cell(i.fix ?? "")} | ${where} |`);
|
|
503
|
+
}
|
|
504
|
+
if (wiredHidden) out.push("", `Plus ${wiredHidden} wired (run with --all to list them).`);
|
|
505
|
+
out.push("");
|
|
506
|
+
}
|
|
507
|
+
return out.join("\n");
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// ---------------------------------------------------------------- command line
|
|
511
|
+
|
|
512
|
+
function parseArgs(argv) {
|
|
513
|
+
const a = { dir: process.cwd(), env: [], all: false };
|
|
514
|
+
for (let i = 0; i < argv.length; i++) {
|
|
515
|
+
const v = argv[i];
|
|
516
|
+
if (v === "--dir") a.dir = path.resolve(argv[++i]);
|
|
517
|
+
else if (v === "--env") a.env.push(argv[++i]);
|
|
518
|
+
else if (v === "--keys") a.keys = argv[++i];
|
|
519
|
+
else if (v === "--out") a.out = argv[++i];
|
|
520
|
+
else if (v === "--all") a.all = true;
|
|
521
|
+
else if (v === "--upload") a.upload = true;
|
|
522
|
+
else if (v === "--help" || v === "-h") a.help = true;
|
|
523
|
+
else throw new Error(`Unknown option ${v}`);
|
|
524
|
+
}
|
|
525
|
+
return a;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
const HELP = `site-manager-kit audit: checks how this website is wired to Site Manager (it only reads, unless --upload).
|
|
529
|
+
|
|
530
|
+
--dir <folder> the website's folder (default: here)
|
|
531
|
+
--env <file> a .env file with BLUEPRINT_URL and BLUEPRINT_API_KEY or BLUEPRINT_AUDIT_KEY (default: .env.local, then .env)
|
|
532
|
+
--upload send the result to Site Manager's Wiring tab (needs a Wiring audit key in BLUEPRINT_AUDIT_KEY)
|
|
533
|
+
--keys <file> the copy keys file (default: src/copy-keys.ts)
|
|
534
|
+
--out <file> where to write the report (default: .wiring/report.md)
|
|
535
|
+
--all list wired items too
|
|
536
|
+
|
|
537
|
+
Exits 1 when anything is Not wired or missing in Site Manager, 2 when the audit (or the upload) couldn't run.`;
|
|
538
|
+
|
|
539
|
+
export async function main(argv) {
|
|
540
|
+
let args;
|
|
541
|
+
try {
|
|
542
|
+
args = parseArgs(argv);
|
|
543
|
+
} catch (e) {
|
|
544
|
+
console.error(e.message);
|
|
545
|
+
console.error(HELP);
|
|
546
|
+
return 2;
|
|
547
|
+
}
|
|
548
|
+
if (args.help) {
|
|
549
|
+
console.log(HELP);
|
|
550
|
+
return 0;
|
|
551
|
+
}
|
|
552
|
+
const dir = args.dir;
|
|
553
|
+
const pkg = existsSync(path.join(dir, "package.json")) ? JSON.parse(readFileSync(path.join(dir, "package.json"), "utf8")) : {};
|
|
554
|
+
const conf = pkg.siteManagerAudit ?? {};
|
|
555
|
+
const env = {};
|
|
556
|
+
const envFiles = args.env.length ? args.env : [".env.local", ".env"];
|
|
557
|
+
for (const f of [...envFiles].reverse()) {
|
|
558
|
+
const p = path.resolve(dir, f);
|
|
559
|
+
if (existsSync(p)) Object.assign(env, parseEnv(readFileSync(p, "utf8")));
|
|
560
|
+
}
|
|
561
|
+
for (const k of ["BLUEPRINT_URL", "BLUEPRINT_API_KEY", "BLUEPRINT_AUDIT_KEY", "BLUEPRINT_SITE_ID"]) if (process.env[k]) env[k] = process.env[k];
|
|
562
|
+
// A Wiring audit key reads everything a website key does, so when it's there it's used for both reading and upload.
|
|
563
|
+
const key = env.BLUEPRINT_AUDIT_KEY || env.BLUEPRINT_API_KEY;
|
|
564
|
+
const missing = [!env.BLUEPRINT_URL && "BLUEPRINT_URL", !key && "BLUEPRINT_API_KEY (or BLUEPRINT_AUDIT_KEY)"].filter(Boolean);
|
|
565
|
+
if (missing.length) {
|
|
566
|
+
console.error(`${missing.join(" and ")} ${missing.length > 1 ? "aren't" : "isn't"} set. Put the site's production values in .env.local (never commit it) or pass --env <file>.`);
|
|
567
|
+
return 2;
|
|
568
|
+
}
|
|
569
|
+
if (args.upload && !env.BLUEPRINT_AUDIT_KEY) {
|
|
570
|
+
console.error("--upload needs BLUEPRINT_AUDIT_KEY: a Wiring audit key from the site's API Keys tab in Site Manager. Website keys can't upload.");
|
|
571
|
+
return 2;
|
|
572
|
+
}
|
|
573
|
+
const keysFile = args.keys ?? conf.keysFile ?? "src/copy-keys.ts";
|
|
574
|
+
const siteFiles = readSite(dir, { ignore: conf.ignore ?? [], roots: conf.roots });
|
|
575
|
+
const routeFiles = ["src/app/api/blueprint/copy-keys/route.ts", "app/api/blueprint/copy-keys/route.ts", "src/app/api/blueprint/copy-keys/route.js", "app/api/blueprint/copy-keys/route.js"];
|
|
576
|
+
const declaredResult = await loadDeclaredKeys(dir, keysFile);
|
|
577
|
+
let result;
|
|
578
|
+
try {
|
|
579
|
+
result = await audit({ site: siteFiles, api: makeApi(env.BLUEPRINT_URL, key), declaredResult, keysFile, hasCopyKeysRoute: routeFiles.some((f) => existsSync(path.join(dir, f))) });
|
|
580
|
+
} catch (e) {
|
|
581
|
+
console.error(`The audit couldn't run: ${e.message}`);
|
|
582
|
+
return 2;
|
|
583
|
+
}
|
|
584
|
+
let kitVersion = "unknown";
|
|
585
|
+
try {
|
|
586
|
+
kitVersion = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
|
|
587
|
+
} catch {}
|
|
588
|
+
const meta = { when: new Date().toISOString().slice(0, 16).replace("T", " ") + " UTC", host: new URL(env.BLUEPRINT_URL).host, siteId: env.BLUEPRINT_SITE_ID ?? "from the API key", kitVersion, all: args.all };
|
|
589
|
+
const out = path.resolve(dir, args.out ?? ".wiring/report.md");
|
|
590
|
+
mkdirSync(path.dirname(out), { recursive: true });
|
|
591
|
+
if (!args.out) writeFileSync(path.join(dir, ".wiring", ".gitignore"), "*\n");
|
|
592
|
+
writeFileSync(out, renderReport(result, meta));
|
|
593
|
+
writeFileSync(out.replace(/\.md$/, "") + ".json", JSON.stringify({ ...meta, ...result }, null, 2));
|
|
594
|
+
const { counts, failed } = summarize(result.items);
|
|
595
|
+
console.log(`Wiring audit for ${result.site.name}: ${counts.wired} wired, ${counts.not_wired} not wired, ${counts.missing} missing in Site Manager, ${counts.hard_coded} hard-coded, ${counts.unknown} unknown.`);
|
|
596
|
+
console.log(`Report: ${path.relative(process.cwd(), out) || out}`);
|
|
597
|
+
if (args.upload) {
|
|
598
|
+
const up = await uploadResult(env.BLUEPRINT_URL, env.BLUEPRINT_AUDIT_KEY, result, kitVersion);
|
|
599
|
+
(up.ok ? console.log : console.error)(up.message);
|
|
600
|
+
if (!up.ok) return 2;
|
|
601
|
+
}
|
|
602
|
+
return failed ? 1 : 0;
|
|
603
|
+
}
|
|
604
|
+
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// site-manager-kit's command line. One command so far: `audit` (the wiring audit, audit/wiring-audit.mjs).
|
|
3
|
+
const [command, ...rest] = process.argv.slice(2);
|
|
4
|
+
if (command === "audit") {
|
|
5
|
+
const { main } = await import("../audit/wiring-audit.mjs");
|
|
6
|
+
process.exitCode = await main(rest);
|
|
7
|
+
} else {
|
|
8
|
+
console.log("Usage: site-manager-kit audit [--help]");
|
|
9
|
+
process.exitCode = command ? 2 : 0;
|
|
10
|
+
}
|