@ibalzam/codejitsu-core 0.4.0 → 0.5.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.
@@ -0,0 +1,112 @@
1
+ import { pass, fail, warn, info, summarize } from '../util.mjs';
2
+
3
+ export async function runForms(ctx) {
4
+ const { htmlFiles, config } = ctx;
5
+ const auditCfg = config.audit ?? {};
6
+ const formCfg = auditCfg.forms ?? {};
7
+ const results = [];
8
+
9
+ // Dedupe forms by a stable signature so a shared modal counted on 127 pages
10
+ // doesn't masquerade as "127 forms".
11
+ const seen = new Map(); // signature → { firstPage, action, method, occurrences, hasCaptcha, hasConsent, hasHoneypot, hasJsHook }
12
+ for (const f of htmlFiles) {
13
+ for (const m of f.content.matchAll(/<form([^>]*)>([\s\S]*?)<\/form>/gi)) {
14
+ const attrs = m[1];
15
+ const body = m[2];
16
+ const id = attrs.match(/\bid=["']([^"']+)["']/)?.[1];
17
+ const action = attrs.match(/\baction=["']([^"']*)["']/)?.[1] ?? null;
18
+ const method = attrs.match(/\bmethod=["']([^"']*)["']/)?.[1] ?? 'get';
19
+ // Signature ≈ id + action + first 200 chars of body. Stable across pages.
20
+ const signature = `${id ?? ''}|${action ?? ''}|${body.slice(0, 200)}`;
21
+
22
+ const hasCaptcha = /(?:recaptcha|hcaptcha|h-captcha|turnstile)/i.test(body) ||
23
+ /(?:recaptcha|hcaptcha|h-captcha|turnstile)/i.test(f.content);
24
+ const hasConsent =
25
+ /<input[^>]+type=["']checkbox["'][^>]*(?:consent|terms|privacy|gdpr|casl|agree)/i.test(body);
26
+ const hasHoneypot =
27
+ /<input[^>]+name=["'](?:bot[-_]?field|honey|trap|website|url)["'][^>]+(?:hidden|display\s*:\s*none|visibility\s*:\s*hidden|aria-hidden=["']true)/i.test(body);
28
+ // Heuristic: page has form-submission JS hooks (addEventListener('submit'),
29
+ // emailjs/fetch/axios, or references to the form's id). False negatives
30
+ // (missing here) get caught by the manual review the audit prompts.
31
+ const jsHints = [
32
+ /addEventListener\(['"]submit['"]/,
33
+ /\.onsubmit\s*=/,
34
+ /emailjs\.(?:send|sendForm)/,
35
+ /netlify-forms|formspree|getform|web3forms/i,
36
+ ];
37
+ const hasJsHook =
38
+ jsHints.some((re) => re.test(f.content)) ||
39
+ (!!id && new RegExp(`['"\`]${id}['"\`]`).test(f.content));
40
+
41
+ if (seen.has(signature)) {
42
+ seen.get(signature).occurrences++;
43
+ } else {
44
+ seen.set(signature, {
45
+ firstPage: f.relPath,
46
+ id,
47
+ action,
48
+ method,
49
+ occurrences: 1,
50
+ hasCaptcha,
51
+ hasConsent,
52
+ hasHoneypot,
53
+ hasJsHook,
54
+ });
55
+ }
56
+ }
57
+ }
58
+
59
+ const forms = [...seen.values()];
60
+
61
+ if (forms.length === 0) {
62
+ results.push(info('No <form> elements found in built HTML'));
63
+ return results;
64
+ }
65
+
66
+ results.push(info(
67
+ `${forms.length} unique form${forms.length === 1 ? '' : 's'}`,
68
+ forms.map((x) => `${x.id ?? '(no id)'} on ${x.firstPage}${x.occurrences > 1 ? ` (×${x.occurrences})` : ''} → action: ${x.action ?? '(none)'}`)
69
+ ));
70
+
71
+ // Forms without action AND without a JS hook → likely broken.
72
+ const orphanForms = forms.filter((x) => !x.action && !x.hasJsHook);
73
+ results.push(
74
+ orphanForms.length === 0
75
+ ? pass('All forms have an action OR a JS submit handler')
76
+ : warn(
77
+ `${orphanForms.length} forms with no action and no JS hook`,
78
+ orphanForms.map((x) => `${x.firstPage}: id=${x.id ?? '(none)'}`)
79
+ )
80
+ );
81
+
82
+ // Spam protection (if required).
83
+ if (formCfg.requireSpamProtection !== false) {
84
+ const noProtection = forms.filter((x) => !x.hasCaptcha && !x.hasHoneypot);
85
+ results.push(
86
+ noProtection.length === 0
87
+ ? pass('All forms have spam protection (captcha or honeypot)')
88
+ : warn(
89
+ `${noProtection.length} forms without spam protection`,
90
+ noProtection.map((x) => `${x.firstPage}: id=${x.id ?? '(none)'}`)
91
+ )
92
+ );
93
+ }
94
+
95
+ // Consent (if required).
96
+ if (formCfg.requireConsent === true) {
97
+ const noConsent = forms.filter((x) => !x.hasConsent);
98
+ results.push(
99
+ noConsent.length === 0
100
+ ? pass('All forms have GDPR/CASL consent')
101
+ : fail(
102
+ `${noConsent.length} forms missing consent`,
103
+ noConsent.map((x) => `${x.firstPage}: id=${x.id ?? '(none)'}`)
104
+ )
105
+ );
106
+ } else {
107
+ const withConsent = forms.filter((x) => x.hasConsent).length;
108
+ results.push(info(`${withConsent}/${forms.length} forms have visible consent indicators`));
109
+ }
110
+
111
+ return results;
112
+ }
@@ -0,0 +1,58 @@
1
+ import { pass, fail, warn, summarize, anchorHrefs, isExternal } from '../util.mjs';
2
+
3
+ export async function runLinks(ctx) {
4
+ const { htmlFiles, config } = ctx;
5
+ const siteOrigin = config.site.url.replace(/\/$/, '');
6
+ const results = [];
7
+
8
+ // Internal links must end with `/` (trailing slash policy).
9
+ const missingSlash = [];
10
+ // External links must have target="_blank" + rel containing noopener.
11
+ const unsafeExternal = [];
12
+ // Stray dev/localhost references in production HTML.
13
+ const localhost = [];
14
+
15
+ for (const f of htmlFiles) {
16
+ for (const { href, full } of anchorHrefs(f.content)) {
17
+ // Ignore anchors-only, mailto, tel, javascript:
18
+ if (href.startsWith('#') || href.startsWith('mailto:') ||
19
+ href.startsWith('tel:') || href.startsWith('javascript:')) continue;
20
+
21
+ const external = isExternal(href, siteOrigin);
22
+
23
+ if (external) {
24
+ const hasBlank = /target=["']_blank["']/.test(full);
25
+ const hasNoopener = /rel=["'][^"']*noopener[^"']*["']/.test(full);
26
+ if (hasBlank && !hasNoopener) {
27
+ unsafeExternal.push(`${f.relPath}: ${href}`);
28
+ }
29
+ } else {
30
+ // Internal link. Strip query/hash; require trailing slash on the path.
31
+ const cleanPath = href.split('?')[0].split('#')[0];
32
+ if (cleanPath && cleanPath !== '/' && !cleanPath.endsWith('/') &&
33
+ !/\.(html?|xml|txt|webp|png|jpe?g|svg|pdf|json|js|css|ico|woff2?)$/i.test(cleanPath)) {
34
+ missingSlash.push(`${f.relPath}: ${href}`);
35
+ }
36
+ }
37
+
38
+ if (/localhost|127\.0\.0\.1|0\.0\.0\.0/.test(href)) {
39
+ localhost.push(`${f.relPath}: ${href}`);
40
+ }
41
+ }
42
+
43
+ // Also scan raw content for localhost references (CSS bg images, scripts).
44
+ if (/(?:src|href)=["'][^"']*(?:localhost|127\.0\.0\.1)/.test(f.content)) {
45
+ localhost.push(`${f.relPath}: (other ref)`);
46
+ }
47
+ }
48
+
49
+ results.push(summarize('All internal links end with /', dedupe(missingSlash)));
50
+ results.push(summarize('External links use rel="noopener noreferrer"', dedupe(unsafeExternal), 'warn'));
51
+ results.push(summarize('No localhost/dev URLs in production HTML', dedupe(localhost)));
52
+
53
+ return results;
54
+ }
55
+
56
+ function dedupe(arr) {
57
+ return Array.from(new Set(arr));
58
+ }
@@ -0,0 +1,117 @@
1
+ import { pass, fail, warn, info, summarize } from '../util.mjs';
2
+
3
+ export async function runPerformance(ctx) {
4
+ const { htmlFiles, config } = ctx;
5
+ const auditCfg = config.audit ?? {};
6
+ const perfCfg = auditCfg.performance ?? {};
7
+ const results = [];
8
+
9
+ // Defaults — these are advisory thresholds, not standards.
10
+ const INLINE_SCRIPT_BUDGET = perfCfg.inlineScriptBudgetBytes ?? 200_000;
11
+ const INLINE_STYLE_BUDGET = perfCfg.inlineStyleBudgetBytes ?? 100_000;
12
+ const SCRIPT_TAG_BUDGET = perfCfg.scriptTagBudget ?? 15;
13
+
14
+ const imgsNoSize = [];
15
+ const imgsHaveLoadingEager = [];
16
+ const imgsBelowFoldNotLazy = [];
17
+ const oversizedScripts = [];
18
+ const oversizedStyles = [];
19
+ const tooManyScripts = [];
20
+ const noFontPreload = [];
21
+
22
+ for (const f of htmlFiles) {
23
+ // <img> dimensions for CLS
24
+ for (const m of f.content.matchAll(/<img[^>]*>/gi)) {
25
+ const tag = m[0];
26
+ const hasWidth = /\swidth=/.test(tag);
27
+ const hasHeight = /\sheight=/.test(tag);
28
+ if (!hasWidth || !hasHeight) {
29
+ const src = tag.match(/src=["']([^"']+)["']/)?.[1] ?? '?';
30
+ // Astro's <Image> auto-emits dimensions; raw <img> doesn't. We only
31
+ // flag if BOTH are missing.
32
+ if (!hasWidth && !hasHeight) imgsNoSize.push(`${f.relPath}: ${src}`);
33
+ }
34
+ }
35
+
36
+ // Lazy-loading heuristic: only the first <img> per page should be eager
37
+ // (the LCP candidate). Everything else should be loading="lazy".
38
+ const imgs = [...f.content.matchAll(/<img[^>]*>/gi)].map((m) => m[0]);
39
+ imgs.forEach((tag, idx) => {
40
+ const isLazy = /loading=["']lazy["']/.test(tag);
41
+ const isEager = /loading=["']eager["']/.test(tag);
42
+ const src = tag.match(/src=["']([^"']+)["']/)?.[1] ?? '?';
43
+
44
+ if (idx === 0) {
45
+ // First image is OK eager (it's likely the hero).
46
+ // If it's lazy, that hurts LCP — flag.
47
+ if (isLazy) imgsBelowFoldNotLazy.push(`${f.relPath}: first img is lazy (LCP risk): ${src}`);
48
+ } else {
49
+ // Subsequent images should be lazy.
50
+ if (!isLazy && !isEager) imgsBelowFoldNotLazy.push(`${f.relPath}: below-fold img not lazy: ${src}`);
51
+ }
52
+ });
53
+
54
+ // Inline <script> total size + count
55
+ let inlineScriptSize = 0;
56
+ let scriptTagCount = 0;
57
+ for (const m of f.content.matchAll(/<script[^>]*>([\s\S]*?)<\/script>/gi)) {
58
+ scriptTagCount++;
59
+ inlineScriptSize += m[1].length;
60
+ }
61
+ // External scripts too
62
+ scriptTagCount += [...f.content.matchAll(/<script[^>]+src=["'][^"']+["'][^>]*>/gi)].length;
63
+
64
+ if (inlineScriptSize > INLINE_SCRIPT_BUDGET) {
65
+ oversizedScripts.push(`${f.relPath}: ${(inlineScriptSize / 1024).toFixed(1)} KB inline`);
66
+ }
67
+ if (scriptTagCount > SCRIPT_TAG_BUDGET) {
68
+ tooManyScripts.push(`${f.relPath}: ${scriptTagCount} <script> tags`);
69
+ }
70
+
71
+ // Inline <style> total size
72
+ let inlineStyleSize = 0;
73
+ for (const m of f.content.matchAll(/<style[^>]*>([\s\S]*?)<\/style>/gi)) {
74
+ inlineStyleSize += m[1].length;
75
+ }
76
+ if (inlineStyleSize > INLINE_STYLE_BUDGET) {
77
+ oversizedStyles.push(`${f.relPath}: ${(inlineStyleSize / 1024).toFixed(1)} KB inline`);
78
+ }
79
+
80
+ // Font preload (look for <link rel="preload" as="font">)
81
+ const hasFontPreload = /<link\s+rel=["']preload["'][^>]+as=["']font["']/.test(f.content);
82
+ if (!hasFontPreload) noFontPreload.push(f.relPath);
83
+ }
84
+
85
+ results.push(summarize('All <img> have width/height attrs (CLS)', dedupe(imgsNoSize), 'warn'));
86
+ results.push(summarize('Below-fold <img> use loading="lazy"', dedupe(imgsBelowFoldNotLazy), 'warn'));
87
+ results.push(summarize(
88
+ `Inline <script> ≤ ${(INLINE_SCRIPT_BUDGET / 1024).toFixed(0)} KB per page`,
89
+ dedupe(oversizedScripts),
90
+ 'warn'
91
+ ));
92
+ results.push(summarize(
93
+ `Inline <style> ≤ ${(INLINE_STYLE_BUDGET / 1024).toFixed(0)} KB per page`,
94
+ dedupe(oversizedStyles),
95
+ 'warn'
96
+ ));
97
+ results.push(summarize(
98
+ `≤ ${SCRIPT_TAG_BUDGET} <script> tags per page`,
99
+ dedupe(tooManyScripts),
100
+ 'warn'
101
+ ));
102
+
103
+ // Font preload — high-value but easy to miss. Info only (don't fail).
104
+ if (noFontPreload.length === htmlFiles.length) {
105
+ results.push(info('No font preload found on any page', 'Consider <link rel="preload" as="font" type="font/woff2" crossorigin> for LCP gains.'));
106
+ } else if (noFontPreload.length > 0) {
107
+ results.push(info(`${noFontPreload.length}/${htmlFiles.length} pages without font preload`));
108
+ } else {
109
+ results.push(pass('Every page preloads at least one font'));
110
+ }
111
+
112
+ return results;
113
+ }
114
+
115
+ function dedupe(arr) {
116
+ return Array.from(new Set(arr)).slice(0, 20);
117
+ }
@@ -0,0 +1,178 @@
1
+ import { pass, fail, warn, info, summarize, getTitle, getMeta, getLinkHref } from '../util.mjs';
2
+
3
+ // Required fields per common JSON-LD @type. Used by the schema-completeness check.
4
+ const SCHEMA_REQUIRED = {
5
+ Organization: ['name', 'url'],
6
+ LocalBusiness: ['name', 'url', 'address', 'telephone'],
7
+ Service: ['name', 'description', 'provider'],
8
+ BlogPosting: ['headline', 'datePublished', 'author', 'publisher'],
9
+ Article: ['headline', 'datePublished', 'author', 'publisher'],
10
+ FAQPage: ['mainEntity'],
11
+ Product: ['name', 'image', 'description', 'offers'],
12
+ Event: ['name', 'startDate', 'location'],
13
+ WebSite: ['name', 'url'],
14
+ WebPage: ['name'],
15
+ BreadcrumbList: ['itemListElement'],
16
+ };
17
+
18
+ const BOILERPLATE_DESC_RE = /^(welcome to|home of|the official|your one-?stop)/i;
19
+
20
+ export async function runSeo(ctx) {
21
+ const { htmlFiles, config } = ctx;
22
+ const siteOrigin = config.site.url.replace(/\/$/, '');
23
+ const siteName = config.site.name;
24
+ const results = [];
25
+
26
+ const titles = new Map();
27
+ const descriptions = new Map();
28
+ const noTitle = [];
29
+ const titleTooLong = [];
30
+ const titleNoBrand = [];
31
+ const noDescription = [];
32
+ const descTooLong = [];
33
+ const descBoilerplate = [];
34
+ const noCanonical = [];
35
+ const wrongCanonicalDomain = [];
36
+ const canonicalNoTrailingSlash = [];
37
+ const noOg = [];
38
+ const noOgImage = [];
39
+ const noTwitter = [];
40
+ const noJsonLd = [];
41
+ const unsafeJsonLd = [];
42
+ const invalidJsonLd = [];
43
+ const incompleteSchemas = [];
44
+
45
+ for (const f of htmlFiles) {
46
+ // Title
47
+ const title = getTitle(f.content);
48
+ if (!title) noTitle.push(f.relPath);
49
+ else {
50
+ if (title.length > 60) titleTooLong.push(`${f.relPath} (${title.length} chars)`);
51
+ if (siteName && !title.toLowerCase().includes(siteName.toLowerCase())) {
52
+ // Skip the homepage and 404 — they sometimes omit brand intentionally.
53
+ if (f.relPath !== 'index.html' && f.relPath !== '404.html') {
54
+ titleNoBrand.push(`${f.relPath}: "${title.slice(0, 60)}"`);
55
+ }
56
+ }
57
+ (titles.get(title) ?? titles.set(title, []).get(title)).push(f.relPath);
58
+ }
59
+
60
+ // Description
61
+ const desc = getMeta(f.content, 'description');
62
+ if (!desc) noDescription.push(f.relPath);
63
+ else {
64
+ if (desc.length > 160) descTooLong.push(`${f.relPath} (${desc.length} chars)`);
65
+ if (BOILERPLATE_DESC_RE.test(desc)) {
66
+ descBoilerplate.push(`${f.relPath}: "${desc.slice(0, 60)}..."`);
67
+ }
68
+ (descriptions.get(desc) ?? descriptions.set(desc, []).get(desc)).push(f.relPath);
69
+ }
70
+
71
+ // Canonical
72
+ const canonical = getLinkHref(f.content, 'canonical');
73
+ if (!canonical) noCanonical.push(f.relPath);
74
+ else {
75
+ if (!canonical.startsWith(siteOrigin)) wrongCanonicalDomain.push(`${f.relPath}: ${canonical}`);
76
+ else if (!canonical.endsWith('/')) canonicalNoTrailingSlash.push(`${f.relPath}: ${canonical}`);
77
+ }
78
+
79
+ // OG / Twitter
80
+ if (!getMeta(f.content, 'og:title') || !getMeta(f.content, 'og:description') ||
81
+ !getMeta(f.content, 'og:url') || !getMeta(f.content, 'og:type')) {
82
+ noOg.push(f.relPath);
83
+ }
84
+ if (!getMeta(f.content, 'og:image')) noOgImage.push(f.relPath);
85
+ if (!getMeta(f.content, 'twitter:card')) noTwitter.push(f.relPath);
86
+
87
+ // JSON-LD
88
+ const blocks = [...f.content.matchAll(/<script[^>]*application\/ld\+json[^>]*>([\s\S]*?)<\/script>/gi)];
89
+ if (blocks.length === 0) {
90
+ noJsonLd.push(f.relPath);
91
+ } else {
92
+ for (const m of blocks) {
93
+ if (/<\/[a-z]/i.test(m[1])) unsafeJsonLd.push(`${f.relPath} (unescaped </ )`);
94
+ let parsed;
95
+ try { parsed = JSON.parse(m[1]); } catch {
96
+ invalidJsonLd.push(`${f.relPath} (invalid JSON)`);
97
+ continue;
98
+ }
99
+ const types = collectTypes(parsed);
100
+ for (const type of types) {
101
+ const required = SCHEMA_REQUIRED[type];
102
+ if (!required) continue;
103
+ const missing = required.filter((k) => !hasField(parsed, k, type));
104
+ if (missing.length > 0) {
105
+ incompleteSchemas.push(`${f.relPath}: ${type} missing ${missing.join(', ')}`);
106
+ }
107
+ }
108
+ }
109
+ }
110
+ }
111
+
112
+ results.push(summarize('Every page has <title>', noTitle));
113
+ results.push(summarize('Titles ≤ 60 chars', titleTooLong, 'warn'));
114
+ if (siteName) results.push(summarize('Titles include brand name', titleNoBrand, 'warn'));
115
+ results.push(summarize('Every page has <meta description>', noDescription));
116
+ results.push(summarize('Descriptions ≤ 160 chars', descTooLong, 'warn'));
117
+ results.push(summarize('Descriptions don\'t start with boilerplate ("Welcome to...")', descBoilerplate, 'warn'));
118
+ results.push(summarize('Every page has canonical', noCanonical));
119
+ results.push(summarize('Canonical URLs use site domain', wrongCanonicalDomain));
120
+ results.push(summarize('Canonical URLs have trailing slash', canonicalNoTrailingSlash));
121
+ results.push(summarize('Every page has OG title/description/url/type', noOg));
122
+ results.push(summarize('Every page has og:image', noOgImage, 'warn'));
123
+ results.push(summarize('Every page has Twitter card', noTwitter, 'warn'));
124
+ results.push(summarize('Every page has JSON-LD schema', noJsonLd));
125
+ results.push(summarize('JSON-LD escapes </ safely', unsafeJsonLd));
126
+ results.push(summarize('JSON-LD is valid JSON', invalidJsonLd));
127
+ results.push(summarize('JSON-LD has required fields per @type', incompleteSchemas, 'warn'));
128
+
129
+ const dupTitles = [...titles.entries()].filter(([, ps]) => ps.length > 1);
130
+ results.push(
131
+ dupTitles.length === 0
132
+ ? pass('Page titles are unique')
133
+ : warn(`${dupTitles.length} duplicate titles`,
134
+ dupTitles.slice(0, 5).map(([t, ps]) => `"${t.slice(0, 50)}..." → ${ps.length} pages`))
135
+ );
136
+
137
+ const dupDesc = [...descriptions.entries()].filter(([, ps]) => ps.length > 1);
138
+ results.push(
139
+ dupDesc.length === 0
140
+ ? pass('Page descriptions are unique')
141
+ : warn(`${dupDesc.length} duplicate descriptions`,
142
+ dupDesc.slice(0, 5).map(([d, ps]) => `"${d.slice(0, 50)}..." → ${ps.length} pages`))
143
+ );
144
+
145
+ return results;
146
+ }
147
+
148
+ /** Walk a (possibly nested or arrayed) JSON-LD object and collect every @type. */
149
+ function collectTypes(obj) {
150
+ const types = new Set();
151
+ function walk(node) {
152
+ if (!node || typeof node !== 'object') return;
153
+ if (Array.isArray(node)) { node.forEach(walk); return; }
154
+ if (typeof node['@type'] === 'string') types.add(node['@type']);
155
+ if (Array.isArray(node['@type'])) node['@type'].forEach((t) => types.add(t));
156
+ for (const v of Object.values(node)) walk(v);
157
+ }
158
+ walk(obj);
159
+ return types;
160
+ }
161
+
162
+ /** Does the JSON-LD object (anywhere in its tree) have field `key` set, for the given @type? */
163
+ function hasField(obj, key, type) {
164
+ let found = false;
165
+ function walk(node) {
166
+ if (found || !node || typeof node !== 'object') return;
167
+ if (Array.isArray(node)) { node.forEach(walk); return; }
168
+ const t = node['@type'];
169
+ const matches = t === type || (Array.isArray(t) && t.includes(type));
170
+ if (matches && node[key] !== undefined && node[key] !== null && node[key] !== '') {
171
+ found = true;
172
+ return;
173
+ }
174
+ for (const v of Object.values(node)) walk(v);
175
+ }
176
+ walk(obj);
177
+ return found;
178
+ }
@@ -0,0 +1,105 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import { pass, fail, warn, info } from '../util.mjs';
4
+
5
+ export async function runStructure(ctx) {
6
+ const { cwd, distDir, htmlFiles, enabled } = ctx;
7
+ const results = [];
8
+
9
+ results.push(info(`${htmlFiles.length} HTML pages built`));
10
+
11
+ const hasSitemap =
12
+ fs.existsSync(path.join(distDir, 'sitemap-index.xml')) ||
13
+ fs.existsSync(path.join(distDir, 'sitemap-0.xml'));
14
+ results.push(hasSitemap ? pass('Sitemap present') : fail('Sitemap missing in dist/'));
15
+
16
+ const robotsPath = path.join(distDir, 'robots.txt');
17
+ if (!fs.existsSync(robotsPath)) {
18
+ results.push(fail('robots.txt missing'));
19
+ } else {
20
+ results.push(pass('robots.txt present'));
21
+ const robots = fs.readFileSync(robotsPath, 'utf8');
22
+ results.push(
23
+ /Sitemap:\s*https?:\/\//i.test(robots)
24
+ ? pass('robots.txt references sitemap')
25
+ : warn('robots.txt does not reference sitemap')
26
+ );
27
+ }
28
+
29
+ results.push(
30
+ fs.existsSync(path.join(distDir, '404.html'))
31
+ ? pass('Custom 404 page (dist/404.html)')
32
+ : warn('No custom 404 page (dist/404.html)')
33
+ );
34
+
35
+ if (enabled.llms) {
36
+ results.push(
37
+ fs.existsSync(path.join(distDir, 'llms.txt'))
38
+ ? pass('llms.txt present')
39
+ : warn('llms.txt missing (llms module is enabled)')
40
+ );
41
+ results.push(
42
+ fs.existsSync(path.join(distDir, 'llms-full.txt'))
43
+ ? pass('llms-full.txt present')
44
+ : warn('llms-full.txt missing (llms module is enabled)')
45
+ );
46
+ }
47
+
48
+ if (enabled.deploy) {
49
+ results.push(
50
+ fs.existsSync(path.join(cwd, 'wrangler.toml'))
51
+ ? pass('wrangler.toml at site root')
52
+ : fail('wrangler.toml missing')
53
+ );
54
+ results.push(
55
+ fs.existsSync(path.join(cwd, '.github/workflows/daily-deploy.yml'))
56
+ ? pass('Daily deploy workflow present')
57
+ : warn('No .github/workflows/daily-deploy.yml')
58
+ );
59
+ }
60
+
61
+ // Astro config sanity + trailing-slash plugin agreement
62
+ const astroCfgPath =
63
+ fs.existsSync(path.join(cwd, 'astro.config.ts'))
64
+ ? path.join(cwd, 'astro.config.ts')
65
+ : fs.existsSync(path.join(cwd, 'astro.config.mjs'))
66
+ ? path.join(cwd, 'astro.config.mjs')
67
+ : null;
68
+ if (astroCfgPath) {
69
+ const astroCfg = fs.readFileSync(astroCfgPath, 'utf8');
70
+
71
+ const trailingSlashAlways = /trailingSlash:\s*['"]always['"]/.test(astroCfg);
72
+ results.push(
73
+ trailingSlashAlways
74
+ ? pass("astro.config: trailingSlash: 'always'")
75
+ : fail("astro.config missing trailingSlash: 'always'")
76
+ );
77
+
78
+ results.push(
79
+ /output:\s*['"]static['"]/.test(astroCfg)
80
+ ? pass("astro.config: output: 'static'")
81
+ : fail("astro.config missing output: 'static'")
82
+ );
83
+
84
+ // Trailing-slash plugin: if astro.config enforces 'always', the rehype
85
+ // plugin should also be wired so markdown hrefs get auto-fixed. The audit
86
+ // catches violations either way, but the plugin prevents them entering dist/.
87
+ if (trailingSlashAlways) {
88
+ const hasPlugin =
89
+ /@ibalzam\/codejitsu-core\/rehype\/trailing-slash/.test(astroCfg) ||
90
+ /rehype.*[Tt]railing[Ss]lash/.test(astroCfg);
91
+ results.push(
92
+ hasPlugin
93
+ ? pass('rehype/trailing-slash plugin wired into markdown')
94
+ : info(
95
+ 'No trailing-slash rehype plugin detected',
96
+ 'Add @ibalzam/codejitsu-core/rehype/trailing-slash to astro.config markdown.rehypePlugins to auto-fix /foo → /foo/ in markdown.'
97
+ )
98
+ );
99
+ }
100
+ } else {
101
+ results.push(fail('astro.config.{ts,mjs} missing'));
102
+ }
103
+
104
+ return results;
105
+ }