@ibalzam/codejitsu-core 0.4.0 → 0.6.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/bin/codejitsu.mjs +23 -3
- package/modules/audit/src/a11y/runner.mjs +146 -0
- package/modules/audit/src/ai/runner.mjs +176 -0
- package/modules/audit/src/groups/ai-discoverability.mjs +51 -0
- package/modules/audit/src/groups/analytics.mjs +54 -0
- package/modules/audit/src/groups/blog-quality.mjs +98 -0
- package/modules/audit/src/groups/content.mjs +87 -0
- package/modules/audit/src/groups/forms.mjs +122 -0
- package/modules/audit/src/groups/links.mjs +58 -0
- package/modules/audit/src/groups/performance.mjs +117 -0
- package/modules/audit/src/groups/seo.mjs +178 -0
- package/modules/audit/src/groups/structure.mjs +105 -0
- package/modules/audit/src/http/runner.mjs +185 -0
- package/modules/audit/src/run.mjs +168 -0
- package/modules/audit/src/util.mjs +72 -0
- package/modules/config/src/types.d.ts +37 -0
- package/modules/config/src/types.ts +40 -0
- package/modules/contact/CLAUDE.md +164 -0
- package/modules/contact/checklist.md +35 -0
- package/modules/contact/templates/ContactModal.astro +420 -0
- package/modules/llms/src/generate.mjs +22 -5
- package/modules/rehype/CLAUDE.md +64 -0
- package/modules/rehype/src/trailing-slash.mjs +88 -0
- package/package.json +6 -4
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# Contact module — checklist
|
|
2
|
+
|
|
3
|
+
## Setup
|
|
4
|
+
|
|
5
|
+
- [ ] `codejitsu.config.ts` has a `contact.emailjs` block with serviceId, templateId, publicKey.
|
|
6
|
+
- [ ] (Optional) `contact.recaptcha.siteKey` set if site uses reCAPTCHA.
|
|
7
|
+
- [ ] EmailJS template variables match what the modal sends: `name`, `email`, `phone`, `message`.
|
|
8
|
+
|
|
9
|
+
## Wiring
|
|
10
|
+
|
|
11
|
+
- [ ] Exactly **one** `<ContactModal>` per page (placed in a layout that wraps every page).
|
|
12
|
+
- [ ] At least one trigger exists with `data-codejitsu-contact-trigger`.
|
|
13
|
+
- [ ] (If site has GA4/Ads) A `codejitsu-contact-submitted` event listener fires the conversion.
|
|
14
|
+
|
|
15
|
+
## Behaviour (manual or browser-tested)
|
|
16
|
+
|
|
17
|
+
- [ ] Modal opens when trigger clicked.
|
|
18
|
+
- [ ] Esc closes it.
|
|
19
|
+
- [ ] Backdrop click closes it.
|
|
20
|
+
- [ ] Tab cycles within the modal (focus trap).
|
|
21
|
+
- [ ] First field gets focus when opened.
|
|
22
|
+
- [ ] Required fields show `*` and HTML5 validation blocks empty submit.
|
|
23
|
+
- [ ] Submit shows "Sending…" state on the button.
|
|
24
|
+
- [ ] Successful submit shows the thank-you toast.
|
|
25
|
+
- [ ] Failed submit shows an error alert.
|
|
26
|
+
|
|
27
|
+
## Spam protection
|
|
28
|
+
|
|
29
|
+
- [ ] Honeypot input present at `name="cj_hp_website"`, off-screen, `tabindex="-1"`.
|
|
30
|
+
- [ ] If reCAPTCHA configured: widget visible inside the form, submit blocked until solved.
|
|
31
|
+
|
|
32
|
+
## Theming
|
|
33
|
+
|
|
34
|
+
- [ ] Site's `:root` CSS sets `--codejitsu-modal-accent` (otherwise it uses default blue).
|
|
35
|
+
- [ ] Modal's image (if used) is in `public/` and < 200KB.
|
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* Codejitsu contact modal.
|
|
4
|
+
*
|
|
5
|
+
* Drop one instance into a layout that wraps every page (e.g. BaseLayout).
|
|
6
|
+
* Trigger from anywhere with:
|
|
7
|
+
*
|
|
8
|
+
* <button data-codejitsu-contact-trigger>Get a quote</button>
|
|
9
|
+
*
|
|
10
|
+
* On success: dispatches a `codejitsu-contact-submitted` custom event on
|
|
11
|
+
* `window`. Sites add their own listener for analytics (GA4 conversion,
|
|
12
|
+
* Google Ads, etc.) so this component stays generic.
|
|
13
|
+
*
|
|
14
|
+
* Theming via CSS variables (set on `:root` in your global CSS):
|
|
15
|
+
* --codejitsu-modal-accent (focus ring + button bg; default #2563eb)
|
|
16
|
+
* --codejitsu-modal-accent-hover (default #1d4ed8)
|
|
17
|
+
* --codejitsu-modal-on-accent (text on button; default #ffffff)
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
interface FieldProps {
|
|
21
|
+
enabled?: boolean;
|
|
22
|
+
required?: boolean;
|
|
23
|
+
label?: string;
|
|
24
|
+
placeholder?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface Props {
|
|
28
|
+
/** Unique id (lets multiple modals coexist if ever needed). Default 'codejitsu-contact'. */
|
|
29
|
+
id?: string;
|
|
30
|
+
title: string;
|
|
31
|
+
image?: {
|
|
32
|
+
src: string;
|
|
33
|
+
alt: string;
|
|
34
|
+
width?: number;
|
|
35
|
+
height?: number;
|
|
36
|
+
};
|
|
37
|
+
fields?: {
|
|
38
|
+
name?: FieldProps;
|
|
39
|
+
email?: FieldProps;
|
|
40
|
+
phone?: FieldProps;
|
|
41
|
+
message?: FieldProps;
|
|
42
|
+
};
|
|
43
|
+
submitText?: string;
|
|
44
|
+
thankYouMessage?: string;
|
|
45
|
+
emailjs: {
|
|
46
|
+
serviceId: string;
|
|
47
|
+
templateId: string;
|
|
48
|
+
publicKey: string;
|
|
49
|
+
};
|
|
50
|
+
recaptcha?: { siteKey: string };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const {
|
|
54
|
+
id = 'codejitsu-contact',
|
|
55
|
+
title,
|
|
56
|
+
image,
|
|
57
|
+
fields = {},
|
|
58
|
+
submitText = 'Send Message',
|
|
59
|
+
thankYouMessage = "Thanks! We'll get back to you shortly.",
|
|
60
|
+
emailjs,
|
|
61
|
+
recaptcha,
|
|
62
|
+
} = Astro.props as Props;
|
|
63
|
+
|
|
64
|
+
// Field defaults — all fields enabled, name+email+phone required, message optional.
|
|
65
|
+
const f = {
|
|
66
|
+
name: { enabled: true, required: true, label: 'Name', placeholder: 'Your full name', ...fields.name },
|
|
67
|
+
email: { enabled: true, required: true, label: 'Email', placeholder: 'you@email.com', ...fields.email },
|
|
68
|
+
phone: { enabled: true, required: true, label: 'Phone', placeholder: '(xxx) xxx-xxxx', ...fields.phone },
|
|
69
|
+
message: { enabled: true, required: false, label: 'Message', placeholder: 'How can we help?', ...fields.message },
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const overlayId = `${id}-overlay`;
|
|
73
|
+
const cardId = `${id}-card`;
|
|
74
|
+
const closeId = `${id}-close`;
|
|
75
|
+
const formId = `${id}-form`;
|
|
76
|
+
const toastId = `${id}-toast`;
|
|
77
|
+
const titleId = `${id}-title`;
|
|
78
|
+
|
|
79
|
+
const config = {
|
|
80
|
+
serviceId: emailjs.serviceId,
|
|
81
|
+
templateId: emailjs.templateId,
|
|
82
|
+
publicKey: emailjs.publicKey,
|
|
83
|
+
recaptchaSiteKey: recaptcha?.siteKey ?? null,
|
|
84
|
+
};
|
|
85
|
+
---
|
|
86
|
+
|
|
87
|
+
<div
|
|
88
|
+
id={overlayId}
|
|
89
|
+
class="cj-modal-overlay fixed inset-0 z-[100] flex items-center justify-center bg-black/60 backdrop-blur-sm opacity-0 pointer-events-none transition-opacity duration-300"
|
|
90
|
+
aria-hidden="true"
|
|
91
|
+
inert
|
|
92
|
+
role="dialog"
|
|
93
|
+
aria-modal="true"
|
|
94
|
+
aria-labelledby={titleId}
|
|
95
|
+
>
|
|
96
|
+
<div
|
|
97
|
+
id={cardId}
|
|
98
|
+
class="bg-white rounded-2xl shadow-2xl w-full max-w-3xl mx-4 max-h-[90vh] overflow-y-auto p-6 sm:p-8 transform scale-95 transition-transform duration-300"
|
|
99
|
+
>
|
|
100
|
+
<div class="flex items-center justify-between mb-4">
|
|
101
|
+
<h2 id={titleId} class="text-xl font-bold text-gray-900">{title}</h2>
|
|
102
|
+
<button
|
|
103
|
+
type="button"
|
|
104
|
+
id={closeId}
|
|
105
|
+
class="text-gray-400 hover:text-gray-700 transition-colors p-1"
|
|
106
|
+
aria-label="Close modal"
|
|
107
|
+
>
|
|
108
|
+
<svg class="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
|
109
|
+
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"></path>
|
|
110
|
+
</svg>
|
|
111
|
+
</button>
|
|
112
|
+
</div>
|
|
113
|
+
|
|
114
|
+
<div class={image ? 'flex flex-col md:flex-row gap-6' : ''}>
|
|
115
|
+
{image && (
|
|
116
|
+
<div class="hidden md:block md:w-1/2 shrink-0">
|
|
117
|
+
<img
|
|
118
|
+
src={image.src}
|
|
119
|
+
alt={image.alt}
|
|
120
|
+
width={image.width ?? 600}
|
|
121
|
+
height={image.height ?? 600}
|
|
122
|
+
class="w-full h-full rounded-xl object-cover"
|
|
123
|
+
loading="lazy"
|
|
124
|
+
/>
|
|
125
|
+
</div>
|
|
126
|
+
)}
|
|
127
|
+
|
|
128
|
+
<div class="flex-1">
|
|
129
|
+
<form id={formId} class="cj-emailjs-form space-y-4" novalidate>
|
|
130
|
+
{f.name.enabled && (
|
|
131
|
+
<div>
|
|
132
|
+
<label for={`${id}-name`} class="block text-sm font-medium text-gray-700 mb-1">
|
|
133
|
+
{f.name.label}{f.name.required && <span class="text-red-500"> *</span>}
|
|
134
|
+
</label>
|
|
135
|
+
<input
|
|
136
|
+
type="text"
|
|
137
|
+
id={`${id}-name`}
|
|
138
|
+
name="name"
|
|
139
|
+
required={f.name.required}
|
|
140
|
+
autocomplete="name"
|
|
141
|
+
class="cj-modal-input w-full border border-gray-300 rounded-lg px-4 py-2.5 text-gray-900 outline-none transition-shadow"
|
|
142
|
+
placeholder={f.name.placeholder}
|
|
143
|
+
/>
|
|
144
|
+
</div>
|
|
145
|
+
)}
|
|
146
|
+
|
|
147
|
+
{f.email.enabled && (
|
|
148
|
+
<div>
|
|
149
|
+
<label for={`${id}-email`} class="block text-sm font-medium text-gray-700 mb-1">
|
|
150
|
+
{f.email.label}{f.email.required && <span class="text-red-500"> *</span>}
|
|
151
|
+
</label>
|
|
152
|
+
<input
|
|
153
|
+
type="email"
|
|
154
|
+
id={`${id}-email`}
|
|
155
|
+
name="email"
|
|
156
|
+
required={f.email.required}
|
|
157
|
+
autocomplete="email"
|
|
158
|
+
class="cj-modal-input w-full border border-gray-300 rounded-lg px-4 py-2.5 text-gray-900 outline-none transition-shadow"
|
|
159
|
+
placeholder={f.email.placeholder}
|
|
160
|
+
/>
|
|
161
|
+
</div>
|
|
162
|
+
)}
|
|
163
|
+
|
|
164
|
+
{f.phone.enabled && (
|
|
165
|
+
<div>
|
|
166
|
+
<label for={`${id}-phone`} class="block text-sm font-medium text-gray-700 mb-1">
|
|
167
|
+
{f.phone.label}{f.phone.required && <span class="text-red-500"> *</span>}
|
|
168
|
+
</label>
|
|
169
|
+
<input
|
|
170
|
+
type="tel"
|
|
171
|
+
id={`${id}-phone`}
|
|
172
|
+
name="phone"
|
|
173
|
+
required={f.phone.required}
|
|
174
|
+
autocomplete="tel"
|
|
175
|
+
class="cj-modal-input w-full border border-gray-300 rounded-lg px-4 py-2.5 text-gray-900 outline-none transition-shadow"
|
|
176
|
+
placeholder={f.phone.placeholder}
|
|
177
|
+
/>
|
|
178
|
+
</div>
|
|
179
|
+
)}
|
|
180
|
+
|
|
181
|
+
{f.message.enabled && (
|
|
182
|
+
<div>
|
|
183
|
+
<label for={`${id}-message`} class="block text-sm font-medium text-gray-700 mb-1">
|
|
184
|
+
{f.message.label}{f.message.required && <span class="text-red-500"> *</span>}
|
|
185
|
+
</label>
|
|
186
|
+
<textarea
|
|
187
|
+
id={`${id}-message`}
|
|
188
|
+
name="message"
|
|
189
|
+
required={f.message.required}
|
|
190
|
+
rows="3"
|
|
191
|
+
class="cj-modal-input w-full border border-gray-300 rounded-lg px-4 py-2.5 text-gray-900 outline-none transition-shadow resize-y"
|
|
192
|
+
placeholder={f.message.placeholder}
|
|
193
|
+
></textarea>
|
|
194
|
+
</div>
|
|
195
|
+
)}
|
|
196
|
+
|
|
197
|
+
{/* Honeypot — bots fill this; humans don't see it */}
|
|
198
|
+
<div aria-hidden="true" style="position:absolute;left:-9999px;width:1px;height:1px;overflow:hidden;">
|
|
199
|
+
<label for={`${id}-website`}>Website (leave blank)</label>
|
|
200
|
+
<input type="text" id={`${id}-website`} name="cj_hp_website" tabindex="-1" autocomplete="off" />
|
|
201
|
+
</div>
|
|
202
|
+
|
|
203
|
+
{recaptcha && (
|
|
204
|
+
<div class="g-recaptcha" data-sitekey={recaptcha.siteKey}></div>
|
|
205
|
+
)}
|
|
206
|
+
|
|
207
|
+
<button
|
|
208
|
+
type="submit"
|
|
209
|
+
class="cj-modal-submit w-full text-center font-semibold py-3 rounded-lg transition-colors"
|
|
210
|
+
>
|
|
211
|
+
{submitText}
|
|
212
|
+
</button>
|
|
213
|
+
</form>
|
|
214
|
+
</div>
|
|
215
|
+
</div>
|
|
216
|
+
</div>
|
|
217
|
+
</div>
|
|
218
|
+
|
|
219
|
+
<div
|
|
220
|
+
id={toastId}
|
|
221
|
+
class="cj-modal-toast fixed bottom-6 right-6 z-[110] bg-green-600 text-white px-6 py-3 rounded-lg shadow-lg transform translate-y-20 opacity-0 transition-all duration-300 pointer-events-none"
|
|
222
|
+
role="status"
|
|
223
|
+
aria-live="polite"
|
|
224
|
+
>
|
|
225
|
+
{thankYouMessage}
|
|
226
|
+
</div>
|
|
227
|
+
|
|
228
|
+
<style is:global>
|
|
229
|
+
:root {
|
|
230
|
+
--codejitsu-modal-accent: #2563eb;
|
|
231
|
+
--codejitsu-modal-accent-hover: #1d4ed8;
|
|
232
|
+
--codejitsu-modal-on-accent: #ffffff;
|
|
233
|
+
}
|
|
234
|
+
.cj-modal-input:focus {
|
|
235
|
+
border-color: var(--codejitsu-modal-accent);
|
|
236
|
+
box-shadow: 0 0 0 3px color-mix(in oklab, var(--codejitsu-modal-accent) 25%, transparent);
|
|
237
|
+
}
|
|
238
|
+
.cj-modal-submit {
|
|
239
|
+
background-color: var(--codejitsu-modal-accent);
|
|
240
|
+
color: var(--codejitsu-modal-on-accent);
|
|
241
|
+
}
|
|
242
|
+
.cj-modal-submit:hover:not(:disabled) {
|
|
243
|
+
background-color: var(--codejitsu-modal-accent-hover);
|
|
244
|
+
}
|
|
245
|
+
.cj-modal-submit:disabled {
|
|
246
|
+
opacity: 0.6;
|
|
247
|
+
cursor: not-allowed;
|
|
248
|
+
}
|
|
249
|
+
</style>
|
|
250
|
+
|
|
251
|
+
{recaptcha && (
|
|
252
|
+
<script is:inline src="https://www.google.com/recaptcha/api.js" async defer></script>
|
|
253
|
+
)}
|
|
254
|
+
|
|
255
|
+
<script is:inline src="https://cdn.jsdelivr.net/npm/@emailjs/browser@4/dist/email.min.js"></script>
|
|
256
|
+
<script is:inline define:vars={{ modalId: id, cfg: config }}>
|
|
257
|
+
// Per-modal IDs (one modal per page expected; this scopes to its DOM).
|
|
258
|
+
const overlay = document.getElementById(modalId + '-overlay');
|
|
259
|
+
const card = document.getElementById(modalId + '-card');
|
|
260
|
+
const closeBtn = document.getElementById(modalId + '-close');
|
|
261
|
+
const form = document.getElementById(modalId + '-form');
|
|
262
|
+
const toast = document.getElementById(modalId + '-toast');
|
|
263
|
+
|
|
264
|
+
if (!overlay || !card || !form) {
|
|
265
|
+
// Modal not present on this page; bail silently.
|
|
266
|
+
} else {
|
|
267
|
+
// Initialise EmailJS once per page load.
|
|
268
|
+
function initEmailJS() {
|
|
269
|
+
if (window.emailjs && !window.__cjEmailjsInitialized) {
|
|
270
|
+
window.emailjs.init({ publicKey: cfg.publicKey });
|
|
271
|
+
window.__cjEmailjsInitialized = true;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
initEmailJS();
|
|
275
|
+
document.addEventListener('DOMContentLoaded', initEmailJS);
|
|
276
|
+
|
|
277
|
+
let previouslyFocused = null;
|
|
278
|
+
|
|
279
|
+
function getFocusable() {
|
|
280
|
+
return Array.from(card.querySelectorAll(
|
|
281
|
+
'a[href], button:not([disabled]), input:not([disabled]), textarea:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])'
|
|
282
|
+
));
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function open() {
|
|
286
|
+
previouslyFocused = document.activeElement;
|
|
287
|
+
overlay.classList.remove('opacity-0', 'pointer-events-none');
|
|
288
|
+
overlay.classList.add('opacity-100');
|
|
289
|
+
overlay.setAttribute('aria-hidden', 'false');
|
|
290
|
+
overlay.removeAttribute('inert');
|
|
291
|
+
card.classList.remove('scale-95');
|
|
292
|
+
card.classList.add('scale-100');
|
|
293
|
+
document.body.style.overflow = 'hidden';
|
|
294
|
+
setTimeout(function () {
|
|
295
|
+
const f = getFocusable();
|
|
296
|
+
if (f.length) f[0].focus();
|
|
297
|
+
}, 100);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function close() {
|
|
301
|
+
overlay.classList.add('opacity-0', 'pointer-events-none');
|
|
302
|
+
overlay.classList.remove('opacity-100');
|
|
303
|
+
overlay.setAttribute('aria-hidden', 'true');
|
|
304
|
+
overlay.setAttribute('inert', '');
|
|
305
|
+
card.classList.add('scale-95');
|
|
306
|
+
card.classList.remove('scale-100');
|
|
307
|
+
document.body.style.overflow = '';
|
|
308
|
+
if (previouslyFocused && previouslyFocused.focus) {
|
|
309
|
+
previouslyFocused.focus();
|
|
310
|
+
previouslyFocused = null;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function showToast() {
|
|
315
|
+
if (!toast) return;
|
|
316
|
+
toast.classList.remove('translate-y-20', 'opacity-0');
|
|
317
|
+
toast.classList.add('translate-y-0', 'opacity-100');
|
|
318
|
+
setTimeout(function () {
|
|
319
|
+
toast.classList.add('translate-y-20', 'opacity-0');
|
|
320
|
+
toast.classList.remove('translate-y-0', 'opacity-100');
|
|
321
|
+
}, 4000);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// Triggers: data-codejitsu-contact-trigger or data-codejitsu-contact-trigger="<id>".
|
|
325
|
+
document.addEventListener('click', function (e) {
|
|
326
|
+
const trigger = e.target.closest('[data-codejitsu-contact-trigger]');
|
|
327
|
+
if (trigger) {
|
|
328
|
+
const wanted = trigger.getAttribute('data-codejitsu-contact-trigger');
|
|
329
|
+
if (!wanted || wanted === modalId) {
|
|
330
|
+
e.preventDefault();
|
|
331
|
+
open();
|
|
332
|
+
}
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
if (e.target.closest('#' + modalId + '-close')) {
|
|
336
|
+
close();
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
if (e.target === overlay) close();
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
// Esc + focus trap.
|
|
343
|
+
document.addEventListener('keydown', function (e) {
|
|
344
|
+
if (overlay.classList.contains('pointer-events-none')) return;
|
|
345
|
+
if (e.key === 'Escape') { close(); return; }
|
|
346
|
+
if (e.key === 'Tab') {
|
|
347
|
+
const f = getFocusable();
|
|
348
|
+
if (!f.length) return;
|
|
349
|
+
const first = f[0], last = f[f.length - 1];
|
|
350
|
+
if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
|
|
351
|
+
else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
|
|
352
|
+
}
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
// Submit handler.
|
|
356
|
+
form.addEventListener('submit', function (e) {
|
|
357
|
+
e.preventDefault();
|
|
358
|
+
|
|
359
|
+
// Honeypot
|
|
360
|
+
const hp = form.querySelector('input[name="cj_hp_website"]');
|
|
361
|
+
if (hp && hp.value) {
|
|
362
|
+
// Bot detected — pretend success, don't actually send.
|
|
363
|
+
form.reset();
|
|
364
|
+
close();
|
|
365
|
+
showToast();
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
if (!form.checkValidity()) {
|
|
370
|
+
form.reportValidity();
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
if (cfg.recaptchaSiteKey) {
|
|
375
|
+
const captchaResponse = form.querySelector('textarea[name="g-recaptcha-response"]');
|
|
376
|
+
if (!captchaResponse || !captchaResponse.value) {
|
|
377
|
+
alert('Please complete the reCAPTCHA before submitting.');
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
initEmailJS();
|
|
383
|
+
if (!window.emailjs) {
|
|
384
|
+
console.error('[codejitsu-contact] EmailJS SDK not loaded');
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
const submitBtn = form.querySelector('button[type="submit"]');
|
|
389
|
+
const originalText = submitBtn ? submitBtn.textContent : '';
|
|
390
|
+
if (submitBtn) {
|
|
391
|
+
submitBtn.disabled = true;
|
|
392
|
+
submitBtn.textContent = 'Sending…';
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
window.emailjs.sendForm(cfg.serviceId, cfg.templateId, form).then(function () {
|
|
396
|
+
// Fire custom event for site-side analytics / conversion tracking.
|
|
397
|
+
window.dispatchEvent(new CustomEvent('codejitsu-contact-submitted', {
|
|
398
|
+
detail: {
|
|
399
|
+
modalId,
|
|
400
|
+
formData: Object.fromEntries(new FormData(form).entries()),
|
|
401
|
+
},
|
|
402
|
+
}));
|
|
403
|
+
form.reset();
|
|
404
|
+
close();
|
|
405
|
+
showToast();
|
|
406
|
+
}).catch(function (err) {
|
|
407
|
+
console.error('[codejitsu-contact] EmailJS send failed', err);
|
|
408
|
+
alert('Sorry, something went wrong. Please call us or try again.');
|
|
409
|
+
}).finally(function () {
|
|
410
|
+
if (submitBtn) {
|
|
411
|
+
submitBtn.disabled = false;
|
|
412
|
+
submitBtn.textContent = originalText;
|
|
413
|
+
}
|
|
414
|
+
if (window.grecaptcha && typeof window.grecaptcha.reset === 'function') {
|
|
415
|
+
try { window.grecaptcha.reset(); } catch (e) {}
|
|
416
|
+
}
|
|
417
|
+
});
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
</script>
|
|
@@ -198,6 +198,12 @@ async function generateContentScan({ config, cwd }) {
|
|
|
198
198
|
const llms = config.llms;
|
|
199
199
|
const scan = llms.contentScan ?? {};
|
|
200
200
|
|
|
201
|
+
// Date + draft field names come from the blog module config; CC schemas like
|
|
202
|
+
// pearl's use `pubDate` + `draft`, while simpler sites use `date` (+ no draft).
|
|
203
|
+
const blogCfg = config.blog && typeof config.blog === 'object' ? config.blog : {};
|
|
204
|
+
const dateField = blogCfg.dateField ?? 'date';
|
|
205
|
+
const draftField = blogCfg.draftField ?? null;
|
|
206
|
+
|
|
201
207
|
const servicesDir = scan.servicesDir ? path.resolve(cwd, scan.servicesDir) : null;
|
|
202
208
|
const locationsDir = scan.locationsDir ? path.resolve(cwd, scan.locationsDir) : null;
|
|
203
209
|
const pagesDir = scan.pagesDir ? path.resolve(cwd, scan.pagesDir) : null;
|
|
@@ -205,10 +211,7 @@ async function generateContentScan({ config, cwd }) {
|
|
|
205
211
|
|
|
206
212
|
const services = readContentDir(servicesDir);
|
|
207
213
|
const locations = readContentDir(locationsDir);
|
|
208
|
-
const blogPosts = readBlogPosts(blogDir,
|
|
209
|
-
// Also try 'date' field for fallback
|
|
210
|
-
blogDir && readBlogPosts(blogDir, 'date', 'draft').filter((p) => !p.pubDate) || []
|
|
211
|
-
);
|
|
214
|
+
const blogPosts = readBlogPosts(blogDir, dateField, draftField);
|
|
212
215
|
const pages = pagesDir ? collectStaticPages(pagesDir) : [];
|
|
213
216
|
|
|
214
217
|
const dynamicRoutes = scan.dynamicRoutes ?? [];
|
|
@@ -239,6 +242,7 @@ async function generateContentScan({ config, cwd }) {
|
|
|
239
242
|
business: site.business,
|
|
240
243
|
services,
|
|
241
244
|
locations,
|
|
245
|
+
blogPosts: blogPosts.slice(0, llms.blogFullLimit ?? 20),
|
|
242
246
|
aiGuidance: llms.aiGuidance,
|
|
243
247
|
today: isoDate(),
|
|
244
248
|
});
|
|
@@ -372,7 +376,7 @@ function renderContentScanConcise({ siteUrl, siteName, tagline, about, business,
|
|
|
372
376
|
return lines.join('\n') + '\n';
|
|
373
377
|
}
|
|
374
378
|
|
|
375
|
-
function renderContentScanFull({ siteUrl, siteName, tagline, about, business, services, locations, aiGuidance, today }) {
|
|
379
|
+
function renderContentScanFull({ siteUrl, siteName, tagline, about, business, services, locations, blogPosts, aiGuidance, today }) {
|
|
376
380
|
const lines = [];
|
|
377
381
|
lines.push(`# ${siteName} — Full Reference`);
|
|
378
382
|
lines.push(`Last Updated: ${today}`, '');
|
|
@@ -431,6 +435,19 @@ function renderContentScanFull({ siteUrl, siteName, tagline, about, business, se
|
|
|
431
435
|
lines.push('', '---', '');
|
|
432
436
|
}
|
|
433
437
|
|
|
438
|
+
if (blogPosts && blogPosts.length) {
|
|
439
|
+
lines.push('## Blog Posts', '');
|
|
440
|
+
for (const post of blogPosts) {
|
|
441
|
+
lines.push(`### ${post.title}`, '');
|
|
442
|
+
if (post.date) lines.push(`**Published**: ${post.date}`);
|
|
443
|
+
if (post.author) lines.push(`**Author**: ${post.author}`);
|
|
444
|
+
if (post.tags?.length) lines.push(`**Tags**: ${post.tags.join(', ')}`);
|
|
445
|
+
lines.push(`**URL**: ${siteUrl}/blog/${post.slug}/`, '');
|
|
446
|
+
if (post.description) lines.push(post.description, '');
|
|
447
|
+
lines.push('---', '');
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
434
451
|
lines.push(`## Optional`, '', `- Sitemap: ${siteUrl}/sitemap-index.xml`, '');
|
|
435
452
|
if (aiGuidance) lines.push('## For AI Assistants', '', aiGuidance, '');
|
|
436
453
|
return lines.join('\n') + '\n';
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# Rehype module — instructions for Claude
|
|
2
|
+
|
|
3
|
+
When the user asks to **fix trailing-slash bugs in markdown content** (or pre-empt them across a Codejitsu site), wire up `rehypeTrailingSlash`.
|
|
4
|
+
|
|
5
|
+
## What this module provides
|
|
6
|
+
|
|
7
|
+
A single rehype plugin: `trailingSlash`. Runs during Astro's markdown→HTML conversion. Walks the HTML AST and rewrites internal `<a href="/foo">` to `<a href="/foo/">` (or vice versa with `policy: 'never'`).
|
|
8
|
+
|
|
9
|
+
**Why this exists:** Astro's `trailingSlash: 'always'` config covers route resolution and `Astro.url.pathname` but does NOT touch href strings written by humans in markdown or `.astro` files. This plugin closes that gap for markdown-rendered HTML.
|
|
10
|
+
|
|
11
|
+
It does **not** affect href strings inside `.astro` component source (Astro doesn't run rehype on those). Use the audit to catch those.
|
|
12
|
+
|
|
13
|
+
## Wiring it into a site
|
|
14
|
+
|
|
15
|
+
```ts
|
|
16
|
+
// astro.config.mjs
|
|
17
|
+
import { defineConfig } from 'astro/config';
|
|
18
|
+
import trailingSlash from '@ibalzam/codejitsu-core/rehype/trailing-slash';
|
|
19
|
+
|
|
20
|
+
export default defineConfig({
|
|
21
|
+
markdown: {
|
|
22
|
+
rehypePlugins: [trailingSlash],
|
|
23
|
+
},
|
|
24
|
+
});
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
With explicit options:
|
|
28
|
+
|
|
29
|
+
```ts
|
|
30
|
+
rehypePlugins: [
|
|
31
|
+
[trailingSlash, { policy: 'always' }],
|
|
32
|
+
],
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## What it does NOT touch
|
|
36
|
+
|
|
37
|
+
- External URLs (`http://`, `https://`, `//`)
|
|
38
|
+
- `mailto:`, `tel:`, `javascript:`
|
|
39
|
+
- Anchor-only links (`#section`)
|
|
40
|
+
- Paths ending in a file extension (`.pdf`, `.html`, `.webp`, etc.)
|
|
41
|
+
- Root path (`/`)
|
|
42
|
+
|
|
43
|
+
## What it DOES touch
|
|
44
|
+
|
|
45
|
+
- `<a href="/foo">` → `<a href="/foo/">`
|
|
46
|
+
- `<a href="/foo?bar=1">` → `<a href="/foo/?bar=1">`
|
|
47
|
+
- `<a href="/foo#section">` → `<a href="/foo/#section">`
|
|
48
|
+
|
|
49
|
+
Preserves query strings and fragments. Path-only modification.
|
|
50
|
+
|
|
51
|
+
## What must NOT be done
|
|
52
|
+
|
|
53
|
+
- **Don't apply this to `.astro` component files** — the plugin runs on markdown rehype, not Astro components. If a `<a href="/foo">` lives in a `.astro` file, the plugin can't see it.
|
|
54
|
+
- **Don't set `policy: 'never'` if `astro.config` has `trailingSlash: 'always'`** — they'd contradict each other. The audit will flag the inconsistency.
|
|
55
|
+
- **Don't run this with `policy: 'preserve'` and expect anything to change** — that mode is a no-op (registered as a placeholder for symmetry).
|
|
56
|
+
|
|
57
|
+
## Verify after wiring
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
npm run build
|
|
61
|
+
npx codejitsu audit
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
The audit's "All internal links end with /" check should now report 0 markdown-level offenders. Component-level offenders (in `.astro` files) still surface — those must be fixed by hand.
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rehype plugin that enforces a trailing-slash policy on internal `<a href>`
|
|
3
|
+
* values produced by markdown content. Astro's `trailingSlash: 'always'`
|
|
4
|
+
* controls page routing but does NOT rewrite hand-written hrefs in markdown
|
|
5
|
+
* or component templates. This plugin fills that gap for markdown content.
|
|
6
|
+
*
|
|
7
|
+
* Usage in astro.config.mjs:
|
|
8
|
+
*
|
|
9
|
+
* import trailingSlash from '@ibalzam/codejitsu-core/rehype/trailing-slash';
|
|
10
|
+
*
|
|
11
|
+
* export default defineConfig({
|
|
12
|
+
* markdown: {
|
|
13
|
+
* rehypePlugins: [trailingSlash],
|
|
14
|
+
* },
|
|
15
|
+
* });
|
|
16
|
+
*
|
|
17
|
+
* Or with options:
|
|
18
|
+
*
|
|
19
|
+
* rehypePlugins: [[trailingSlash, { policy: 'always' }]]
|
|
20
|
+
*
|
|
21
|
+
* What it skips (leaves untouched):
|
|
22
|
+
* - External URLs (http://, https://, //, mailto:, tel:, etc.)
|
|
23
|
+
* - Anchor-only links (#section)
|
|
24
|
+
* - Paths ending in a file extension (.pdf, .html, .webp, ...)
|
|
25
|
+
* - Root path `/`
|
|
26
|
+
*
|
|
27
|
+
* @param {object} [opts]
|
|
28
|
+
* @param {'always' | 'never' | 'preserve'} [opts.policy='always']
|
|
29
|
+
*/
|
|
30
|
+
export default function rehypeTrailingSlash(opts = {}) {
|
|
31
|
+
const policy = opts.policy ?? 'always';
|
|
32
|
+
if (policy === 'preserve') return () => {};
|
|
33
|
+
|
|
34
|
+
return (tree) => {
|
|
35
|
+
walk(tree, (node) => {
|
|
36
|
+
if (node.tagName !== 'a') return;
|
|
37
|
+
const href = node.properties?.href;
|
|
38
|
+
if (typeof href !== 'string') return;
|
|
39
|
+
|
|
40
|
+
const normalized = normalize(href, policy);
|
|
41
|
+
if (normalized !== href) {
|
|
42
|
+
node.properties.href = normalized;
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function walk(node, fn) {
|
|
49
|
+
if (node?.type === 'element') fn(node);
|
|
50
|
+
if (Array.isArray(node?.children)) {
|
|
51
|
+
for (const child of node.children) walk(child, fn);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Apply policy to a single href. Pure, no side-effects — exported for tests.
|
|
57
|
+
*
|
|
58
|
+
* @param {string} href
|
|
59
|
+
* @param {'always' | 'never'} policy
|
|
60
|
+
*/
|
|
61
|
+
export function normalize(href, policy) {
|
|
62
|
+
if (!href.startsWith('/')) return href; // external, anchor, relative — skip
|
|
63
|
+
if (href.startsWith('//')) return href; // protocol-relative external
|
|
64
|
+
if (href === '/') return href; // root is its own canonical
|
|
65
|
+
|
|
66
|
+
// Split path / query / fragment so we don't break /foo?bar=baz or /foo#anchor.
|
|
67
|
+
const m = href.match(/^([^?#]*)(\?[^#]*)?(#.*)?$/);
|
|
68
|
+
if (!m) return href;
|
|
69
|
+
let path = m[1];
|
|
70
|
+
const query = m[2] ?? '';
|
|
71
|
+
const fragment = m[3] ?? '';
|
|
72
|
+
|
|
73
|
+
if (!path || path === '/') return href;
|
|
74
|
+
|
|
75
|
+
// Last segment with a `.` is likely a file (e.g. /robots.txt, /og-image.webp).
|
|
76
|
+
const lastSeg = path.split('/').filter(Boolean).pop() ?? '';
|
|
77
|
+
if (lastSeg.includes('.')) return href;
|
|
78
|
+
|
|
79
|
+
const endsWithSlash = path.endsWith('/');
|
|
80
|
+
|
|
81
|
+
if (policy === 'always' && !endsWithSlash) {
|
|
82
|
+
path = `${path}/`;
|
|
83
|
+
} else if (policy === 'never' && endsWithSlash) {
|
|
84
|
+
path = path.replace(/\/+$/, '');
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return `${path}${query}${fragment}`;
|
|
88
|
+
}
|