@qobi/seocode 0.1.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/LICENSE +21 -0
- package/dist/src/billing/polar.js +127 -0
- package/dist/src/billing/repo-limits.js +60 -0
- package/dist/src/billing/subscription-service.js +123 -0
- package/dist/src/billing/tier.js +14 -0
- package/dist/src/billing/types.js +1 -0
- package/dist/src/cli/discover.js +62 -0
- package/dist/src/cli/fix.js +24 -0
- package/dist/src/cli/format-terminal.js +66 -0
- package/dist/src/cli/index.js +204 -0
- package/dist/src/cli/init.js +48 -0
- package/dist/src/cli/staged.js +24 -0
- package/dist/src/config/seocode-config.js +97 -0
- package/dist/src/engine/page-detector.js +82 -0
- package/dist/src/engine/pr-delta.js +31 -0
- package/dist/src/engine/rule-engine.js +112 -0
- package/dist/src/engine/rule-loader.js +35 -0
- package/dist/src/engine/rules/declarative-evaluator.js +105 -0
- package/dist/src/engine/rules/heading-rules.js +43 -0
- package/dist/src/engine/rules/helpers.js +16 -0
- package/dist/src/engine/rules/image-rules.js +82 -0
- package/dist/src/engine/rules/index.js +28 -0
- package/dist/src/engine/rules/link-rules.js +39 -0
- package/dist/src/engine/rules/meta-rules.js +34 -0
- package/dist/src/engine/rules/performance-rules.js +24 -0
- package/dist/src/engine/rules/schema-rules.js +228 -0
- package/dist/src/engine/rules/suggest.js +103 -0
- package/dist/src/engine/rules/technical-rules.js +100 -0
- package/dist/src/parsers/frameworks/ast-value.js +83 -0
- package/dist/src/parsers/frameworks/astro.js +107 -0
- package/dist/src/parsers/frameworks/index.js +36 -0
- package/dist/src/parsers/frameworks/nextjs.js +170 -0
- package/dist/src/parsers/frameworks/remix.js +130 -0
- package/dist/src/parsers/frameworks/roles.js +63 -0
- package/dist/src/parsers/frameworks/types.js +1 -0
- package/dist/src/parsers/html-parser.js +118 -0
- package/dist/src/parsers/index.js +62 -0
- package/dist/src/parsers/jsx-parser.js +174 -0
- package/dist/src/types/index.js +1 -0
- package/dist/src/types/worker-env.js +1 -0
- package/package.json +75 -0
- package/rules/seo-rules.json +3414 -0
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Helpers that build **provably-correct** single-line fix suggestions from the
|
|
3
|
+
* document's raw source. The guiding rule: only ever return a suggestion when
|
|
4
|
+
* the transform is unambiguous on the exact source line. If anything about the
|
|
5
|
+
* line is unexpected (tag spans lines, attribute already present, token not
|
|
6
|
+
* found), return `undefined` so the issue stays advisory-only. A wrong 1-click
|
|
7
|
+
* fix is far worse than no button.
|
|
8
|
+
*/
|
|
9
|
+
/** Returns the 1-based `line` from the document's raw source, or undefined. */
|
|
10
|
+
function sourceLine(doc, line) {
|
|
11
|
+
if (!line || line < 1)
|
|
12
|
+
return undefined;
|
|
13
|
+
const lines = doc.rawContent.split('\n');
|
|
14
|
+
return lines[line - 1];
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Inserts `attr` (e.g. `loading="lazy"`) immediately after the opening `<tag`
|
|
18
|
+
* token on the issue line. Safe only when: the line contains exactly one such
|
|
19
|
+
* tag (unambiguous target), the tag opens and closes on that same line, and the
|
|
20
|
+
* attribute isn't already present. Otherwise returns undefined (advisory-only).
|
|
21
|
+
*
|
|
22
|
+
* `tag` is a bare tag name like `img` or `a`. A word boundary keeps `a` from
|
|
23
|
+
* matching `article`/`aside`.
|
|
24
|
+
*/
|
|
25
|
+
export function suggestTagAttr(doc, line, tag, attr, attrName) {
|
|
26
|
+
const src = sourceLine(doc, line);
|
|
27
|
+
if (src === undefined || line === null)
|
|
28
|
+
return undefined;
|
|
29
|
+
const openRe = new RegExp(`<${tag}\\b`, 'gi');
|
|
30
|
+
const matches = src.match(openRe);
|
|
31
|
+
if (!matches || matches.length !== 1)
|
|
32
|
+
return undefined; // 0 or >1 → ambiguous
|
|
33
|
+
const openIdx = src.search(new RegExp(`<${tag}\\b`, 'i'));
|
|
34
|
+
const closeIdx = src.indexOf('>', openIdx);
|
|
35
|
+
if (closeIdx === -1)
|
|
36
|
+
return undefined; // tag continues on a later line — unsafe
|
|
37
|
+
// Don't duplicate an attribute that's already there (within this tag).
|
|
38
|
+
const tagText = src.slice(openIdx, closeIdx + 1);
|
|
39
|
+
const attrRe = new RegExp(`\\b${attrName}\\s*=`, 'i');
|
|
40
|
+
if (attrRe.test(tagText))
|
|
41
|
+
return undefined;
|
|
42
|
+
const insertAt = openIdx + 1 + tag.length; // just after "<tag"
|
|
43
|
+
const replacement = src.slice(0, insertAt) + ' ' + attr + src.slice(insertAt);
|
|
44
|
+
return { startLine: line, endLine: line, replacement };
|
|
45
|
+
}
|
|
46
|
+
/** Convenience wrapper for the common `<img>` case. */
|
|
47
|
+
export function suggestImgAttr(doc, line, attr, attrName) {
|
|
48
|
+
return suggestTagAttr(doc, line, 'img', attr, attrName);
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Inserts a new line (e.g. `<meta charset="utf-8" />`) as the first child of
|
|
52
|
+
* `<head>`, by rewriting the `<head>` line to itself + the new line. Safe only
|
|
53
|
+
* for HTML documents where `<head>` sits alone on its own line exactly once —
|
|
54
|
+
* JSX/Vue/Svelte have no literal `<head>`, so this returns undefined for them.
|
|
55
|
+
* The new line inherits the `<head>` line's indentation plus two spaces.
|
|
56
|
+
*/
|
|
57
|
+
export function suggestHeadInsert(doc, newLine) {
|
|
58
|
+
if (doc.fileType !== 'html')
|
|
59
|
+
return undefined;
|
|
60
|
+
const lines = doc.rawContent.split('\n');
|
|
61
|
+
let headIdx = -1;
|
|
62
|
+
for (let i = 0; i < lines.length; i++) {
|
|
63
|
+
if (lines[i].trim().toLowerCase() === '<head>') {
|
|
64
|
+
if (headIdx !== -1)
|
|
65
|
+
return undefined; // more than one — ambiguous
|
|
66
|
+
headIdx = i;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
if (headIdx === -1)
|
|
70
|
+
return undefined;
|
|
71
|
+
const headLine = lines[headIdx];
|
|
72
|
+
const indent = (headLine.match(/^\s*/)?.[0] ?? '') + ' ';
|
|
73
|
+
const replacement = headLine + '\n' + indent + newLine;
|
|
74
|
+
return { startLine: headIdx + 1, endLine: headIdx + 1, replacement };
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Centralized post-analysis pass: attaches a provably-correct 1-click fix to
|
|
78
|
+
* issues that don't already carry one, keyed by rule id. Rules whose fix is a
|
|
79
|
+
* per-element line rewrite (image loading, link rel) attach their suggestion at
|
|
80
|
+
* detection time where they have the element line; this pass covers the
|
|
81
|
+
* insertion-style fixes for declarative, file-level rules (`line: null`), where
|
|
82
|
+
* the fix is a standard tag with a fixed, unambiguous value.
|
|
83
|
+
*
|
|
84
|
+
* Mutates and returns the same issues array. Only ever adds a suggestion when
|
|
85
|
+
* one is provably correct — otherwise the issue stays advisory-only.
|
|
86
|
+
*/
|
|
87
|
+
const HEAD_INSERTS = {
|
|
88
|
+
'missing-charset': '<meta charset="utf-8" />',
|
|
89
|
+
'missing-viewport': '<meta name="viewport" content="width=device-width, initial-scale=1" />',
|
|
90
|
+
};
|
|
91
|
+
export function attachSuggestions(doc, issues) {
|
|
92
|
+
for (const issue of issues) {
|
|
93
|
+
if (issue.suggestion)
|
|
94
|
+
continue;
|
|
95
|
+
const headTag = HEAD_INSERTS[issue.ruleId];
|
|
96
|
+
if (headTag) {
|
|
97
|
+
const s = suggestHeadInsert(doc, headTag);
|
|
98
|
+
if (s)
|
|
99
|
+
issue.suggestion = s;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return issues;
|
|
103
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { buildIssue } from './helpers.js';
|
|
2
|
+
const JS_FILE_TYPES = new Set(['jsx', 'tsx', 'vue', 'svelte']);
|
|
3
|
+
/**
|
|
4
|
+
* Hand-coded handlers for technical rules that require compound conditions,
|
|
5
|
+
* iteration, or runtime context that can't be expressed as a single
|
|
6
|
+
* declarative check descriptor.
|
|
7
|
+
*
|
|
8
|
+
* Simple checks (missing-lang, missing-viewport, accidental-noindex, etc.)
|
|
9
|
+
* live as `check` descriptors in seo-rules.json.
|
|
10
|
+
*/
|
|
11
|
+
export function checkTechnicalRules(rule, doc) {
|
|
12
|
+
switch (rule.id) {
|
|
13
|
+
case 'noindex-in-javascript': {
|
|
14
|
+
if (!JS_FILE_TYPES.has(doc.fileType))
|
|
15
|
+
return null;
|
|
16
|
+
if (doc.hasNoindex)
|
|
17
|
+
return null; // already caught by accidental-noindex
|
|
18
|
+
const dynamicNoindex = /content\s*=\s*\{[^}]*noindex/.test(doc.rawContent) ||
|
|
19
|
+
/['"`]noindex,\s*nofollow['"`]/.test(doc.rawContent);
|
|
20
|
+
if (dynamicNoindex)
|
|
21
|
+
return buildIssue(rule, doc, null, rule.fixTemplate);
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
case 'robots-meta-outside-head': {
|
|
25
|
+
if (doc.fileType !== 'html')
|
|
26
|
+
return null;
|
|
27
|
+
const headEndIdx = doc.rawContent.toLowerCase().indexOf('</head>');
|
|
28
|
+
if (headEndIdx < 0)
|
|
29
|
+
return null;
|
|
30
|
+
const bodyContent = doc.rawContent.slice(headEndIdx);
|
|
31
|
+
if (/<meta[^>]+name\s*=\s*["']robots["'][^>]*>/i.test(bodyContent)) {
|
|
32
|
+
return buildIssue(rule, doc, null, rule.fixTemplate);
|
|
33
|
+
}
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
case 'missing-hreflang': {
|
|
37
|
+
if (!doc.isPageDocument)
|
|
38
|
+
return null;
|
|
39
|
+
if (doc.hreflangLinks.length > 0)
|
|
40
|
+
return null;
|
|
41
|
+
if (doc.langAttribute && !/^en(-|$)/i.test(doc.langAttribute.value)) {
|
|
42
|
+
return buildIssue(rule, doc, doc.langAttribute.line, rule.fixTemplate);
|
|
43
|
+
}
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
case 'hreflang-no-combined-attributes': {
|
|
47
|
+
const issues = [];
|
|
48
|
+
for (const link of doc.hreflangLinks) {
|
|
49
|
+
if (link.hasExtraAttrs) {
|
|
50
|
+
issues.push(buildIssue(rule, doc, link.line, rule.fixTemplate
|
|
51
|
+
.replace('{{LANG_CODE}}', link.lang)
|
|
52
|
+
.replace('{{URL}}', link.href)));
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return issues.length > 0 ? issues : null;
|
|
56
|
+
}
|
|
57
|
+
case 'url-parameters-best-practice': {
|
|
58
|
+
if (doc.ogUrl?.value.includes('?') && !doc.canonicalUrl) {
|
|
59
|
+
return buildIssue(rule, doc, doc.ogUrl.line, rule.fixTemplate);
|
|
60
|
+
}
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
case 'googlebot-file-size-limits': {
|
|
64
|
+
const sizeKB = doc.rawContent.length / 1024;
|
|
65
|
+
if (sizeKB > 500) {
|
|
66
|
+
return buildIssue(rule, doc, null, rule.fixTemplate + ` Current file size: ~${Math.round(sizeKB)}KB.`);
|
|
67
|
+
}
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
case 'javascript-paywall-detection': {
|
|
71
|
+
if (!JS_FILE_TYPES.has(doc.fileType))
|
|
72
|
+
return null;
|
|
73
|
+
const paywallClass = /(?:className|class)\s*=\s*["'][^"']*(?:paywall|subscription-wall|paywalled|locked-content|premium-content|members-only)[^"']*/i;
|
|
74
|
+
if (paywallClass.test(doc.rawContent)) {
|
|
75
|
+
const hasPaywallSchema = doc.jsonLdBlocks.some(b => b.content.includes('isAccessibleForFree'));
|
|
76
|
+
if (!hasPaywallSchema)
|
|
77
|
+
return buildIssue(rule, doc, null, rule.fixTemplate);
|
|
78
|
+
}
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
// Cannot check from static file analysis alone
|
|
82
|
+
case 'mobile-version-links':
|
|
83
|
+
case 'explicit-content-video-crawlable':
|
|
84
|
+
case 'robots-txt-unsupported-fields':
|
|
85
|
+
case 'indexing-api-spam-detection':
|
|
86
|
+
case 'ai-mode-search-console':
|
|
87
|
+
case 'preferred-sources-feature':
|
|
88
|
+
case 'core-updates-continuous':
|
|
89
|
+
case 'spam-policies-apply-to-ai-responses':
|
|
90
|
+
case 'javascript-non-200-rendering':
|
|
91
|
+
case 'canonical-javascript-best-practice':
|
|
92
|
+
case 'safe-search-explicit-tagging':
|
|
93
|
+
case 'agent-friendly-website':
|
|
94
|
+
case 'favicon-aspect-ratio':
|
|
95
|
+
case 'sitemap-recommended':
|
|
96
|
+
return null;
|
|
97
|
+
default:
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import * as t from '@babel/types';
|
|
2
|
+
/**
|
|
3
|
+
* Best-effort static evaluation of a Babel expression node into a plain JS
|
|
4
|
+
* value. Handles the literal shapes frameworks use for metadata objects:
|
|
5
|
+
* strings, template literals with no substitutions, numbers, booleans, null,
|
|
6
|
+
* arrays, and nested objects. Anything dynamic (variables, calls, spreads)
|
|
7
|
+
* evaluates to `undefined` — the caller treats that as "present but unknown",
|
|
8
|
+
* never as "missing", so dynamic metadata never produces a false positive.
|
|
9
|
+
*/
|
|
10
|
+
export function astToValue(node) {
|
|
11
|
+
if (!node)
|
|
12
|
+
return undefined;
|
|
13
|
+
if (t.isStringLiteral(node))
|
|
14
|
+
return node.value;
|
|
15
|
+
if (t.isNumericLiteral(node))
|
|
16
|
+
return node.value;
|
|
17
|
+
if (t.isBooleanLiteral(node))
|
|
18
|
+
return node.value;
|
|
19
|
+
if (t.isNullLiteral(node))
|
|
20
|
+
return null;
|
|
21
|
+
// `as const`, `satisfies`, parenthesised, TS assertions — unwrap
|
|
22
|
+
if (t.isTSAsExpression(node) || t.isTSSatisfiesExpression(node))
|
|
23
|
+
return astToValue(node.expression);
|
|
24
|
+
if (t.isTSNonNullExpression(node))
|
|
25
|
+
return astToValue(node.expression);
|
|
26
|
+
if (t.isParenthesizedExpression(node))
|
|
27
|
+
return astToValue(node.expression);
|
|
28
|
+
if (t.isTemplateLiteral(node)) {
|
|
29
|
+
// Only static templates (no ${...}) are resolvable
|
|
30
|
+
if (node.expressions.length === 0 && node.quasis.length === 1) {
|
|
31
|
+
return node.quasis[0].value.cooked ?? node.quasis[0].value.raw;
|
|
32
|
+
}
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
if (t.isArrayExpression(node)) {
|
|
36
|
+
return node.elements.map(el => (el ? astToValue(el) : undefined));
|
|
37
|
+
}
|
|
38
|
+
if (t.isObjectExpression(node)) {
|
|
39
|
+
const obj = {};
|
|
40
|
+
for (const prop of node.properties) {
|
|
41
|
+
if (!t.isObjectProperty(prop))
|
|
42
|
+
continue;
|
|
43
|
+
let key;
|
|
44
|
+
if (t.isIdentifier(prop.key) && !prop.computed)
|
|
45
|
+
key = prop.key.name;
|
|
46
|
+
else if (t.isStringLiteral(prop.key))
|
|
47
|
+
key = prop.key.value;
|
|
48
|
+
if (key === undefined)
|
|
49
|
+
continue;
|
|
50
|
+
obj[key] = astToValue(prop.value);
|
|
51
|
+
}
|
|
52
|
+
return obj;
|
|
53
|
+
}
|
|
54
|
+
return undefined;
|
|
55
|
+
}
|
|
56
|
+
/** Reads a nested path (e.g. "openGraph.images") returning undefined if any hop is missing. */
|
|
57
|
+
export function getPath(value, path) {
|
|
58
|
+
return path.split('.').reduce((acc, key) => {
|
|
59
|
+
if (acc && typeof acc === 'object')
|
|
60
|
+
return acc[key];
|
|
61
|
+
return undefined;
|
|
62
|
+
}, value);
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Coerces a metadata value into a display string when possible. Handles the
|
|
66
|
+
* common shapes: plain string; `{ url }` / `{ default }` / `{ absolute }`
|
|
67
|
+
* objects; and arrays (first element). Returns undefined for anything else.
|
|
68
|
+
*/
|
|
69
|
+
export function toStringish(value) {
|
|
70
|
+
if (typeof value === 'string')
|
|
71
|
+
return value;
|
|
72
|
+
if (Array.isArray(value))
|
|
73
|
+
return value.length ? toStringish(value[0]) : undefined;
|
|
74
|
+
if (value && typeof value === 'object') {
|
|
75
|
+
const o = value;
|
|
76
|
+
for (const k of ['url', 'default', 'absolute', 'content']) {
|
|
77
|
+
if (typeof o[k] === 'string')
|
|
78
|
+
return o[k];
|
|
79
|
+
}
|
|
80
|
+
// nested (e.g. { url: { … } }) — give up rather than guess
|
|
81
|
+
}
|
|
82
|
+
return undefined;
|
|
83
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { LAYOUT_SHELL_SUPPRESS, PAGE_INHERITED_FROM_LAYOUT } from './roles.js';
|
|
2
|
+
/**
|
|
3
|
+
* Astro adapter.
|
|
4
|
+
*
|
|
5
|
+
* Astro files are a `---` frontmatter fence (JS/TS) followed by an HTML-like
|
|
6
|
+
* template. Two patterns cause false positives for a static reader:
|
|
7
|
+
*
|
|
8
|
+
* 1. A base **layout** owns the document shell and sets `<title>{title}</title>`
|
|
9
|
+
* / `<meta name="description" content={description} />` from props. A static
|
|
10
|
+
* reader sees an expression, not text, and would wrongly flag the title/
|
|
11
|
+
* description as missing or too short.
|
|
12
|
+
* 2. A **page** in `src/pages/` delegates its whole `<head>` to a layout
|
|
13
|
+
* component (`<Layout title={…}>…</Layout>`) and has no head of its own — so
|
|
14
|
+
* head/meta rules must not be demanded of the page file.
|
|
15
|
+
*
|
|
16
|
+
* The adapter maps Astro onto the shared layout/page roles and reconciles these.
|
|
17
|
+
*/
|
|
18
|
+
function splitFrontmatter(content) {
|
|
19
|
+
const m = /^\s*---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/.exec(content);
|
|
20
|
+
if (m)
|
|
21
|
+
return { frontmatter: m[1], template: m[2] };
|
|
22
|
+
return { frontmatter: '', template: content };
|
|
23
|
+
}
|
|
24
|
+
/** A `<title>` whose content is (or contains) a `{expr}` — dynamic, not static. */
|
|
25
|
+
function hasDynamicTitle(template) {
|
|
26
|
+
const m = /<title[^>]*>([\s\S]*?)<\/title>/i.exec(template);
|
|
27
|
+
return !!m && /\{[^}]+\}/.test(m[1]);
|
|
28
|
+
}
|
|
29
|
+
/** A `<meta name="description">` whose `content` is a `{expr}` binding. */
|
|
30
|
+
function hasDynamicDescription(template) {
|
|
31
|
+
// content={expr} with name="description" in either attribute order.
|
|
32
|
+
return (/<meta\b[^>]*\bname=["']description["'][^>]*\bcontent=\{[^}]+\}/i.test(template) ||
|
|
33
|
+
/<meta\b[^>]*\bcontent=\{[^}]+\}[^>]*\bname=["']description["']/i.test(template));
|
|
34
|
+
}
|
|
35
|
+
/** Does the template hand its <head> to a wrapping layout component? */
|
|
36
|
+
function delegatesToLayout(template) {
|
|
37
|
+
// A capitalised component whose name ends in/contains "Layout", or common
|
|
38
|
+
// base-shell names, used as an element: <Layout …>, <BaseLayout>, <MainLayout>.
|
|
39
|
+
return (/<[A-Z][A-Za-z0-9]*Layout\b/.test(template) ||
|
|
40
|
+
/<Layout\b/.test(template));
|
|
41
|
+
}
|
|
42
|
+
function inPagesDir(filePath) {
|
|
43
|
+
return /(^|\/)(src\/)?pages\//.test(filePath);
|
|
44
|
+
}
|
|
45
|
+
function isLayoutPath(filePath) {
|
|
46
|
+
return /(^|\/)layouts?\//.test(filePath);
|
|
47
|
+
}
|
|
48
|
+
const astroAdapter = {
|
|
49
|
+
name: 'astro',
|
|
50
|
+
detect(input) {
|
|
51
|
+
return input.fileType === 'astro' || /\.astro$/i.test(input.filePath);
|
|
52
|
+
},
|
|
53
|
+
apply(input, doc) {
|
|
54
|
+
const { template } = splitFrontmatter(input.content);
|
|
55
|
+
const suppressedRuleIds = new Set();
|
|
56
|
+
const suppressedCategories = new Set();
|
|
57
|
+
const rendersHtmlShell = /<html[\s>]/i.test(template) || /<head[\s>]/i.test(template);
|
|
58
|
+
// ── Layout / shell ──────────────────────────────────────────────────────────
|
|
59
|
+
if (rendersHtmlShell || isLayoutPath(input.filePath)) {
|
|
60
|
+
for (const id of LAYOUT_SHELL_SUPPRESS)
|
|
61
|
+
suppressedRuleIds.add(id);
|
|
62
|
+
// Dynamic head values are rendered by Astro — treat them as provided so we
|
|
63
|
+
// don't false-positive on missing/too-short/too-long.
|
|
64
|
+
if (hasDynamicTitle(template)) {
|
|
65
|
+
if (!doc.titleTag)
|
|
66
|
+
doc.titleTag = { value: 'Astro dynamic title', line: 0 };
|
|
67
|
+
suppressedRuleIds.add('missing-title');
|
|
68
|
+
suppressedRuleIds.add('title-too-short');
|
|
69
|
+
suppressedRuleIds.add('title-too-long');
|
|
70
|
+
}
|
|
71
|
+
if (hasDynamicDescription(template)) {
|
|
72
|
+
if (!doc.metaDescription)
|
|
73
|
+
doc.metaDescription = { value: 'Astro dynamic description', line: 0 };
|
|
74
|
+
suppressedRuleIds.add('missing-meta-description');
|
|
75
|
+
suppressedRuleIds.add('meta-description-too-short');
|
|
76
|
+
suppressedRuleIds.add('meta-description-too-long');
|
|
77
|
+
}
|
|
78
|
+
return {
|
|
79
|
+
name: 'astro',
|
|
80
|
+
role: 'layout',
|
|
81
|
+
suppressedRuleIds: [...suppressedRuleIds],
|
|
82
|
+
suppressedCategories: [...suppressedCategories],
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
// ── Page that delegates its <head> to a layout ──────────────────────────────
|
|
86
|
+
if (delegatesToLayout(template) || inPagesDir(input.filePath)) {
|
|
87
|
+
for (const id of PAGE_INHERITED_FROM_LAYOUT)
|
|
88
|
+
suppressedRuleIds.add(id);
|
|
89
|
+
// The head lives in the wrapping layout — don't demand meta tags here.
|
|
90
|
+
suppressedCategories.add('meta');
|
|
91
|
+
return {
|
|
92
|
+
name: 'astro',
|
|
93
|
+
role: 'page',
|
|
94
|
+
suppressedRuleIds: [...suppressedRuleIds],
|
|
95
|
+
suppressedCategories: [...suppressedCategories],
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
// ── A partial/component .astro (no shell, no layout wrapper) ─────────────────
|
|
99
|
+
return {
|
|
100
|
+
name: 'astro',
|
|
101
|
+
role: 'component',
|
|
102
|
+
suppressedRuleIds: [],
|
|
103
|
+
suppressedCategories: [],
|
|
104
|
+
};
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
export default astroAdapter;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import nextjsAdapter from './nextjs.js';
|
|
2
|
+
import remixAdapter from './remix.js';
|
|
3
|
+
import astroAdapter from './astro.js';
|
|
4
|
+
/**
|
|
5
|
+
* Registered framework adapters, in priority order. The first whose `detect`
|
|
6
|
+
* matches wins. To support a new framework, implement FrameworkAdapter and add
|
|
7
|
+
* it here — nothing else in the parser or engine needs to change.
|
|
8
|
+
*/
|
|
9
|
+
export const ADAPTERS = [nextjsAdapter, remixAdapter, astroAdapter];
|
|
10
|
+
/**
|
|
11
|
+
* Runs the first matching adapter against a freshly-parsed document, enriching
|
|
12
|
+
* it in place and stamping `doc.framework`. No-op (leaves `doc.framework`
|
|
13
|
+
* null) when no adapter recognises the file.
|
|
14
|
+
*/
|
|
15
|
+
export function applyFrameworkAdapters(input, doc) {
|
|
16
|
+
for (const adapter of ADAPTERS) {
|
|
17
|
+
let matched = false;
|
|
18
|
+
try {
|
|
19
|
+
matched = adapter.detect(input);
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
matched = false;
|
|
23
|
+
}
|
|
24
|
+
if (!matched)
|
|
25
|
+
continue;
|
|
26
|
+
try {
|
|
27
|
+
doc.framework = adapter.apply(input, doc);
|
|
28
|
+
}
|
|
29
|
+
catch (err) {
|
|
30
|
+
console.warn(`[WARN] Framework adapter "${adapter.name}" failed on ${input.filePath}:`, err);
|
|
31
|
+
doc.framework = null;
|
|
32
|
+
}
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
doc.framework = null;
|
|
36
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import * as t from '@babel/types';
|
|
2
|
+
import { astToValue, getPath, toStringish } from './ast-value.js';
|
|
3
|
+
import { LAYOUT_SHELL_SUPPRESS, HEAD_GOVERNED_ABSENCE_RULES } from './roles.js';
|
|
4
|
+
// Next.js injects these into every rendered page, so their absence from source
|
|
5
|
+
// is never a real issue.
|
|
6
|
+
const FRAMEWORK_DEFAULTS = ['missing-charset', 'missing-viewport', 'missing-favicon'];
|
|
7
|
+
const APP_ROUTER_FILES = new Set(['layout', 'page', 'template', 'default', 'error', 'loading', 'not-found', 'route']);
|
|
8
|
+
function basename(filePath) {
|
|
9
|
+
const file = filePath.split('/').pop() ?? '';
|
|
10
|
+
return file.replace(/\.(t|j)sx?$/, '');
|
|
11
|
+
}
|
|
12
|
+
function programBody(ast) {
|
|
13
|
+
return ast?.program?.body ?? [];
|
|
14
|
+
}
|
|
15
|
+
/** Finds the initializer of `export const <name> = …`. */
|
|
16
|
+
function findNamedExportInit(ast, name) {
|
|
17
|
+
for (const stmt of programBody(ast)) {
|
|
18
|
+
if (t.isExportNamedDeclaration(stmt) && stmt.declaration && t.isVariableDeclaration(stmt.declaration)) {
|
|
19
|
+
for (const decl of stmt.declaration.declarations) {
|
|
20
|
+
if (t.isIdentifier(decl.id) && decl.id.name === name && decl.init) {
|
|
21
|
+
return { node: decl.init, line: decl.init.loc?.start.line ?? stmt.loc?.start.line ?? 0 };
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
/** True if the module exports a const or function of the given name. */
|
|
29
|
+
function hasNamedExport(ast, name) {
|
|
30
|
+
for (const stmt of programBody(ast)) {
|
|
31
|
+
if (!t.isExportNamedDeclaration(stmt) || !stmt.declaration)
|
|
32
|
+
continue;
|
|
33
|
+
const d = stmt.declaration;
|
|
34
|
+
if (t.isFunctionDeclaration(d) && d.id?.name === name)
|
|
35
|
+
return true;
|
|
36
|
+
if (t.isVariableDeclaration(d)) {
|
|
37
|
+
for (const decl of d.declarations) {
|
|
38
|
+
if (t.isIdentifier(decl.id) && decl.id.name === name)
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
function importsNext(ast) {
|
|
46
|
+
return programBody(ast).some(stmt => t.isImportDeclaration(stmt) && /^next(\/|$)/.test(stmt.source.value));
|
|
47
|
+
}
|
|
48
|
+
const HTTP_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS'];
|
|
49
|
+
/**
|
|
50
|
+
* A Next.js App Router **route handler** (`app/**\/route.ts|tsx`) or an OG image
|
|
51
|
+
* route (`next/og` ImageResponse). These return a Response/Image — never an HTML
|
|
52
|
+
* page — so no page/content rule applies. Signals: the conventional `route`
|
|
53
|
+
* filename, or a module that exports HTTP-method handlers with no default page
|
|
54
|
+
* component.
|
|
55
|
+
*/
|
|
56
|
+
function isRouteHandler(ast, filePath) {
|
|
57
|
+
if (basename(filePath) === 'route')
|
|
58
|
+
return true;
|
|
59
|
+
const hasMethodExport = HTTP_METHODS.some(m => hasNamedExport(ast, m));
|
|
60
|
+
const hasDefault = programBody(ast).some(s => t.isExportDefaultDeclaration(s));
|
|
61
|
+
return hasMethodExport && !hasDefault;
|
|
62
|
+
}
|
|
63
|
+
const nextjsAdapter = {
|
|
64
|
+
name: 'nextjs',
|
|
65
|
+
detect(input) {
|
|
66
|
+
if (!input.ast)
|
|
67
|
+
return false;
|
|
68
|
+
const base = basename(input.filePath);
|
|
69
|
+
const inAppOrPages = /\/(app|pages)\//.test(input.filePath) || /(^|\/)(app|pages)\//.test(input.filePath);
|
|
70
|
+
if (importsNext(input.ast))
|
|
71
|
+
return true;
|
|
72
|
+
if (hasNamedExport(input.ast, 'metadata') || hasNamedExport(input.ast, 'generateMetadata') || hasNamedExport(input.ast, 'viewport'))
|
|
73
|
+
return true;
|
|
74
|
+
if (inAppOrPages && APP_ROUTER_FILES.has(base))
|
|
75
|
+
return true;
|
|
76
|
+
return false;
|
|
77
|
+
},
|
|
78
|
+
apply(input, doc) {
|
|
79
|
+
const ast = input.ast;
|
|
80
|
+
// Route handlers (incl. next/og image routes) emit no HTML page — the engine
|
|
81
|
+
// skips every rule for role 'route'. Short-circuit before any page/meta logic.
|
|
82
|
+
if (isRouteHandler(ast, input.filePath)) {
|
|
83
|
+
return { name: 'nextjs', role: 'route', suppressedRuleIds: [], suppressedCategories: [] };
|
|
84
|
+
}
|
|
85
|
+
const suppressedRuleIds = new Set(FRAMEWORK_DEFAULTS);
|
|
86
|
+
const suppressedCategories = new Set();
|
|
87
|
+
// ── Extract the Metadata API object ────────────────────────────────────────
|
|
88
|
+
const metaExport = findNamedExportInit(ast, 'metadata');
|
|
89
|
+
const hasGenerate = hasNamedExport(ast, 'generateMetadata');
|
|
90
|
+
let hasStaticTitle = false;
|
|
91
|
+
let hasStaticDescription = false;
|
|
92
|
+
if (metaExport) {
|
|
93
|
+
const line = metaExport.line;
|
|
94
|
+
const meta = astToValue(metaExport.node);
|
|
95
|
+
const set = (cur, path, assign) => {
|
|
96
|
+
const v = toStringish(getPath(cur, path));
|
|
97
|
+
if (typeof v === 'string' && v.length > 0)
|
|
98
|
+
assign(v);
|
|
99
|
+
};
|
|
100
|
+
if (meta && typeof meta === 'object') {
|
|
101
|
+
set(meta, 'title', v => { doc.titleTag = { value: v, line }; hasStaticTitle = true; });
|
|
102
|
+
set(meta, 'description', v => { doc.metaDescription = { value: v, line }; hasStaticDescription = true; });
|
|
103
|
+
set(meta, 'openGraph.title', v => (doc.ogTitle = { value: v, line }));
|
|
104
|
+
set(meta, 'openGraph.description', v => (doc.ogDescription = { value: v, line }));
|
|
105
|
+
set(meta, 'openGraph.images', v => (doc.ogImage = { value: v, line }));
|
|
106
|
+
set(meta, 'openGraph.url', v => (doc.ogUrl = { value: v, line }));
|
|
107
|
+
set(meta, 'openGraph.type', v => (doc.ogType = { value: v, line }));
|
|
108
|
+
set(meta, 'openGraph.locale', v => (doc.ogLocale = { value: v, line }));
|
|
109
|
+
set(meta, 'twitter.card', v => (doc.twitterCard = { value: v, line }));
|
|
110
|
+
set(meta, 'twitter.title', v => (doc.twitterTitle = { value: v, line }));
|
|
111
|
+
set(meta, 'twitter.description', v => (doc.twitterDescription = { value: v, line }));
|
|
112
|
+
set(meta, 'twitter.images', v => (doc.twitterImage = { value: v, line }));
|
|
113
|
+
set(meta, 'alternates.canonical', v => (doc.canonicalUrl = { value: v, line }));
|
|
114
|
+
const robots = getPath(meta, 'robots');
|
|
115
|
+
const robotsStr = typeof robots === 'string' ? robots.toLowerCase() : '';
|
|
116
|
+
const robotsIndexFalse = robots && typeof robots === 'object' && robots.index === false;
|
|
117
|
+
if (robotsStr.includes('noindex') || robotsIndexFalse) {
|
|
118
|
+
doc.hasNoindex = true;
|
|
119
|
+
doc.metaRobots = { value: robotsStr || 'noindex', line };
|
|
120
|
+
}
|
|
121
|
+
if (getPath(meta, 'icons') !== undefined)
|
|
122
|
+
doc.hasFavicon = true;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
void hasStaticTitle;
|
|
126
|
+
void hasStaticDescription;
|
|
127
|
+
void hasGenerate;
|
|
128
|
+
// ── Determine the file's role ───────────────────────────────────────────────
|
|
129
|
+
const role = detectRole(input, doc);
|
|
130
|
+
// The framework's Metadata API owns the <head>, merged across root/nested
|
|
131
|
+
// layouts + page + generateMetadata + auto-fallbacks. Absence of any head
|
|
132
|
+
// element is therefore unprovable from one file — suppress that whole class
|
|
133
|
+
// on every page/layout. What still fires: positively-detected head problems
|
|
134
|
+
// (accidental-noindex from metadata.robots, literal invalid-json-ld) and all
|
|
135
|
+
// body-content rules (headings, images, links) reflecting the real DOM.
|
|
136
|
+
if (role === 'page' || role === 'layout') {
|
|
137
|
+
for (const id of HEAD_GOVERNED_ABSENCE_RULES)
|
|
138
|
+
suppressedRuleIds.add(id);
|
|
139
|
+
}
|
|
140
|
+
// A layout shell owns no page content — its heading outline lives in pages.
|
|
141
|
+
if (role === 'layout') {
|
|
142
|
+
for (const id of LAYOUT_SHELL_SUPPRESS)
|
|
143
|
+
suppressedRuleIds.add(id);
|
|
144
|
+
}
|
|
145
|
+
return {
|
|
146
|
+
name: 'nextjs',
|
|
147
|
+
role,
|
|
148
|
+
suppressedRuleIds: [...suppressedRuleIds],
|
|
149
|
+
suppressedCategories: [...suppressedCategories],
|
|
150
|
+
};
|
|
151
|
+
},
|
|
152
|
+
};
|
|
153
|
+
function detectRole(input, doc) {
|
|
154
|
+
const base = basename(input.filePath);
|
|
155
|
+
if (base === 'layout' || base === 'template')
|
|
156
|
+
return 'layout';
|
|
157
|
+
if (base === 'page')
|
|
158
|
+
return 'page';
|
|
159
|
+
// Structural fallbacks for non-conventional filenames.
|
|
160
|
+
const rendersHtmlShell = /<html[\s>]/i.test(input.content) || /<body[\s>]/i.test(input.content);
|
|
161
|
+
const rendersChildren = /\{\s*children\s*\}/.test(input.content);
|
|
162
|
+
if (rendersHtmlShell || rendersChildren)
|
|
163
|
+
return 'layout';
|
|
164
|
+
// A default-exported component with page-level content is a page.
|
|
165
|
+
const hasDefaultExport = programBody(input.ast).some(s => t.isExportDefaultDeclaration(s));
|
|
166
|
+
if (hasDefaultExport && (doc.headings.length > 0 || doc.images.length > 0 || doc.links.length > 0))
|
|
167
|
+
return 'page';
|
|
168
|
+
return 'component';
|
|
169
|
+
}
|
|
170
|
+
export default nextjsAdapter;
|