@neuctra/ui-mcp 0.4.2 → 0.4.4

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/src/seo.mjs ADDED
@@ -0,0 +1,1106 @@
1
+ // Pure, deterministic SEO builders behind the generate_page_seo and
2
+ // audit_page_seo tools. No I/O and no LLM calls: the same input always gives
3
+ // the same output, so it runs identically on Node and on edge runtimes.
4
+ //
5
+ // The @id scheme and node shapes mirror neuctra-ui-docs-v2/lib/seo/schemas
6
+ // (Organization at `${site}/#organization`, WebSite at `${site}/#website`,
7
+ // pages linked via isPartOf), so a site built with these snippets weaves the
8
+ // same graph as the Neuctra UI docs do.
9
+
10
+ export const FRAMEWORKS = [
11
+ "nextjs-app",
12
+ "nextjs-pages",
13
+ "react-vite",
14
+ "react-router",
15
+ "astro",
16
+ "tanstack-start",
17
+ "gatsby",
18
+ "html",
19
+ ];
20
+
21
+ export const PAGE_TYPES = [
22
+ "home",
23
+ "docs",
24
+ "blog",
25
+ "product",
26
+ "tool",
27
+ "pricing",
28
+ "landing",
29
+ "faq",
30
+ "about",
31
+ "contact",
32
+ "category",
33
+ "other",
34
+ ];
35
+
36
+ export const TITLE_MAX = 60;
37
+ export const DESCRIPTION_MAX = 155;
38
+
39
+ // ---------------------------------------------------------------------------
40
+ // Small helpers
41
+ // ---------------------------------------------------------------------------
42
+
43
+ function capitalize(text) {
44
+ return text.replace(/^\s*\w/, (c) => c.toUpperCase());
45
+ }
46
+
47
+ const SMALL_WORDS = new Set(["a", "an", "and", "as", "at", "by", "for", "in", "of", "on", "or", "the", "to", "vs", "with"]);
48
+
49
+ /** "react card component" -> "React Card Component"; keeps words that already have capitals or digits (v4, UI, SaaS). */
50
+ function titleCase(text) {
51
+ return text
52
+ .trim()
53
+ .split(/\s+/)
54
+ .map((w, i) =>
55
+ /[A-Z0-9]/.test(w) || (i > 0 && SMALL_WORDS.has(w))
56
+ ? w
57
+ : w[0].toUpperCase() + w.slice(1),
58
+ )
59
+ .join(" ");
60
+ }
61
+
62
+ function titleCaseSlug(segment) {
63
+ return segment
64
+ .split(/[-_]/)
65
+ .filter(Boolean)
66
+ .map((w) => w[0].toUpperCase() + w.slice(1))
67
+ .join(" ");
68
+ }
69
+
70
+ /** Cuts at a word boundary so a trimmed title/description never ends mid-word. */
71
+ function trimToWords(text, max) {
72
+ if (text.length <= max) return text;
73
+ const cut = text.slice(0, max + 1);
74
+ const lastSpace = cut.lastIndexOf(" ");
75
+ return cut
76
+ .slice(0, lastSpace > max * 0.6 ? lastSpace : max)
77
+ .replace(/[\s,;:|.-]+$/, "");
78
+ }
79
+
80
+ function normalizeSiteUrl(siteUrl) {
81
+ return siteUrl.replace(/\/+$/, "");
82
+ }
83
+
84
+ function normalizePath(path) {
85
+ if (!path || path === "/") return "/";
86
+ return `/${path.replace(/^\/+/, "")}`;
87
+ }
88
+
89
+ export function absoluteUrl(siteUrl, path) {
90
+ const site = normalizeSiteUrl(siteUrl);
91
+ const p = normalizePath(path);
92
+ return p === "/" ? `${site}/` : `${site}${p}`;
93
+ }
94
+
95
+ /** JSON for a <script type="application/ld+json">: escapes `<` so page text can't close the tag. */
96
+ export function jsonLdString(data, space = 2) {
97
+ return JSON.stringify(data, null, space).replace(/</g, "\\u003c");
98
+ }
99
+
100
+ function escapeAttr(value) {
101
+ return String(value)
102
+ .replace(/&/g, "&amp;")
103
+ .replace(/"/g, "&quot;")
104
+ .replace(/</g, "&lt;")
105
+ // Braces would open a JSX expression inside <title>; entities are valid in both HTML and JSX.
106
+ .replace(/\{/g, "&#123;")
107
+ .replace(/\}/g, "&#125;");
108
+ }
109
+
110
+ // ---------------------------------------------------------------------------
111
+ // Title and description
112
+ // ---------------------------------------------------------------------------
113
+
114
+ export function buildTitle({ title, primaryKeyword, pageType, siteName }) {
115
+ const warnings = [];
116
+ let value;
117
+
118
+ if (title) {
119
+ value = title.trim();
120
+ const suffix = ` | ${siteName}`;
121
+ if (
122
+ pageType !== "home" &&
123
+ !value.toLowerCase().includes(siteName.toLowerCase()) &&
124
+ value.length + suffix.length <= TITLE_MAX
125
+ ) {
126
+ value += suffix;
127
+ }
128
+ if (
129
+ pageType !== "home" &&
130
+ !value.toLowerCase().includes(siteName.toLowerCase()) &&
131
+ value.length + suffix.length > TITLE_MAX
132
+ ) {
133
+ warnings.push(
134
+ `Brand suffix "${suffix.trim()}" was not added: it would push the title past ${TITLE_MAX} characters. Shorten the title by ${value.length + suffix.length - TITLE_MAX} characters so every page carries the same suffix.`,
135
+ );
136
+ }
137
+ } else if (pageType === "home") {
138
+ value = `${siteName}: ${titleCase(primaryKeyword)}`;
139
+ } else {
140
+ value = `${titleCase(primaryKeyword)} | ${siteName}`;
141
+ }
142
+
143
+ if (value.length > TITLE_MAX) {
144
+ warnings.push(
145
+ `Title is ${value.length} characters; trimmed to ${TITLE_MAX}. Shorten the qualifier so the keyword and brand both survive.`,
146
+ );
147
+ value = trimToWords(value, TITLE_MAX);
148
+ }
149
+ if (value.length < 30) {
150
+ warnings.push(
151
+ `Title is only ${value.length} characters. Add a qualifier (benefit, audience or use case) to use the space Google shows.`,
152
+ );
153
+ }
154
+ if (
155
+ primaryKeyword &&
156
+ !value.toLowerCase().includes(primaryKeyword.toLowerCase())
157
+ ) {
158
+ warnings.push(
159
+ `Title does not contain the primary keyword "${primaryKeyword}". Put it near the start.`,
160
+ );
161
+ }
162
+ return { value, length: value.length, warnings };
163
+ }
164
+
165
+ const DESCRIPTION_TEMPLATES = {
166
+ home: (k, s) =>
167
+ `${s} is ${k}. See what it does, how it works and how to get started in minutes.`,
168
+ docs: (k, s) =>
169
+ `Learn how to use ${k} with ${s}: examples, props, accessibility notes and best practices. Copy the code and ship faster.`,
170
+ blog: (k) =>
171
+ `A practical guide to ${k}: what it is, why it matters and step-by-step examples you can apply today.`,
172
+ product: (k, s) =>
173
+ `${capitalize(k)} by ${s}. See features, use cases and how it compares, then start free today.`,
174
+ pricing: (k, s) =>
175
+ `${s} pricing for ${k}: compare plans, features and limits, and pick the plan that fits your team.`,
176
+ landing: (k, s) =>
177
+ `${capitalize(k)} with ${s}. See how it works, what you get and start in minutes.`,
178
+ faq: (k, s) =>
179
+ `Answers to the most common questions about ${k} and ${s}: setup, features, pricing and support.`,
180
+ about: (k, s) =>
181
+ `About ${s}: the team, the mission and the story behind ${k}.`,
182
+ contact: (k, s) =>
183
+ `Contact ${s} about ${k}: sales, support and partnerships. We usually reply within one business day.`,
184
+ category: (k, s) =>
185
+ `Browse ${k} on ${s}: compare options, filter by what matters and find the right fit.`,
186
+ tool: (k, s) =>
187
+ `${capitalize(k)} with ${s}. Works right in your browser: fast, private and free to use. Try it now.`,
188
+ other: (k, s) => `${capitalize(k)} on ${s}.`,
189
+ };
190
+
191
+ export function buildDescription({
192
+ description,
193
+ primaryKeyword,
194
+ pageType,
195
+ siteName,
196
+ }) {
197
+ const warnings = [];
198
+ let value;
199
+
200
+ if (description) {
201
+ value = description.trim();
202
+ } else {
203
+ value = DESCRIPTION_TEMPLATES[pageType](primaryKeyword, siteName);
204
+ warnings.push(
205
+ "Description was generated from a template. Rewrite it with this page's real, specific value: templates get ignored or rewritten by Google.",
206
+ );
207
+ }
208
+
209
+ if (value.length > DESCRIPTION_MAX) {
210
+ warnings.push(
211
+ `Description is ${value.length} characters; trimmed to ${DESCRIPTION_MAX}.`,
212
+ );
213
+ value = trimToWords(value, DESCRIPTION_MAX);
214
+ if (!/[.!?]$/.test(value)) value += ".";
215
+ if (value.length > DESCRIPTION_MAX) value = trimToWords(value, DESCRIPTION_MAX - 1) + ".";
216
+ }
217
+ if (value.length < 120) {
218
+ warnings.push(
219
+ `Description is ${value.length} characters. Aim for 140 to 155 so the full value proposition shows.`,
220
+ );
221
+ }
222
+ if (
223
+ primaryKeyword &&
224
+ !value.toLowerCase().includes(primaryKeyword.toLowerCase())
225
+ ) {
226
+ warnings.push(
227
+ `Description does not mention the primary keyword "${primaryKeyword}". Google bolds matching terms, which lifts clicks.`,
228
+ );
229
+ }
230
+ return { value, length: value.length, warnings };
231
+ }
232
+
233
+ // ---------------------------------------------------------------------------
234
+ // Schema weaving
235
+ // ---------------------------------------------------------------------------
236
+
237
+ const WEBPAGE_TYPE = {
238
+ faq: "FAQPage",
239
+ about: "AboutPage",
240
+ contact: "ContactPage",
241
+ category: "CollectionPage",
242
+ };
243
+
244
+ export function defaultBreadcrumbs(path, pageName) {
245
+ const segments = normalizePath(path).split("/").filter(Boolean);
246
+ const crumbs = [{ name: "Home", path: "/" }];
247
+ segments.forEach((segment, i) => {
248
+ const isLast = i === segments.length - 1;
249
+ crumbs.push({
250
+ name: isLast && pageName ? pageName : titleCaseSlug(segment),
251
+ path: `/${segments.slice(0, i + 1).join("/")}`,
252
+ });
253
+ });
254
+ return crumbs;
255
+ }
256
+
257
+ export function buildSchemaGraph(input, meta) {
258
+ const site = normalizeSiteUrl(input.siteUrl);
259
+ const url = absoluteUrl(input.siteUrl, input.path);
260
+ const ids = {
261
+ organization: `${site}/#organization`,
262
+ website: `${site}/#website`,
263
+ webpage: `${url}#webpage`,
264
+ breadcrumb: `${url}#breadcrumb`,
265
+ faq: `${url}#faq`,
266
+ };
267
+ const graph = [];
268
+ const warnings = [];
269
+
270
+ const organization = {
271
+ "@type": "Organization",
272
+ "@id": ids.organization,
273
+ name: input.siteName,
274
+ url: `${site}/`,
275
+ };
276
+ if (input.logo) organization.logo = input.logo;
277
+ if (input.sameAs?.length) organization.sameAs = input.sameAs;
278
+ graph.push(organization);
279
+
280
+ graph.push({
281
+ "@type": "WebSite",
282
+ "@id": ids.website,
283
+ url: `${site}/`,
284
+ name: input.siteName,
285
+ publisher: { "@id": ids.organization },
286
+ inLanguage: input.language,
287
+ });
288
+
289
+ const isFaqPage = input.pageType === "faq";
290
+ const webpage = {
291
+ "@type": WEBPAGE_TYPE[input.pageType] ?? "WebPage",
292
+ "@id": ids.webpage,
293
+ url,
294
+ name: meta.title,
295
+ description: meta.description,
296
+ isPartOf: { "@id": ids.website },
297
+ inLanguage: input.language,
298
+ };
299
+ if (input.pageType !== "home") webpage.breadcrumb = { "@id": ids.breadcrumb };
300
+ if (input.pageType === "home") webpage.about = { "@id": ids.organization };
301
+ if (input.ogImage) webpage.primaryImageOfPage = { "@type": "ImageObject", url: input.ogImage };
302
+ if (input.datePublished) webpage.datePublished = input.datePublished;
303
+ if (input.dateModified) webpage.dateModified = input.dateModified;
304
+ graph.push(webpage);
305
+
306
+ if (input.pageType !== "home") {
307
+ const crumbs = input.breadcrumbs?.length
308
+ ? input.breadcrumbs
309
+ : defaultBreadcrumbs(input.path, input.pageName);
310
+ graph.push({
311
+ "@type": "BreadcrumbList",
312
+ "@id": ids.breadcrumb,
313
+ itemListElement: crumbs.map((c, i) => ({
314
+ "@type": "ListItem",
315
+ position: i + 1,
316
+ name: c.name,
317
+ item: absoluteUrl(input.siteUrl, c.path),
318
+ })),
319
+ });
320
+ }
321
+
322
+ const headline = input.pageName ?? titleCase(input.primaryKeyword);
323
+ const common = {
324
+ mainEntityOfPage: { "@id": ids.webpage },
325
+ publisher: { "@id": ids.organization },
326
+ inLanguage: input.language,
327
+ };
328
+ const author = input.author
329
+ ? { "@type": "Person", name: input.author }
330
+ : { "@id": ids.organization };
331
+
332
+ if (input.pageType === "docs") {
333
+ graph.push({
334
+ "@type": "TechArticle",
335
+ "@id": `${url}#article`,
336
+ headline,
337
+ description: meta.description,
338
+ author,
339
+ ...(input.datePublished && { datePublished: input.datePublished }),
340
+ ...(input.dateModified && { dateModified: input.dateModified }),
341
+ ...common,
342
+ });
343
+ } else if (input.pageType === "blog") {
344
+ graph.push({
345
+ "@type": "BlogPosting",
346
+ "@id": `${url}#article`,
347
+ headline: trimToWords(headline, 110),
348
+ description: meta.description,
349
+ author,
350
+ ...(input.ogImage && { image: input.ogImage }),
351
+ ...(input.datePublished && { datePublished: input.datePublished }),
352
+ ...(input.dateModified && { dateModified: input.dateModified }),
353
+ ...common,
354
+ });
355
+ if (!input.datePublished) warnings.push("Blog posts need datePublished (and dateModified when updated) for Article rich results and freshness.");
356
+ if (!input.author) warnings.push("Add a real author name: BlogPosting without a Person author is a weak E-E-A-T signal.");
357
+ if (!input.ogImage) warnings.push("Add an ogImage: Article rich results require an image.");
358
+ } else if (["product", "tool", "pricing"].includes(input.pageType)) {
359
+ const isTool = input.pageType === "tool";
360
+ const node = {
361
+ "@type": isTool ? "SoftwareApplication" : input.productType ?? "SoftwareApplication",
362
+ "@id": `${url}#product`,
363
+ name: headline,
364
+ description: meta.description,
365
+ ...(input.ogImage && { image: input.ogImage }),
366
+ ...common,
367
+ };
368
+ delete node.inLanguage;
369
+ if (node["@type"] === "SoftwareApplication") {
370
+ node.applicationCategory =
371
+ input.applicationCategory ?? (isTool ? "UtilitiesApplication" : "DeveloperApplication");
372
+ node.operatingSystem = "Web";
373
+ if (input.features?.length) node.featureList = input.features;
374
+ } else {
375
+ node.brand = { "@id": ids.organization };
376
+ }
377
+ if (input.isFree) node.isAccessibleForFree = true;
378
+ if (input.offers?.length) {
379
+ node.offers = input.offers.map((o) => ({
380
+ "@type": "Offer",
381
+ ...(o.name && { name: o.name }),
382
+ price: String(o.price),
383
+ priceCurrency: o.priceCurrency,
384
+ url,
385
+ }));
386
+ } else if (input.isFree) {
387
+ node.offers = { "@type": "Offer", price: "0", priceCurrency: input.currency ?? "USD", url };
388
+ } else {
389
+ warnings.push(
390
+ `No offers given: the ${node["@type"]} node has no price. Pass isFree for a free tool, or offers with real, visible prices; never invent them.`,
391
+ );
392
+ }
393
+ if (isTool && !input.features?.length) {
394
+ warnings.push("Pass features (3 to 8 short capabilities) so the SoftwareApplication gets a featureList.");
395
+ }
396
+ graph.push(node);
397
+ }
398
+
399
+ // Visible usage steps -> HowTo (built from the same array as the on-page <ol>).
400
+ if (input.steps?.length) {
401
+ graph.push({
402
+ "@type": "HowTo",
403
+ "@id": `${url}#howto`,
404
+ name: input.howToName ?? `How to use ${headline}`,
405
+ step: input.steps.map((text, i) => ({
406
+ "@type": "HowToStep",
407
+ position: i + 1,
408
+ name: text.length > 80 ? trimToWords(text, 80) : text,
409
+ text,
410
+ })),
411
+ isPartOf: { "@id": ids.webpage },
412
+ });
413
+ } else if (input.pageType === "tool") {
414
+ warnings.push("Pass steps (the visible \"How to\" list) to add a HowTo node built from the same data as the page.");
415
+ }
416
+
417
+ // Listing pages -> ItemList of the linked pages, in visible order.
418
+ if (input.items?.length) {
419
+ graph.push({
420
+ "@type": "ItemList",
421
+ "@id": `${url}#itemlist`,
422
+ itemListElement: input.items.map((it, i) => ({
423
+ "@type": "ListItem",
424
+ position: i + 1,
425
+ name: it.name,
426
+ url: absoluteUrl(input.siteUrl, it.path),
427
+ })),
428
+ });
429
+ webpage.mainEntity = webpage.mainEntity ?? { "@id": `${url}#itemlist` };
430
+ } else if (input.pageType === "category") {
431
+ warnings.push("Pass items (the pages this category links to) to add an ItemList for the CollectionPage.");
432
+ }
433
+
434
+ if (input.faqs?.length) {
435
+ const questions = input.faqs.map((f) => ({
436
+ "@type": "Question",
437
+ name: f.question,
438
+ acceptedAnswer: { "@type": "Answer", text: f.answer },
439
+ }));
440
+ if (isFaqPage) {
441
+ webpage.mainEntity = questions;
442
+ } else {
443
+ graph.push({
444
+ "@type": "FAQPage",
445
+ "@id": ids.faq,
446
+ isPartOf: { "@id": ids.webpage },
447
+ mainEntity: questions,
448
+ });
449
+ }
450
+ } else if (isFaqPage) {
451
+ warnings.push("pageType is \"faq\" but no faqs were given: FAQPage needs its visible questions in mainEntity.");
452
+ }
453
+
454
+ return { jsonLd: { "@context": "https://schema.org", "@graph": graph }, warnings };
455
+ }
456
+
457
+ // ---------------------------------------------------------------------------
458
+ // Meta tags and framework snippets
459
+ // ---------------------------------------------------------------------------
460
+
461
+ function buildMetaObject(input, meta) {
462
+ const url = absoluteUrl(input.siteUrl, input.path);
463
+ return {
464
+ title: meta.title,
465
+ description: meta.description,
466
+ canonical: url,
467
+ robots: input.noindex
468
+ ? "noindex, follow"
469
+ : "index, follow, max-image-preview:large, max-snippet:-1, max-video-preview:-1",
470
+ openGraph: {
471
+ type: input.pageType === "blog" ? "article" : "website",
472
+ siteName: input.siteName,
473
+ title: input.ogTitle ?? meta.title,
474
+ description: input.ogDescription ?? meta.description,
475
+ url,
476
+ ...(input.ogImage && { image: input.ogImage, imageWidth: 1200, imageHeight: 630 }),
477
+ ...(input.pageType === "blog" && input.datePublished && { publishedTime: input.datePublished }),
478
+ ...(input.pageType === "blog" && input.dateModified && { modifiedTime: input.dateModified }),
479
+ },
480
+ twitter: {
481
+ card: input.ogImage ? "summary_large_image" : "summary",
482
+ title: input.ogTitle ?? meta.title,
483
+ description: input.ogDescription ?? meta.description,
484
+ ...(input.ogImage && { image: input.ogImage }),
485
+ },
486
+ };
487
+ }
488
+
489
+ /** The <head> tags shared by every JSX/HTML-style framework. */
490
+ function headTags(m) {
491
+ const tags = [
492
+ `<title>${escapeAttr(m.title)}</title>`,
493
+ `<meta name="description" content="${escapeAttr(m.description)}" />`,
494
+ `<link rel="canonical" href="${m.canonical}" />`,
495
+ `<meta name="robots" content="${m.robots}" />`,
496
+ `<meta property="og:type" content="${m.openGraph.type}" />`,
497
+ `<meta property="og:site_name" content="${escapeAttr(m.openGraph.siteName)}" />`,
498
+ `<meta property="og:title" content="${escapeAttr(m.openGraph.title)}" />`,
499
+ `<meta property="og:description" content="${escapeAttr(m.openGraph.description)}" />`,
500
+ `<meta property="og:url" content="${m.canonical}" />`,
501
+ ];
502
+ if (m.openGraph.image) {
503
+ tags.push(
504
+ `<meta property="og:image" content="${m.openGraph.image}" />`,
505
+ `<meta property="og:image:width" content="1200" />`,
506
+ `<meta property="og:image:height" content="630" />`,
507
+ );
508
+ }
509
+ if (m.openGraph.publishedTime) tags.push(`<meta property="article:published_time" content="${m.openGraph.publishedTime}" />`);
510
+ if (m.openGraph.modifiedTime) tags.push(`<meta property="article:modified_time" content="${m.openGraph.modifiedTime}" />`);
511
+ tags.push(
512
+ `<meta name="twitter:card" content="${m.twitter.card}" />`,
513
+ `<meta name="twitter:title" content="${escapeAttr(m.twitter.title)}" />`,
514
+ `<meta name="twitter:description" content="${escapeAttr(m.twitter.description)}" />`,
515
+ );
516
+ if (m.twitter.image) tags.push(`<meta name="twitter:image" content="${m.twitter.image}" />`);
517
+ return tags;
518
+ }
519
+
520
+ function indent(lines, spaces) {
521
+ const pad = " ".repeat(spaces);
522
+ return lines.map((l) => (l ? pad + l : l)).join("\n");
523
+ }
524
+
525
+ function jsonLdJs(jsonLd) {
526
+ // Pretty JSON is valid JS object-literal syntax, so it can be pasted as `const jsonLd = ...`.
527
+ return JSON.stringify(jsonLd, null, 2);
528
+ }
529
+
530
+ const JSX_SCRIPT = `<script
531
+ type="application/ld+json"
532
+ dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd).replace(/</g, "\\\\u003c") }}
533
+ />`;
534
+
535
+ export function renderSnippet(framework, m, jsonLd) {
536
+ const q = JSON.stringify;
537
+ switch (framework) {
538
+ case "nextjs-app": {
539
+ const og = {
540
+ title: m.openGraph.title,
541
+ description: m.openGraph.description,
542
+ url: m.canonical,
543
+ siteName: m.openGraph.siteName,
544
+ type: m.openGraph.type,
545
+ ...(m.openGraph.image && {
546
+ images: [{ url: m.openGraph.image, width: 1200, height: 630 }],
547
+ }),
548
+ ...(m.openGraph.publishedTime && { publishedTime: m.openGraph.publishedTime }),
549
+ ...(m.openGraph.modifiedTime && { modifiedTime: m.openGraph.modifiedTime }),
550
+ };
551
+ const indexable = !m.robots.startsWith("noindex");
552
+ const metadata = {
553
+ // absolute: the title already carries the brand, so a root
554
+ // `title.template` ("%s | Brand") must not add it a second time.
555
+ title: { absolute: m.title },
556
+ description: m.description,
557
+ alternates: { canonical: m.canonical },
558
+ robots: {
559
+ index: indexable,
560
+ follow: true,
561
+ googleBot: {
562
+ index: indexable,
563
+ follow: true,
564
+ // kebab-case keys: camelCase variants are silently ignored.
565
+ "max-image-preview": "large",
566
+ "max-snippet": -1,
567
+ "max-video-preview": -1,
568
+ },
569
+ },
570
+ // Child openGraph/twitter objects REPLACE the parent's, so images are repeated here.
571
+ openGraph: og,
572
+ twitter: {
573
+ card: m.twitter.card,
574
+ title: m.twitter.title,
575
+ description: m.twitter.description,
576
+ ...(m.twitter.image && { images: [m.twitter.image] }),
577
+ },
578
+ };
579
+ return {
580
+ file: "app/<route>/page.jsx (or layout.jsx if the page is \"use client\")",
581
+ language: "jsx",
582
+ code: `// A "use client" page cannot export metadata: move this export to the route's layout.jsx.
583
+ // title.absolute: the brand is already in the title, so a root "%s | Brand" template won't double it.
584
+ // openGraph/twitter here REPLACE the parent layout's objects entirely, so images are repeated.
585
+ // googleBot keys must stay kebab-case ("max-image-preview"); camelCase keys are ignored.
586
+ // If the tool widget needs the browser, wrap only the widget in dynamic(..., { ssr: false }).
587
+ export const metadata = ${JSON.stringify(metadata, null, 2)};
588
+
589
+ const jsonLd = ${jsonLdJs(jsonLd)};
590
+
591
+ export default function Page() {
592
+ return (
593
+ <>
594
+ ${indent(JSX_SCRIPT.split("\n"), 6)}
595
+ {/* page content */}
596
+ </>
597
+ );
598
+ }`,
599
+ };
600
+ }
601
+
602
+ case "nextjs-pages":
603
+ return {
604
+ file: "pages/<route>.jsx",
605
+ language: "jsx",
606
+ code: `import Head from "next/head";
607
+
608
+ const jsonLd = ${jsonLdJs(jsonLd)};
609
+
610
+ export default function Page() {
611
+ return (
612
+ <>
613
+ <Head>
614
+ ${indent(headTags(m), 8)}
615
+ ${indent(JSX_SCRIPT.split("\n"), 8)}
616
+ </Head>
617
+ {/* page content */}
618
+ </>
619
+ );
620
+ }`,
621
+ };
622
+
623
+ case "react-vite":
624
+ return {
625
+ file: "src/pages/<Page>.jsx",
626
+ language: "jsx",
627
+ code: `// React 19 hoists <title>, <meta> and <link> into <head> automatically.
628
+ // On React 18, wrap these tags in <Helmet> from react-helmet-async instead.
629
+ // Prerender this route at build time (or use an SSR framework): a client-only
630
+ // SPA ships an empty HTML shell that most AI crawlers never render.
631
+
632
+ const jsonLd = ${jsonLdJs(jsonLd)};
633
+
634
+ export default function Page() {
635
+ return (
636
+ <>
637
+ ${indent(headTags(m), 6)}
638
+ ${indent(JSX_SCRIPT.split("\n"), 6)}
639
+ {/* page content */}
640
+ </>
641
+ );
642
+ }`,
643
+ };
644
+
645
+ case "react-router": {
646
+ const entries = [
647
+ `{ title: ${q(m.title)} }`,
648
+ `{ name: "description", content: ${q(m.description)} }`,
649
+ `{ tagName: "link", rel: "canonical", href: ${q(m.canonical)} }`,
650
+ `{ name: "robots", content: ${q(m.robots)} }`,
651
+ `{ property: "og:type", content: ${q(m.openGraph.type)} }`,
652
+ `{ property: "og:site_name", content: ${q(m.openGraph.siteName)} }`,
653
+ `{ property: "og:title", content: ${q(m.openGraph.title)} }`,
654
+ `{ property: "og:description", content: ${q(m.openGraph.description)} }`,
655
+ `{ property: "og:url", content: ${q(m.canonical)} }`,
656
+ ...(m.openGraph.image ? [`{ property: "og:image", content: ${q(m.openGraph.image)} }`] : []),
657
+ `{ name: "twitter:card", content: ${q(m.twitter.card)} }`,
658
+ `{ name: "twitter:title", content: ${q(m.twitter.title)} }`,
659
+ `{ name: "twitter:description", content: ${q(m.twitter.description)} }`,
660
+ ...(m.twitter.image ? [`{ name: "twitter:image", content: ${q(m.twitter.image)} }`] : []),
661
+ `{ "script:ld+json": jsonLd }`,
662
+ ];
663
+ return {
664
+ file: "app/routes/<route>.jsx",
665
+ language: "jsx",
666
+ code: `const jsonLd = ${jsonLdJs(jsonLd)};
667
+
668
+ export function meta() {
669
+ return [
670
+ ${entries.map((e) => ` ${e},`).join("\n")}
671
+ ];
672
+ }`,
673
+ };
674
+ }
675
+
676
+ case "astro":
677
+ return {
678
+ file: "src/pages/<route>.astro (or pass these as props to your layout's <head>)",
679
+ language: "astro",
680
+ code: `---
681
+ const jsonLd = ${jsonLdJs(jsonLd)};
682
+ ---
683
+ <html lang="en">
684
+ <head>
685
+ <meta charset="utf-8" />
686
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
687
+ ${indent(headTags(m), 4)}
688
+ <script type="application/ld+json" set:html={JSON.stringify(jsonLd).replace(/</g, "\\\\u003c")} />
689
+ </head>
690
+ <body>
691
+ <!-- page content; render Neuctra UI components as React islands -->
692
+ </body>
693
+ </html>`,
694
+ };
695
+
696
+ case "tanstack-start": {
697
+ const meta = [
698
+ `{ title: ${q(m.title)} }`,
699
+ `{ name: "description", content: ${q(m.description)} }`,
700
+ `{ name: "robots", content: ${q(m.robots)} }`,
701
+ `{ property: "og:type", content: ${q(m.openGraph.type)} }`,
702
+ `{ property: "og:site_name", content: ${q(m.openGraph.siteName)} }`,
703
+ `{ property: "og:title", content: ${q(m.openGraph.title)} }`,
704
+ `{ property: "og:description", content: ${q(m.openGraph.description)} }`,
705
+ `{ property: "og:url", content: ${q(m.canonical)} }`,
706
+ ...(m.openGraph.image ? [`{ property: "og:image", content: ${q(m.openGraph.image)} }`] : []),
707
+ `{ name: "twitter:card", content: ${q(m.twitter.card)} }`,
708
+ `{ name: "twitter:title", content: ${q(m.twitter.title)} }`,
709
+ `{ name: "twitter:description", content: ${q(m.twitter.description)} }`,
710
+ ];
711
+ return {
712
+ file: "src/routes/<route>.jsx",
713
+ language: "jsx",
714
+ code: `import { createFileRoute } from "@tanstack/react-router";
715
+
716
+ const jsonLd = ${jsonLdJs(jsonLd)};
717
+
718
+ export const Route = createFileRoute("/<route>")({
719
+ head: () => ({
720
+ meta: [
721
+ ${meta.map((e) => ` ${e},`).join("\n")}
722
+ ],
723
+ links: [{ rel: "canonical", href: ${q(m.canonical)} }],
724
+ scripts: [
725
+ { type: "application/ld+json", children: JSON.stringify(jsonLd).replace(/</g, "\\\\u003c") },
726
+ ],
727
+ }),
728
+ component: Page,
729
+ });
730
+
731
+ function Page() {
732
+ return <>{/* page content */}</>;
733
+ }`,
734
+ };
735
+ }
736
+
737
+ case "gatsby":
738
+ return {
739
+ file: "src/pages/<route>.jsx",
740
+ language: "jsx",
741
+ code: `const jsonLd = ${jsonLdJs(jsonLd)};
742
+
743
+ export const Head = () => (
744
+ <>
745
+ ${indent(headTags(m), 4)}
746
+ ${indent(JSX_SCRIPT.split("\n"), 4)}
747
+ </>
748
+ );
749
+
750
+ export default function Page() {
751
+ return <>{/* page content */}</>;
752
+ }`,
753
+ };
754
+
755
+ case "html":
756
+ default:
757
+ return {
758
+ file: "<head> of the page's HTML",
759
+ language: "html",
760
+ code: `${headTags(m).join("\n")}
761
+ <script type="application/ld+json">
762
+ ${jsonLdString(jsonLd)}
763
+ </script>`,
764
+ };
765
+ }
766
+ }
767
+
768
+ function followUps(input) {
769
+ const items = [];
770
+ if (!input.faqs?.length && !["contact", "home"].includes(input.pageType)) {
771
+ items.push("AEO: add 4 to 8 real, page-specific FAQs, render them visibly on the page and pass them back in as faqs so the FAQPage schema matches word for word.");
772
+ }
773
+ if (["docs", "blog", "product", "landing"].includes(input.pageType)) {
774
+ items.push("AEO: phrase key H2s as questions and put a direct 40 to 60 word answer right under each.");
775
+ }
776
+ items.push("GEO: add this URL to /llms.txt and to sitemap.xml (with lastmod) as part of the build.");
777
+ if (!input.sameAs?.length) {
778
+ items.push("GEO: pass sameAs (GitHub, LinkedIn, X, npm, ...) so the Organization entity is unambiguous to search and AI engines.");
779
+ }
780
+ if (["docs", "blog"].includes(input.pageType) && !input.dateModified) {
781
+ items.push("Show a visible \"Last updated\" date and pass dateModified: generative engines favour fresh sources.");
782
+ }
783
+ if (input.pageType === "tool") {
784
+ items.push("Render the H1, lead, article sections, FAQ and JSON-LD on the server; only the interactive widget may be client-only (ssr: false).");
785
+ items.push("Link 3 to 4 related tools from the route registry with the framework's Link component.");
786
+ }
787
+ if (input.pageType === "category") {
788
+ items.push("Put an H2 above the grid of items so headings go H1 > H2 > H3.");
789
+ }
790
+ if (input.framework === "react-vite") {
791
+ items.push("Prerender this route or move to an SSR framework: metadata added on the client is invisible to most AI crawlers.");
792
+ }
793
+ items.push("Validate the JSON-LD with Google's Rich Results Test and validator.schema.org before shipping.");
794
+ return items;
795
+ }
796
+
797
+ export function generatePageSeo(raw) {
798
+ const input = { language: "en", framework: "html", ...raw };
799
+ const title = buildTitle(input);
800
+ const description = buildDescription(input);
801
+ const meta = { title: title.value, description: description.value };
802
+ const schema = buildSchemaGraph(input, meta);
803
+ const m = buildMetaObject(input, meta);
804
+ const snippet = renderSnippet(input.framework, m, schema.jsonLd);
805
+
806
+ return {
807
+ title: { value: title.value, length: title.length },
808
+ description: { value: description.value, length: description.length },
809
+ canonical: m.canonical,
810
+ robots: m.robots,
811
+ openGraph: m.openGraph,
812
+ twitter: m.twitter,
813
+ jsonLd: schema.jsonLd,
814
+ snippet: { framework: input.framework, ...snippet },
815
+ warnings: [
816
+ ...title.warnings,
817
+ ...description.warnings,
818
+ ...schema.warnings,
819
+ ...(input.ogImage
820
+ ? []
821
+ : ["No ogImage: every page (including category and static pages) needs a 1200x630 og:image, or shares show no preview."]),
822
+ ],
823
+ followUps: followUps(input),
824
+ };
825
+ }
826
+
827
+ // ---------------------------------------------------------------------------
828
+ // Built-HTML parsing (regex-based, good enough for audit extraction)
829
+ // ---------------------------------------------------------------------------
830
+
831
+ function decodeEntities(text) {
832
+ return text
833
+ .replace(/&#(\d+);/g, (_, n) => String.fromCodePoint(Number(n)))
834
+ .replace(/&#x([0-9a-f]+);/gi, (_, n) => String.fromCodePoint(parseInt(n, 16)))
835
+ .replace(/&nbsp;/g, " ")
836
+ .replace(/&quot;/g, '"')
837
+ .replace(/&#39;|&apos;/g, "'")
838
+ .replace(/&lt;/g, "<")
839
+ .replace(/&gt;/g, ">")
840
+ .replace(/&amp;/g, "&");
841
+ }
842
+
843
+ function stripTags(html) {
844
+ return decodeEntities(html.replace(/<[^>]+>/g, " ")).replace(/\s+/g, " ").trim();
845
+ }
846
+
847
+ function attr(tag, name) {
848
+ const m = tag.match(new RegExp(`\\b${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s>]+))`, "i"));
849
+ return m ? decodeEntities(m[1] ?? m[2] ?? m[3] ?? "") : undefined;
850
+ }
851
+
852
+ function findMeta(head, key, value) {
853
+ for (const tag of head.match(/<meta\b[^>]*>/gi) ?? []) {
854
+ if ((attr(tag, key) ?? "").toLowerCase() === value) return attr(tag, "content");
855
+ }
856
+ return undefined;
857
+ }
858
+
859
+ /** Top-level entity types per JSON-LD block: root object, root array items, or @graph items. */
860
+ function topLevelJsonLdTypes(html) {
861
+ const types = [];
862
+ const errors = [];
863
+ const blocks = html.match(/<script\b[^>]*type\s*=\s*["']application\/ld\+json["'][^>]*>[\s\S]*?<\/script>/gi) ?? [];
864
+ for (const block of blocks) {
865
+ const body = block.replace(/^<script\b[^>]*>/i, "").replace(/<\/script>$/i, "");
866
+ try {
867
+ const data = JSON.parse(body);
868
+ const roots = Array.isArray(data) ? data : [data];
869
+ for (const root of roots) {
870
+ const nodes = Array.isArray(root?.["@graph"]) ? root["@graph"] : [root];
871
+ for (const node of nodes) {
872
+ const t = node?.["@type"];
873
+ if (Array.isArray(t)) types.push(...t);
874
+ else if (t) types.push(t);
875
+ }
876
+ }
877
+ } catch {
878
+ errors.push(body.slice(0, 60));
879
+ }
880
+ }
881
+ return { types, blocks: blocks.length, errors };
882
+ }
883
+
884
+ /** Extracts every audit input it can from a page's built/server-rendered HTML. */
885
+ export function parseHtmlForAudit(html) {
886
+ const headMatch = html.match(/<head\b[^>]*>([\s\S]*?)<\/head>/i);
887
+ const head = headMatch ? headMatch[1] : html;
888
+ const bodyMatch = html.match(/<body\b[^>]*>([\s\S]*?)<\/body>/i);
889
+ const body = bodyMatch ? bodyMatch[1] : html;
890
+ const mainMatch = body.match(/<main\b[^>]*>([\s\S]*?)<\/main>/i);
891
+ const content = (mainMatch ? mainMatch[1] : body)
892
+ .replace(/<script\b[\s\S]*?<\/script>/gi, " ")
893
+ .replace(/<style\b[\s\S]*?<\/style>/gi, " ")
894
+ .replace(/<svg\b[\s\S]*?<\/svg>/gi, " ");
895
+
896
+ const titleMatch = head.match(/<title\b[^>]*>([\s\S]*?)<\/title>/i);
897
+ const canonicalTag = (head.match(/<link\b[^>]*>/gi) ?? []).find((t) => (attr(t, "rel") ?? "").toLowerCase() === "canonical");
898
+ const robots = findMeta(head, "name", "robots") ?? "";
899
+ const headings = [...content.matchAll(/<h([1-6])\b[^>]*>([\s\S]*?)<\/h\1>/gi)].map((m) => ({
900
+ level: Number(m[1]),
901
+ text: stripTags(m[2]),
902
+ }));
903
+ const imgs = content.match(/<img\b[^>]*>/gi) ?? [];
904
+ const firstP = content.match(/<p\b[^>]*>([\s\S]*?)<\/p>/i);
905
+ const text = stripTags(content);
906
+ const jsonLd = topLevelJsonLdTypes(html);
907
+
908
+ return {
909
+ title: titleMatch ? stripTags(titleMatch[1]) : undefined,
910
+ description: findMeta(head, "name", "description"),
911
+ canonical: canonicalTag ? attr(canonicalTag, "href") ?? "" : "",
912
+ noindex: /noindex/i.test(robots),
913
+ hasOgImage: !!findMeta(head, "property", "og:image"),
914
+ hasTwitterImage: !!findMeta(head, "name", "twitter:image"),
915
+ h1s: headings.filter((h) => h.level === 1).map((h) => h.text),
916
+ headings,
917
+ jsonLdTypes: jsonLd.types,
918
+ jsonLdErrors: jsonLd.errors,
919
+ imagesMissingAlt: imgs.filter((t) => attr(t, "alt") === undefined).length,
920
+ wordCount: text ? text.split(" ").length : 0,
921
+ firstParagraph: firstP ? stripTags(firstP[1]) : undefined,
922
+ };
923
+ }
924
+
925
+ // ---------------------------------------------------------------------------
926
+ // Audit
927
+ // ---------------------------------------------------------------------------
928
+
929
+ function includesKeyword(text, keyword) {
930
+ return !!text && text.toLowerCase().includes(keyword.toLowerCase());
931
+ }
932
+
933
+ function sameUrl(a, b) {
934
+ const norm = (u) => u.replace(/[?#].*$/, "").replace(/\/+$/, "").toLowerCase();
935
+ return norm(a) === norm(b);
936
+ }
937
+
938
+ const WEBPAGE_TYPES = ["WebPage", "AboutPage", "ContactPage", "CollectionPage", "FAQPage", "ItemPage", "ProfilePage", "SearchResultsPage", "CheckoutPage", "QAPage"];
939
+
940
+ export function auditPageSeo(input) {
941
+ // Built HTML fills in anything not passed explicitly; explicit fields win.
942
+ const parsed = input.html ? parseHtmlForAudit(input.html) : null;
943
+ const p = { ...(parsed ?? {}), ...Object.fromEntries(Object.entries(input).filter(([k, v]) => k !== "html" && v !== undefined)) };
944
+ const results = [];
945
+ const add = (status, check, detail, fix) =>
946
+ results.push({ status, check, detail, ...(fix && { fix }) });
947
+
948
+ // Title
949
+ if (!p.title) add("fail", "Title", "No <title>.", "Add a unique 50 to 60 character title: primary keyword first, brand last.");
950
+ else if (p.title.length > 70) add("fail", "Title", `${p.title.length} characters: badly truncated in results.`, "Cut to 60 characters or fewer.");
951
+ else if (p.title.length > TITLE_MAX) add("warn", "Title", `${p.title.length} characters: may be truncated.`, "Aim for 50 to 60 characters.");
952
+ else if (p.title.length < 30) add("warn", "Title", `${p.title.length} characters: too short to compete.`, "Add a qualifier (benefit, audience or use case).");
953
+ else add("pass", "Title", `${p.title.length} characters.`);
954
+
955
+ // Brand suffix: exactly once, at the end
956
+ if (p.title) {
957
+ const parts = p.title.split(/\s+[|\u2013\u2014-]\s+/).map((x) => x.trim().toLowerCase());
958
+ const last = parts[parts.length - 1];
959
+ if (parts.length > 2 && parts[parts.length - 2] === last) {
960
+ add("fail", "Title brand suffix", `Suffix is doubled: "${p.title}".`, "Add the brand in one place only (title template or metadata builder), and never hand-type it into page titles. In Next.js use title: { absolute } when the builder already adds it.");
961
+ } else if (p.brandName) {
962
+ const brand = p.brandName.toLowerCase();
963
+ const count = p.title.toLowerCase().split(brand).length - 1;
964
+ if (count === 0) add("warn", "Title brand suffix", `No "${p.brandName}" in the title.`, `End every title with " | ${p.brandName}" from one shared template, so branding is consistent site-wide.`);
965
+ else if (count > 1) add("fail", "Title brand suffix", `"${p.brandName}" appears ${count} times.`, "Remove the hand-typed suffix; let the shared template add it once.");
966
+ else if (!p.title.toLowerCase().trim().endsWith(brand)) add("warn", "Title brand suffix", "Brand is not at the end.", `Use the site-wide pattern "Keyword: Benefit | ${p.brandName}".`);
967
+ else add("pass", "Title brand suffix", "Present once, at the end.");
968
+ }
969
+ }
970
+
971
+ // Description
972
+ if (!p.description) add("fail", "Meta description", "Missing.", "Add a unique 140 to 155 character description with the value and a reason to click.");
973
+ else if (p.description.length > 200) add("fail", "Meta description", `${p.description.length} characters.`, "Cut to 155 characters or fewer.");
974
+ else if (p.description.length > 160) add("warn", "Meta description", `${p.description.length} characters: will be truncated.`, "Cut to 155 characters or fewer.");
975
+ else if (p.description.length < 120) add("warn", "Meta description", `${p.description.length} characters: too short.`, "Expand to 140 to 155 characters.");
976
+ else add("pass", "Meta description", `${p.description.length} characters.`);
977
+
978
+ // H1
979
+ if (p.h1s) {
980
+ if (p.h1s.length === 0) add("fail", "H1", "No H1 on the page.", (p.wordCount ?? 0) < 150 ? "The page looks client-rendered (almost no text in the HTML). Render the H1, content, FAQ and JSON-LD on the server; wrap only the interactive widget in dynamic(..., { ssr: false })." : "Add exactly one H1 that matches the search intent.");
981
+ else if (p.h1s.length > 1) add("fail", "H1", `${p.h1s.length} H1s found.`, "Keep exactly one H1; demote the others to H2.");
982
+ else add("pass", "H1", `"${p.h1s[0]}"`);
983
+ }
984
+
985
+ // Heading order and AEO question headings
986
+ if (p.headings?.length) {
987
+ let prev = 1;
988
+ const skips = [];
989
+ for (const h of p.headings) {
990
+ if (h.level > prev + 1) skips.push(`H${prev} -> H${h.level} ("${h.text}")`);
991
+ prev = h.level;
992
+ }
993
+ if (skips.length) add("warn", "Heading hierarchy", `Skipped levels: ${skips.join("; ")}.`, "Step down one level at a time (H2 -> H3), never skip.");
994
+ else add("pass", "Heading hierarchy", "No skipped levels.");
995
+
996
+ const h2s = p.headings.filter((h) => h.level === 2);
997
+ const questions = h2s.filter((h) => /\?\s*$|^(how|what|why|when|where|which|who|can|does|do|is|are|should)\b/i.test(h.text.trim()));
998
+ if (h2s.length && !questions.length) add("warn", "AEO question headings", "No H2 is phrased as a question.", "Rewrite 2 or more H2s as the questions people search, each followed by a 40 to 60 word direct answer.");
999
+ else if (questions.length) add("pass", "AEO question headings", `${questions.length} question-style H2s.`);
1000
+ }
1001
+
1002
+ // Keyword placement
1003
+ if (p.primaryKeyword) {
1004
+ const k = p.primaryKeyword;
1005
+ if (p.title !== undefined) includesKeyword(p.title, k) ? add("pass", "Keyword in title", `Contains "${k}".`) : add("warn", "Keyword in title", `Missing "${k}".`, "Put the primary keyword near the start of the title.");
1006
+ if (p.h1s?.length) includesKeyword(p.h1s[0], k) ? add("pass", "Keyword in H1", `Contains "${k}".`) : add("warn", "Keyword in H1", `Missing "${k}".`, "Use the primary keyword or a close variant in the H1.");
1007
+ if (p.firstParagraph !== undefined) {
1008
+ const first100 = p.firstParagraph.split(/\s+/).slice(0, 100).join(" ");
1009
+ includesKeyword(first100, k) ? add("pass", "Keyword early in content", "Appears in the first 100 words.") : add("warn", "Keyword early in content", "Not in the first 100 words.", "Mention the primary keyword naturally in the opening paragraph.");
1010
+ }
1011
+ const slug = p.url.replace(/^https?:\/\/[^/]+/, "").toLowerCase();
1012
+ const slugWords = k.toLowerCase().split(/\s+/).filter((w) => w.length > 2);
1013
+ if (slugWords.length && !slugWords.some((w) => slug.includes(w))) add("warn", "Keyword in URL", `URL path "${slug || "/"}" has no keyword words.`, "Use a short kebab-case slug containing the keyword (only for new pages; 301 any change).");
1014
+ }
1015
+
1016
+ // URL shape
1017
+ const path = p.url.replace(/^https?:\/\/[^/]+/, "");
1018
+ const urlIssues = [];
1019
+ if (/[A-Z]/.test(path)) urlIssues.push("uppercase letters");
1020
+ if (/_/.test(path)) urlIssues.push("underscores");
1021
+ if (/\?/.test(path)) urlIssues.push("query string");
1022
+ if (p.url.length > 100) urlIssues.push(`${p.url.length} characters long`);
1023
+ if (!p.url.startsWith("https://")) urlIssues.push("not https");
1024
+ urlIssues.length ? add("warn", "URL", `Issues: ${urlIssues.join(", ")}.`, "Use short, lowercase, kebab-case https URLs without parameters.") : add("pass", "URL", "Clean.");
1025
+
1026
+ // Canonical
1027
+ if (p.canonical === undefined) add("warn", "Canonical", "Not provided to the audit.", "Check the page has <link rel=\"canonical\"> with an absolute URL.");
1028
+ else if (!p.canonical) add("fail", "Canonical", "Missing.", `Add <link rel="canonical" href="${p.url}">.`);
1029
+ else if (!/^https?:\/\//.test(p.canonical)) add("fail", "Canonical", `Relative: "${p.canonical}".`, "Use an absolute https URL.");
1030
+ else if (!sameUrl(p.canonical, p.url)) add("warn", "Canonical", `Points elsewhere: ${p.canonical}.`, "Fine if intentional (duplicate content); otherwise point it at this URL.");
1031
+ else add("pass", "Canonical", "Self-referencing and absolute.");
1032
+
1033
+ // Robots
1034
+ if (p.noindex) add("warn", "Indexing", "Page is noindex.", "Remove noindex if this page should rank or be cited.");
1035
+
1036
+ // Social image
1037
+ if (p.hasOgImage === false) add("warn", "OpenGraph image", "No og:image.", "Add a 1200x630 og:image and twitter:image. In Next.js, a page-level openGraph object replaces the parent's, so repeat images in it.");
1038
+ else if (p.hasOgImage) add("pass", "OpenGraph image", "Present.");
1039
+
1040
+ // Structured data
1041
+ if (p.jsonLdErrors?.length) {
1042
+ add("fail", "Schema: JSON syntax", `${p.jsonLdErrors.length} JSON-LD block(s) don't parse.`, "Fix the JSON (usually an unescaped quote or trailing comma); invalid blocks are ignored entirely.");
1043
+ }
1044
+ if (p.jsonLdTypes) {
1045
+ const types = p.jsonLdTypes;
1046
+ const counts = types.reduce((acc, t) => ({ ...acc, [t]: (acc[t] ?? 0) + 1 }), {});
1047
+ const dupes = Object.entries(counts).filter(([t, n]) => n > 1 && t !== "ItemList");
1048
+ if (dupes.length) add("fail", "Schema: duplicate nodes", `Duplicated: ${dupes.map(([t, n]) => `${t} x${n}`).join(", ")}.`, "Emit each type once per page. Usually a shared page shell and the page itself both output JSON-LD: keep the shell, pass the data into it, delete the page-level copy.");
1049
+ else if (types.length) add("pass", "Schema: duplicate nodes", "No duplicated node types.");
1050
+ const pageNodes = types.filter((t) => WEBPAGE_TYPES.includes(t) && t !== "FAQPage");
1051
+ if (pageNodes.length) add("pass", "Schema: WebPage", `Present (${pageNodes[0]}).`);
1052
+ else if (types.includes("FAQPage")) add("warn", "Schema: WebPage", "Only an FAQPage node stands in for the page.", "Fine for a dedicated FAQ page. Any other page needs its own WebPage (or subtype) node, with the FAQPage linked to it via isPartOf.");
1053
+ else add("warn", "Schema: WebPage", "No WebPage (or subtype) node.", "Weave a WebPage node linked to WebSite and Organization (use generate_page_seo).");
1054
+ const isHome = sameUrl(p.url, p.url.replace(/^(https?:\/\/[^/]+).*$/, "$1"));
1055
+ if (!isHome) types.includes("BreadcrumbList") ? add("pass", "Schema: BreadcrumbList", "Present.") : add("warn", "Schema: BreadcrumbList", "Missing.", "Add a BreadcrumbList matching the visible breadcrumb/URL path.");
1056
+ types.includes("Organization") ? add("pass", "Schema: Organization", "Present.") : add("warn", "Schema: Organization", "Missing.", "Add Organization with logo and sameAs, referenced by @id from every page.");
1057
+ if (p.faqCount > 0 && !types.includes("FAQPage")) add("warn", "Schema: FAQPage", `${p.faqCount} visible FAQs but no FAQPage schema.`, "Mark up the visible FAQs as FAQPage with identical text.");
1058
+ if (types.includes("FAQPage") && p.faqCount === 0) add("fail", "Schema: FAQPage", "FAQPage schema but no visible FAQs.", "Structured data must match visible content: show the FAQs or remove the markup.");
1059
+ }
1060
+
1061
+ // FAQs
1062
+ if (p.faqCount !== undefined) {
1063
+ if (p.faqCount === 0) add("warn", "AEO: FAQs", "No FAQ block.", "Add 4 to 8 real, page-specific questions with 40 to 80 word answers.");
1064
+ else if (p.faqCount > 10) add("warn", "AEO: FAQs", `${p.faqCount} FAQs.`, "Keep the 4 to 8 most-asked; move the rest to a dedicated FAQ page.");
1065
+ else add("pass", "AEO: FAQs", `${p.faqCount} FAQs.`);
1066
+ }
1067
+
1068
+ // Content depth
1069
+ if (p.wordCount !== undefined) {
1070
+ p.wordCount < 300 ? add("warn", "Content depth", `${p.wordCount} words: thin.`, "Cover the topic fully: the main answer, follow-up questions and objections (usually 600+ words for guides).") : add("pass", "Content depth", `${p.wordCount} words.`);
1071
+ }
1072
+
1073
+ // Images
1074
+ if (p.imagesMissingAlt !== undefined) {
1075
+ p.imagesMissingAlt > 0 ? add("fail", "Image alt text", `${p.imagesMissingAlt} images without alt.`, "Add descriptive alt to meaningful images and alt=\"\" to decorative ones.") : add("pass", "Image alt text", "All images have alt.");
1076
+ }
1077
+
1078
+ const weight = { pass: 1, warn: 0.5, fail: 0 };
1079
+ const score = results.length
1080
+ ? Math.round((results.reduce((s, r) => s + weight[r.status], 0) / results.length) * 100)
1081
+ : 0;
1082
+ const order = { fail: 0, warn: 1, pass: 2 };
1083
+ results.sort((a, b) => order[a.status] - order[b.status]);
1084
+
1085
+ return {
1086
+ score,
1087
+ ...(parsed && {
1088
+ extracted: {
1089
+ title: parsed.title,
1090
+ description: parsed.description,
1091
+ canonical: parsed.canonical,
1092
+ h1s: parsed.h1s,
1093
+ jsonLdTypes: parsed.jsonLdTypes,
1094
+ wordCount: parsed.wordCount,
1095
+ imagesMissingAlt: parsed.imagesMissingAlt,
1096
+ hasOgImage: parsed.hasOgImage,
1097
+ },
1098
+ }),
1099
+ summary: {
1100
+ fail: results.filter((r) => r.status === "fail").length,
1101
+ warn: results.filter((r) => r.status === "warn").length,
1102
+ pass: results.filter((r) => r.status === "pass").length,
1103
+ },
1104
+ results,
1105
+ };
1106
+ }