@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,130 @@
|
|
|
1
|
+
import * as t from '@babel/types';
|
|
2
|
+
import { astToValue } from './ast-value.js';
|
|
3
|
+
import { LAYOUT_SHELL_SUPPRESS, HEAD_GOVERNED_ABSENCE_RULES } from './roles.js';
|
|
4
|
+
/**
|
|
5
|
+
* Remix / React Router adapter. Metadata comes from an exported `meta` function
|
|
6
|
+
* that returns an array of descriptor objects:
|
|
7
|
+
*
|
|
8
|
+
* export const meta: MetaFunction = () => [
|
|
9
|
+
* { title: "…" },
|
|
10
|
+
* { name: "description", content: "…" },
|
|
11
|
+
* { property: "og:title", content: "…" },
|
|
12
|
+
* ];
|
|
13
|
+
*/
|
|
14
|
+
function programBody(ast) {
|
|
15
|
+
return ast?.program?.body ?? [];
|
|
16
|
+
}
|
|
17
|
+
function importsRemix(ast) {
|
|
18
|
+
return programBody(ast).some(stmt => t.isImportDeclaration(stmt) && /^(@remix-run\/|react-router)/.test(stmt.source.value));
|
|
19
|
+
}
|
|
20
|
+
function findExportedFn(ast, name) {
|
|
21
|
+
for (const stmt of programBody(ast)) {
|
|
22
|
+
if (!t.isExportNamedDeclaration(stmt) || !stmt.declaration)
|
|
23
|
+
continue;
|
|
24
|
+
const d = stmt.declaration;
|
|
25
|
+
if (t.isFunctionDeclaration(d) && d.id?.name === name)
|
|
26
|
+
return d;
|
|
27
|
+
if (t.isVariableDeclaration(d)) {
|
|
28
|
+
for (const decl of d.declarations) {
|
|
29
|
+
if (t.isIdentifier(decl.id) && decl.id.name === name && decl.init &&
|
|
30
|
+
(t.isArrowFunctionExpression(decl.init) || t.isFunctionExpression(decl.init))) {
|
|
31
|
+
return decl.init;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
/** Pulls the array literal a meta function returns, if statically present. */
|
|
39
|
+
function returnedArray(fn) {
|
|
40
|
+
const body = fn.body;
|
|
41
|
+
if (t.isArrayExpression(body))
|
|
42
|
+
return body; // concise arrow: () => [ … ]
|
|
43
|
+
if (t.isBlockStatement(body)) {
|
|
44
|
+
for (const stmt of body.body) {
|
|
45
|
+
if (t.isReturnStatement(stmt) && stmt.argument && t.isArrayExpression(stmt.argument)) {
|
|
46
|
+
return stmt.argument;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
const remixAdapter = {
|
|
53
|
+
name: 'remix',
|
|
54
|
+
detect(input) {
|
|
55
|
+
if (!input.ast)
|
|
56
|
+
return false;
|
|
57
|
+
if (importsRemix(input.ast))
|
|
58
|
+
return true;
|
|
59
|
+
// A routes/ file that exports a Remix-style meta or loader.
|
|
60
|
+
const inRoutes = /\/routes?\//.test(input.filePath) || /(^|\/)root\.(t|j)sx$/.test(input.filePath);
|
|
61
|
+
return inRoutes && !!findExportedFn(input.ast, 'meta');
|
|
62
|
+
},
|
|
63
|
+
apply(input, doc) {
|
|
64
|
+
const ast = input.ast;
|
|
65
|
+
const suppressedRuleIds = new Set();
|
|
66
|
+
const suppressedCategories = new Set();
|
|
67
|
+
const metaFn = findExportedFn(ast, 'meta');
|
|
68
|
+
let hasTitle = false;
|
|
69
|
+
let hasDescription = false;
|
|
70
|
+
if (metaFn) {
|
|
71
|
+
const arr = returnedArray(metaFn);
|
|
72
|
+
const line = (metaFn.loc?.start.line ?? 0);
|
|
73
|
+
if (arr) {
|
|
74
|
+
for (const el of arr.elements) {
|
|
75
|
+
if (!el || !t.isObjectExpression(el))
|
|
76
|
+
continue;
|
|
77
|
+
const entry = astToValue(el);
|
|
78
|
+
if (!entry)
|
|
79
|
+
continue;
|
|
80
|
+
const content = typeof entry.content === 'string' ? entry.content : '';
|
|
81
|
+
if (typeof entry.title === 'string' && entry.title) {
|
|
82
|
+
doc.titleTag = { value: entry.title, line };
|
|
83
|
+
hasTitle = true;
|
|
84
|
+
}
|
|
85
|
+
else if (entry.name === 'description') {
|
|
86
|
+
doc.metaDescription = { value: content, line };
|
|
87
|
+
hasDescription = true;
|
|
88
|
+
}
|
|
89
|
+
else if (entry.property === 'og:title')
|
|
90
|
+
doc.ogTitle = { value: content, line };
|
|
91
|
+
else if (entry.property === 'og:description')
|
|
92
|
+
doc.ogDescription = { value: content, line };
|
|
93
|
+
else if (entry.property === 'og:image')
|
|
94
|
+
doc.ogImage = { value: content, line };
|
|
95
|
+
else if (entry.property === 'og:url')
|
|
96
|
+
doc.ogUrl = { value: content, line };
|
|
97
|
+
else if (entry.property === 'og:type')
|
|
98
|
+
doc.ogType = { value: content, line };
|
|
99
|
+
else if (entry.name === 'twitter:card')
|
|
100
|
+
doc.twitterCard = { value: content, line };
|
|
101
|
+
else if (entry.name === 'twitter:title')
|
|
102
|
+
doc.twitterTitle = { value: content, line };
|
|
103
|
+
else if (entry.name === 'twitter:image')
|
|
104
|
+
doc.twitterImage = { value: content, line };
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
// Remix root (renders <html>) is the shell; route files are pages.
|
|
109
|
+
const isRoot = /(^|\/)root\.(t|j)sx$/.test(input.filePath) || /<html[\s>]/i.test(input.content) || /\{\s*children\s*\}/.test(input.content);
|
|
110
|
+
const role = isRoot ? 'layout' : 'page';
|
|
111
|
+
// Remix composes the <head> from root + nested route meta()/links() exports —
|
|
112
|
+
// absence of a head element can't be proven from one route file. Suppress the
|
|
113
|
+
// whole "missing head element" class (see HEAD_GOVERNED_ABSENCE_RULES).
|
|
114
|
+
void hasTitle;
|
|
115
|
+
void hasDescription;
|
|
116
|
+
for (const id of HEAD_GOVERNED_ABSENCE_RULES)
|
|
117
|
+
suppressedRuleIds.add(id);
|
|
118
|
+
if (role === 'layout') {
|
|
119
|
+
for (const id of LAYOUT_SHELL_SUPPRESS)
|
|
120
|
+
suppressedRuleIds.add(id);
|
|
121
|
+
}
|
|
122
|
+
return {
|
|
123
|
+
name: 'remix',
|
|
124
|
+
role,
|
|
125
|
+
suppressedRuleIds: [...suppressedRuleIds],
|
|
126
|
+
suppressedCategories: [...suppressedCategories],
|
|
127
|
+
};
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
export default remixAdapter;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Role-based rule suppression shared by all framework adapters.
|
|
3
|
+
*
|
|
4
|
+
* The idea is framework-agnostic: a "layout"/"shell" wraps page content, and a
|
|
5
|
+
* "page"/"route" inherits its document <head> from that shell. Each adapter maps
|
|
6
|
+
* its own file conventions onto these roles and reuses these sets so behaviour
|
|
7
|
+
* stays consistent across frameworks.
|
|
8
|
+
*/
|
|
9
|
+
// A layout/shell isn't expected to contain the page's heading outline or
|
|
10
|
+
// content structured data — those belong to the pages it wraps.
|
|
11
|
+
export const LAYOUT_SHELL_SUPPRESS = [
|
|
12
|
+
'missing-h1',
|
|
13
|
+
'multiple-h1',
|
|
14
|
+
'heading-hierarchy',
|
|
15
|
+
'empty-heading',
|
|
16
|
+
'missing-json-ld',
|
|
17
|
+
];
|
|
18
|
+
// A content page inherits its entire document shell (<html>, <head>) from the
|
|
19
|
+
// layout/root: lang, canonical, hreflang, and the head tags viewport/charset/
|
|
20
|
+
// favicon. None of these should be required in the page file itself.
|
|
21
|
+
export const PAGE_INHERITED_FROM_LAYOUT = [
|
|
22
|
+
'missing-lang',
|
|
23
|
+
'missing-canonical',
|
|
24
|
+
'missing-hreflang',
|
|
25
|
+
'missing-viewport',
|
|
26
|
+
'missing-charset',
|
|
27
|
+
'missing-favicon',
|
|
28
|
+
];
|
|
29
|
+
/**
|
|
30
|
+
* The complete set of "an expected <head> element is ABSENT" rules.
|
|
31
|
+
*
|
|
32
|
+
* On a framework file this class of finding can never be proven from static
|
|
33
|
+
* analysis, because the rendered <head> is composed by the framework's Metadata
|
|
34
|
+
* API and MERGES across root layout + nested layouts + the page + generateMetadata
|
|
35
|
+
* + framework auto-fallbacks (Next.js fills og:title from title, og:description
|
|
36
|
+
* from description, etc.) + SSR-injected JSON-LD. A single source file only ever
|
|
37
|
+
* shows a fragment of that, so flagging "missing X" is a false positive.
|
|
38
|
+
*
|
|
39
|
+
* We therefore suppress this whole class on framework page/layout files and keep
|
|
40
|
+
* it fully active on plain HTML (where the <head> IS the final, complete head).
|
|
41
|
+
* Real signal on framework files still comes through: things we can POSITIVELY
|
|
42
|
+
* detect — accidental-noindex (from metadata.robots or a literal tag) and a
|
|
43
|
+
* literal invalid-json-ld block — plus all body-level content rules (headings,
|
|
44
|
+
* images, links), which reflect the real rendered DOM and are never suppressed.
|
|
45
|
+
*/
|
|
46
|
+
export const HEAD_GOVERNED_ABSENCE_RULES = [
|
|
47
|
+
// title / description
|
|
48
|
+
'missing-title', 'title-too-short', 'title-too-long',
|
|
49
|
+
'missing-meta-description', 'meta-description-too-short', 'meta-description-too-long',
|
|
50
|
+
// Open Graph
|
|
51
|
+
'missing-og-title', 'missing-og-description', 'missing-og-image',
|
|
52
|
+
'missing-og-url', 'missing-og-type', 'missing-og-locale',
|
|
53
|
+
// Twitter
|
|
54
|
+
'missing-twitter-card', 'missing-twitter-title', 'missing-twitter-image',
|
|
55
|
+
// technical head tags
|
|
56
|
+
'missing-canonical', 'missing-lang', 'missing-viewport',
|
|
57
|
+
'missing-charset', 'missing-favicon', 'missing-hreflang',
|
|
58
|
+
// structured data (framework SSRs JSON-LD; presence/recommendation is unprovable statically)
|
|
59
|
+
'missing-json-ld', 'json-ld-recommended-format', 'structured-data-javascript-initial-html',
|
|
60
|
+
'organization-markup', 'site-name-schema',
|
|
61
|
+
// page-structure hints that can't be verified on a framework fragment
|
|
62
|
+
'read-more-deeplink', 'missing-preload-lcp',
|
|
63
|
+
];
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import * as cheerio from 'cheerio';
|
|
2
|
+
import { isPageDocument } from '../engine/page-detector.js';
|
|
3
|
+
const FRAMEWORK_MOUNT_PATTERNS = ['id="root"', 'id="app"', 'id="__next"', 'id="__nuxt"', '<app-root'];
|
|
4
|
+
export function parseHtml(filePath, content) {
|
|
5
|
+
const $ = cheerio.load(content);
|
|
6
|
+
function escapeRegex(s) {
|
|
7
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
8
|
+
}
|
|
9
|
+
function findLineForTag(tag, attrName, attrValue) {
|
|
10
|
+
const pattern = attrValue
|
|
11
|
+
? new RegExp(`<${tag}[^>]*${attrName}=["']${escapeRegex(attrValue)}["']`, 'i')
|
|
12
|
+
: new RegExp(`<${tag}[\\s>]`, 'i');
|
|
13
|
+
const match = content.match(pattern);
|
|
14
|
+
if (!match || match.index === undefined)
|
|
15
|
+
return null;
|
|
16
|
+
return content.substring(0, match.index).split('\n').length;
|
|
17
|
+
}
|
|
18
|
+
const titleEl = $('title').first();
|
|
19
|
+
const titleValue = titleEl.text().trim();
|
|
20
|
+
const titleLine = titleValue ? findLineForTag('title') : null;
|
|
21
|
+
const metaDescEl = $('meta[name="description"]').first();
|
|
22
|
+
const metaDescValue = metaDescEl.attr('content') || '';
|
|
23
|
+
const metaDescLine = metaDescValue ? findLineForTag('meta', 'name', 'description') : null;
|
|
24
|
+
const canonicalEl = $('link[rel="canonical"]').first();
|
|
25
|
+
const canonicalValue = canonicalEl.attr('href') || '';
|
|
26
|
+
const langEl = $('html').first();
|
|
27
|
+
const langValue = langEl.attr('lang') || '';
|
|
28
|
+
const viewportEl = $('meta[name="viewport"]').first();
|
|
29
|
+
const viewportValue = viewportEl.attr('content') || '';
|
|
30
|
+
const charsetEl = $('meta[charset]').first();
|
|
31
|
+
const charsetValue = charsetEl.attr('charset') || '';
|
|
32
|
+
const robotsEl = $('meta[name="robots"]').first();
|
|
33
|
+
const robotsValue = robotsEl.attr('content') || '';
|
|
34
|
+
const headings = [];
|
|
35
|
+
$('h1, h2, h3, h4, h5, h6').each((_, el) => {
|
|
36
|
+
const tag = el.tagName;
|
|
37
|
+
const text = $(el).text().trim();
|
|
38
|
+
const line = findLineForTag(tag) ?? 0;
|
|
39
|
+
headings.push({ tag, text, line });
|
|
40
|
+
});
|
|
41
|
+
const images = [];
|
|
42
|
+
$('img').each((_, el) => {
|
|
43
|
+
const src = $(el).attr('src') || '';
|
|
44
|
+
const alt = $(el).attr('alt') ?? null;
|
|
45
|
+
const loading = $(el).attr('loading') ?? null;
|
|
46
|
+
const line = findLineForTag('img', 'src', src) ?? 0;
|
|
47
|
+
images.push({ src, alt, loading, line });
|
|
48
|
+
});
|
|
49
|
+
const links = [];
|
|
50
|
+
$('a').each((_, el) => {
|
|
51
|
+
const href = $(el).attr('href') || '';
|
|
52
|
+
const text = $(el).text().trim();
|
|
53
|
+
const rel = $(el).attr('rel') ?? null;
|
|
54
|
+
const line = findLineForTag('a', 'href', href) ?? 0;
|
|
55
|
+
links.push({ href, text, rel, line });
|
|
56
|
+
});
|
|
57
|
+
const jsonLdBlocks = [];
|
|
58
|
+
$('script[type="application/ld+json"]').each((_, el) => {
|
|
59
|
+
const content_inner = $(el).html() || '';
|
|
60
|
+
const line = findLineForTag('script', 'type', 'application/ld+json') ?? 0;
|
|
61
|
+
jsonLdBlocks.push({ content: content_inner.trim(), line });
|
|
62
|
+
});
|
|
63
|
+
const preloadLinks = [];
|
|
64
|
+
$('link[rel="preload"]').each((_, el) => {
|
|
65
|
+
const href = $(el).attr('href') || '';
|
|
66
|
+
const as_ = $(el).attr('as') || '';
|
|
67
|
+
const line = findLineForTag('link', 'rel', 'preload') ?? 0;
|
|
68
|
+
preloadLinks.push({ href, as: as_, line });
|
|
69
|
+
});
|
|
70
|
+
const hreflangLinks = [];
|
|
71
|
+
$('link[rel="alternate"]').each((_, el) => {
|
|
72
|
+
const lang = $(el).attr('hreflang');
|
|
73
|
+
if (!lang)
|
|
74
|
+
return;
|
|
75
|
+
const href = $(el).attr('href') || '';
|
|
76
|
+
const attrNames = Object.keys(el.attribs || {});
|
|
77
|
+
const knownAttrs = new Set(['rel', 'hreflang', 'href', 'type']);
|
|
78
|
+
const hasExtraAttrs = attrNames.some(a => !knownAttrs.has(a));
|
|
79
|
+
const line = findLineForTag('link', 'hreflang', lang) ?? 0;
|
|
80
|
+
hreflangLinks.push({ lang, href, line, hasExtraAttrs });
|
|
81
|
+
});
|
|
82
|
+
const ogLocaleVal = $('meta[property="og:locale"]').attr('content') || '';
|
|
83
|
+
const doc = {
|
|
84
|
+
filePath,
|
|
85
|
+
fileType: 'html',
|
|
86
|
+
rawContent: content,
|
|
87
|
+
titleTag: titleValue ? { value: titleValue, line: titleLine ?? 0 } : null,
|
|
88
|
+
metaDescription: metaDescValue ? { value: metaDescValue, line: metaDescLine ?? 0 } : null,
|
|
89
|
+
metaRobots: robotsValue ? { value: robotsValue, line: findLineForTag('meta', 'name', 'robots') ?? 0 } : null,
|
|
90
|
+
canonicalUrl: canonicalValue ? { value: canonicalValue, line: findLineForTag('link', 'rel', 'canonical') ?? 0 } : null,
|
|
91
|
+
langAttribute: langValue ? { value: langValue, line: 1 } : null,
|
|
92
|
+
viewportMeta: viewportValue ? { value: viewportValue, line: findLineForTag('meta', 'name', 'viewport') ?? 0 } : null,
|
|
93
|
+
charsetMeta: charsetValue ? { value: charsetValue, line: findLineForTag('meta', 'charset', charsetValue) ?? 0 } : null,
|
|
94
|
+
ogTitle: $('meta[property="og:title"]').attr('content') ? { value: $('meta[property="og:title"]').attr('content'), line: findLineForTag('meta', 'property', 'og:title') ?? 0 } : null,
|
|
95
|
+
ogDescription: $('meta[property="og:description"]').attr('content') ? { value: $('meta[property="og:description"]').attr('content'), line: findLineForTag('meta', 'property', 'og:description') ?? 0 } : null,
|
|
96
|
+
ogImage: $('meta[property="og:image"]').attr('content') ? { value: $('meta[property="og:image"]').attr('content'), line: findLineForTag('meta', 'property', 'og:image') ?? 0 } : null,
|
|
97
|
+
ogUrl: $('meta[property="og:url"]').attr('content') ? { value: $('meta[property="og:url"]').attr('content'), line: findLineForTag('meta', 'property', 'og:url') ?? 0 } : null,
|
|
98
|
+
ogType: $('meta[property="og:type"]').attr('content') ? { value: $('meta[property="og:type"]').attr('content'), line: findLineForTag('meta', 'property', 'og:type') ?? 0 } : null,
|
|
99
|
+
ogLocale: ogLocaleVal ? { value: ogLocaleVal, line: findLineForTag('meta', 'property', 'og:locale') ?? 0 } : null,
|
|
100
|
+
twitterCard: $('meta[name="twitter:card"]').attr('content') ? { value: $('meta[name="twitter:card"]').attr('content'), line: findLineForTag('meta', 'name', 'twitter:card') ?? 0 } : null,
|
|
101
|
+
twitterTitle: $('meta[name="twitter:title"]').attr('content') ? { value: $('meta[name="twitter:title"]').attr('content'), line: findLineForTag('meta', 'name', 'twitter:title') ?? 0 } : null,
|
|
102
|
+
twitterDescription: $('meta[name="twitter:description"]').attr('content') ? { value: $('meta[name="twitter:description"]').attr('content'), line: findLineForTag('meta', 'name', 'twitter:description') ?? 0 } : null,
|
|
103
|
+
twitterImage: $('meta[name="twitter:image"]').attr('content') ? { value: $('meta[name="twitter:image"]').attr('content'), line: findLineForTag('meta', 'name', 'twitter:image') ?? 0 } : null,
|
|
104
|
+
headings,
|
|
105
|
+
images,
|
|
106
|
+
links,
|
|
107
|
+
jsonLdBlocks,
|
|
108
|
+
preloadLinks,
|
|
109
|
+
hreflangLinks,
|
|
110
|
+
hasNoindex: robotsValue.toLowerCase().includes('noindex'),
|
|
111
|
+
hasSitemapRef: content.includes('sitemap'),
|
|
112
|
+
hasFavicon: $('link[rel="icon"], link[rel="shortcut icon"]').length > 0,
|
|
113
|
+
isPageDocument: false,
|
|
114
|
+
isFrameworkShell: FRAMEWORK_MOUNT_PATTERNS.some((p) => content.includes(p)),
|
|
115
|
+
};
|
|
116
|
+
doc.isPageDocument = isPageDocument(doc);
|
|
117
|
+
return doc;
|
|
118
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { parseHtml } from './html-parser.js';
|
|
2
|
+
import { parseJsx } from './jsx-parser.js';
|
|
3
|
+
import { applyFrameworkAdapters } from './frameworks/index.js';
|
|
4
|
+
const SUPPORTED_EXTENSIONS = ['html', 'htm', 'js', 'jsx', 'tsx', 'vue', 'svelte', 'astro'];
|
|
5
|
+
export function isSupportedFile(filename) {
|
|
6
|
+
const ext = filename.split('.').pop()?.toLowerCase() ?? '';
|
|
7
|
+
return SUPPORTED_EXTENSIONS.includes(ext);
|
|
8
|
+
}
|
|
9
|
+
export function parseFile(filePath, content) {
|
|
10
|
+
const ext = filePath.split('.').pop()?.toLowerCase() ?? '';
|
|
11
|
+
switch (ext) {
|
|
12
|
+
case 'html':
|
|
13
|
+
case 'htm':
|
|
14
|
+
return parseHtml(filePath, content);
|
|
15
|
+
case 'js':
|
|
16
|
+
case 'jsx':
|
|
17
|
+
// Plain .js commonly holds JSX in React projects (Create React App, etc.),
|
|
18
|
+
// so it goes through the same JSX parser. Non-JSX .js simply yields no
|
|
19
|
+
// findings rather than being skipped.
|
|
20
|
+
return parseJsx(filePath, content, 'jsx');
|
|
21
|
+
case 'tsx':
|
|
22
|
+
return parseJsx(filePath, content, 'tsx');
|
|
23
|
+
case 'vue':
|
|
24
|
+
return parseVueLike(filePath, content);
|
|
25
|
+
case 'svelte':
|
|
26
|
+
return parseSvelteLike(filePath, content);
|
|
27
|
+
case 'astro':
|
|
28
|
+
return parseAstroLike(filePath, content);
|
|
29
|
+
default:
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
// Vue: extract <template> block and parse as HTML
|
|
34
|
+
function parseVueLike(filePath, content) {
|
|
35
|
+
const templateMatch = content.match(/<template>([\s\S]*?)<\/template>/);
|
|
36
|
+
const templateContent = templateMatch ? templateMatch[1] : content;
|
|
37
|
+
const doc = parseHtml(filePath, templateContent);
|
|
38
|
+
doc.fileType = 'vue';
|
|
39
|
+
return doc;
|
|
40
|
+
}
|
|
41
|
+
// Svelte: parse as HTML (Svelte templates are valid HTML supersets for our purposes)
|
|
42
|
+
function parseSvelteLike(filePath, content) {
|
|
43
|
+
const doc = parseHtml(filePath, content);
|
|
44
|
+
doc.fileType = 'svelte';
|
|
45
|
+
return doc;
|
|
46
|
+
}
|
|
47
|
+
// Astro: a `---` frontmatter fence (JS/TS) followed by an HTML-like template.
|
|
48
|
+
// Parse the template as HTML, then let the Astro adapter reconcile dynamic head
|
|
49
|
+
// values and layout delegation (the frontmatter is passed along for that).
|
|
50
|
+
function parseAstroLike(filePath, content) {
|
|
51
|
+
const template = stripAstroFrontmatter(content);
|
|
52
|
+
const doc = parseHtml(filePath, template);
|
|
53
|
+
doc.fileType = 'astro';
|
|
54
|
+
// HTML parsing doesn't run adapters (no AST); Astro's works off the raw source.
|
|
55
|
+
applyFrameworkAdapters({ filePath, content, fileType: 'astro' }, doc);
|
|
56
|
+
return doc;
|
|
57
|
+
}
|
|
58
|
+
/** Removes the leading `---\n…\n---` frontmatter block, leaving the template. */
|
|
59
|
+
function stripAstroFrontmatter(content) {
|
|
60
|
+
const m = /^\s*---\r?\n[\s\S]*?\r?\n---\r?\n?/.exec(content);
|
|
61
|
+
return m ? content.slice(m[0].length) : content;
|
|
62
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { parse } from '@babel/parser';
|
|
2
|
+
import _traverse from '@babel/traverse';
|
|
3
|
+
import * as t from '@babel/types';
|
|
4
|
+
// @babel/traverse is CJS with a default export; handle both ESM and CJS interop
|
|
5
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
6
|
+
const traverse = _traverse.default ?? _traverse;
|
|
7
|
+
import { isPageDocument } from '../engine/page-detector.js';
|
|
8
|
+
import { applyFrameworkAdapters } from './frameworks/index.js';
|
|
9
|
+
export function parseJsx(filePath, content, fileType) {
|
|
10
|
+
const doc = createEmptyDocument(filePath, fileType, content);
|
|
11
|
+
let ast;
|
|
12
|
+
try {
|
|
13
|
+
ast = parse(content, {
|
|
14
|
+
sourceType: 'module',
|
|
15
|
+
plugins: fileType === 'tsx' ? ['typescript', 'jsx'] : ['jsx'],
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
catch (err) {
|
|
19
|
+
console.warn(`[WARN] Failed to parse JSX/TSX file ${filePath}:`, err);
|
|
20
|
+
return doc;
|
|
21
|
+
}
|
|
22
|
+
traverse(ast, {
|
|
23
|
+
JSXElement(path) {
|
|
24
|
+
const opening = path.node.openingElement;
|
|
25
|
+
const tagName = t.isJSXIdentifier(opening.name) ? opening.name.name : '';
|
|
26
|
+
const line = opening.loc?.start.line ?? 0;
|
|
27
|
+
const getAttr = (name) => {
|
|
28
|
+
const attr = opening.attributes.find((a) => t.isJSXAttribute(a) && t.isJSXIdentifier(a.name) && a.name.name === name);
|
|
29
|
+
if (!attr)
|
|
30
|
+
return null;
|
|
31
|
+
if (t.isStringLiteral(attr.value))
|
|
32
|
+
return attr.value.value;
|
|
33
|
+
if (t.isJSXExpressionContainer(attr.value) && t.isStringLiteral(attr.value.expression)) {
|
|
34
|
+
return attr.value.expression.value;
|
|
35
|
+
}
|
|
36
|
+
return '';
|
|
37
|
+
};
|
|
38
|
+
// Title tag (Next.js / react-helmet pattern)
|
|
39
|
+
if (tagName === 'title') {
|
|
40
|
+
const text = path.node.children
|
|
41
|
+
.filter(t.isJSXText)
|
|
42
|
+
.map((c) => c.value)
|
|
43
|
+
.join('').trim();
|
|
44
|
+
doc.titleTag = { value: text, line };
|
|
45
|
+
}
|
|
46
|
+
// Meta tags
|
|
47
|
+
if (tagName === 'meta') {
|
|
48
|
+
const name = getAttr('name');
|
|
49
|
+
const property = getAttr('property');
|
|
50
|
+
const content_ = getAttr('content');
|
|
51
|
+
// charset: JSX uses charSet (camelCase), HTML uses charset
|
|
52
|
+
const charset = getAttr('charSet') || getAttr('charset');
|
|
53
|
+
if (charset)
|
|
54
|
+
doc.charsetMeta = { value: charset, line };
|
|
55
|
+
if (name === 'description')
|
|
56
|
+
doc.metaDescription = { value: content_ ?? '', line };
|
|
57
|
+
if (name === 'robots') {
|
|
58
|
+
doc.metaRobots = { value: content_ ?? '', line };
|
|
59
|
+
if ((content_ ?? '').toLowerCase().includes('noindex'))
|
|
60
|
+
doc.hasNoindex = true;
|
|
61
|
+
}
|
|
62
|
+
if (name === 'viewport')
|
|
63
|
+
doc.viewportMeta = { value: content_ ?? '', line };
|
|
64
|
+
if (name === 'twitter:card')
|
|
65
|
+
doc.twitterCard = { value: content_ ?? '', line };
|
|
66
|
+
if (name === 'twitter:title')
|
|
67
|
+
doc.twitterTitle = { value: content_ ?? '', line };
|
|
68
|
+
if (name === 'twitter:description')
|
|
69
|
+
doc.twitterDescription = { value: content_ ?? '', line };
|
|
70
|
+
if (name === 'twitter:image')
|
|
71
|
+
doc.twitterImage = { value: content_ ?? '', line };
|
|
72
|
+
if (property === 'og:title')
|
|
73
|
+
doc.ogTitle = { value: content_ ?? '', line };
|
|
74
|
+
if (property === 'og:description')
|
|
75
|
+
doc.ogDescription = { value: content_ ?? '', line };
|
|
76
|
+
if (property === 'og:image')
|
|
77
|
+
doc.ogImage = { value: content_ ?? '', line };
|
|
78
|
+
if (property === 'og:url')
|
|
79
|
+
doc.ogUrl = { value: content_ ?? '', line };
|
|
80
|
+
if (property === 'og:type')
|
|
81
|
+
doc.ogType = { value: content_ ?? '', line };
|
|
82
|
+
if (property === 'og:locale')
|
|
83
|
+
doc.ogLocale = { value: content_ ?? '', line };
|
|
84
|
+
}
|
|
85
|
+
// Link tags
|
|
86
|
+
if (tagName === 'link') {
|
|
87
|
+
const rel = getAttr('rel');
|
|
88
|
+
if (rel === 'canonical')
|
|
89
|
+
doc.canonicalUrl = { value: getAttr('href') ?? '', line };
|
|
90
|
+
if (rel === 'preload')
|
|
91
|
+
doc.preloadLinks.push({ href: getAttr('href') ?? '', as: getAttr('as') ?? '', line });
|
|
92
|
+
if (rel === 'icon' || rel === 'shortcut icon')
|
|
93
|
+
doc.hasFavicon = true;
|
|
94
|
+
const hreflang = getAttr('hreflang');
|
|
95
|
+
if (rel === 'alternate' && hreflang) {
|
|
96
|
+
doc.hreflangLinks.push({ lang: hreflang, href: getAttr('href') ?? '', line, hasExtraAttrs: false });
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
// HTML lang attribute
|
|
100
|
+
if (tagName === 'html') {
|
|
101
|
+
const lang = getAttr('lang');
|
|
102
|
+
if (lang)
|
|
103
|
+
doc.langAttribute = { value: lang, line };
|
|
104
|
+
}
|
|
105
|
+
// Headings
|
|
106
|
+
if (/^h[1-6]$/.test(tagName)) {
|
|
107
|
+
const literal = path.node.children
|
|
108
|
+
.filter(t.isJSXText)
|
|
109
|
+
.map((c) => c.value)
|
|
110
|
+
.join('').trim();
|
|
111
|
+
// A heading whose text comes from an expression ({title}) or a nested
|
|
112
|
+
// element (<span>…</span>, <Icon/>) is NOT empty — it renders real text.
|
|
113
|
+
// Only truly childless headings should trip empty-heading.
|
|
114
|
+
const hasDynamicContent = path.node.children.some((c) => (t.isJSXExpressionContainer(c) && !t.isJSXEmptyExpression(c.expression)) ||
|
|
115
|
+
t.isJSXElement(c) ||
|
|
116
|
+
t.isJSXFragment(c));
|
|
117
|
+
const text = literal || (hasDynamicContent ? '…' : '');
|
|
118
|
+
doc.headings.push({ tag: tagName, text, line });
|
|
119
|
+
}
|
|
120
|
+
// Images
|
|
121
|
+
if (tagName === 'img') {
|
|
122
|
+
const src = getAttr('src') ?? '';
|
|
123
|
+
const alt = getAttr('alt');
|
|
124
|
+
const loading = getAttr('loading');
|
|
125
|
+
doc.images.push({ src, alt, loading, line });
|
|
126
|
+
}
|
|
127
|
+
// Anchors
|
|
128
|
+
if (tagName === 'a') {
|
|
129
|
+
const href = getAttr('href') ?? '';
|
|
130
|
+
const rel = getAttr('rel');
|
|
131
|
+
const text = path.node.children
|
|
132
|
+
.filter(t.isJSXText)
|
|
133
|
+
.map((c) => c.value)
|
|
134
|
+
.join('').trim();
|
|
135
|
+
doc.links.push({ href, text, rel, line });
|
|
136
|
+
}
|
|
137
|
+
// Script for JSON-LD
|
|
138
|
+
if (tagName === 'script' && getAttr('type') === 'application/ld+json') {
|
|
139
|
+
const inner = path.node.children
|
|
140
|
+
.filter(t.isJSXText)
|
|
141
|
+
.map((c) => c.value)
|
|
142
|
+
.join('').trim();
|
|
143
|
+
// Runtime-generated JSON-LD can't be validated statically. In React the
|
|
144
|
+
// body is set via dangerouslySetInnerHTML={{__html: JSON.stringify(...)}},
|
|
145
|
+
// or as an expression child {jsonString} — either way there's no literal
|
|
146
|
+
// JSON to read, so mark it dynamic (the block still counts as "present",
|
|
147
|
+
// but the invalid-json-ld rule won't flag it).
|
|
148
|
+
const hasDangerousHtml = path.node.openingElement.attributes.some((a) => t.isJSXAttribute(a) && t.isJSXIdentifier(a.name) && a.name.name === 'dangerouslySetInnerHTML');
|
|
149
|
+
const hasExpressionChild = path.node.children.some((c) => t.isJSXExpressionContainer(c));
|
|
150
|
+
const dynamic = hasDangerousHtml || hasExpressionChild || inner.length === 0;
|
|
151
|
+
doc.jsonLdBlocks.push({ content: inner, line, dynamic });
|
|
152
|
+
}
|
|
153
|
+
},
|
|
154
|
+
});
|
|
155
|
+
// Framework awareness: let an adapter (Next.js, Remix, …) read metadata the
|
|
156
|
+
// raw JSX traversal can't see and declare role-based rule scoping. Runs after
|
|
157
|
+
// the JSX pass so adapters can also use what was found literally.
|
|
158
|
+
applyFrameworkAdapters({ filePath, content, fileType, ast }, doc);
|
|
159
|
+
doc.isPageDocument = isPageDocument(doc);
|
|
160
|
+
return doc;
|
|
161
|
+
}
|
|
162
|
+
function createEmptyDocument(filePath, fileType, rawContent) {
|
|
163
|
+
return {
|
|
164
|
+
filePath, fileType, rawContent,
|
|
165
|
+
titleTag: null, metaDescription: null, metaRobots: null,
|
|
166
|
+
canonicalUrl: null, langAttribute: null, viewportMeta: null, charsetMeta: null,
|
|
167
|
+
ogTitle: null, ogDescription: null, ogImage: null, ogUrl: null, ogType: null, ogLocale: null,
|
|
168
|
+
twitterCard: null, twitterTitle: null, twitterDescription: null, twitterImage: null,
|
|
169
|
+
headings: [], images: [], links: [], jsonLdBlocks: [], preloadLinks: [], hreflangLinks: [],
|
|
170
|
+
hasNoindex: false, hasSitemapRef: false, hasFavicon: false,
|
|
171
|
+
isPageDocument: false,
|
|
172
|
+
isFrameworkShell: false,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@qobi/seocode",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Framework-aware technical SEO review for your codebase — zero-config CLI that catches deploy-blocking SEO regressions (noindex, dynamic metadata, broken JSON-LD) before you push.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "./dist/src/cli/index.js",
|
|
8
|
+
"bin": {
|
|
9
|
+
"seocode": "./dist/src/cli/index.js"
|
|
10
|
+
},
|
|
11
|
+
"keywords": [
|
|
12
|
+
"seo",
|
|
13
|
+
"cli",
|
|
14
|
+
"nextjs",
|
|
15
|
+
"remix",
|
|
16
|
+
"astro",
|
|
17
|
+
"json-ld",
|
|
18
|
+
"metadata",
|
|
19
|
+
"pre-commit",
|
|
20
|
+
"lint",
|
|
21
|
+
"static-analysis"
|
|
22
|
+
],
|
|
23
|
+
"files": [
|
|
24
|
+
"dist/src/cli",
|
|
25
|
+
"dist/src/engine",
|
|
26
|
+
"dist/src/parsers",
|
|
27
|
+
"dist/src/config",
|
|
28
|
+
"dist/src/billing",
|
|
29
|
+
"dist/src/types",
|
|
30
|
+
"rules/seo-rules.json"
|
|
31
|
+
],
|
|
32
|
+
"scripts": {
|
|
33
|
+
"dev": "wrangler pages dev . --compatibility-date=2024-09-23",
|
|
34
|
+
"deploy": "wrangler pages deploy .",
|
|
35
|
+
"build": "tsc",
|
|
36
|
+
"cli": "node dist/src/cli/index.js",
|
|
37
|
+
"test": "node --experimental-vm-modules node_modules/.bin/jest --runInBand",
|
|
38
|
+
"test:watch": "node --experimental-vm-modules node_modules/.bin/jest --watch",
|
|
39
|
+
"lint": "eslint src --ext .ts,.tsx",
|
|
40
|
+
"type-check": "tsc --noEmit"
|
|
41
|
+
},
|
|
42
|
+
"dependencies": {
|
|
43
|
+
"@babel/parser": "^7.24.0",
|
|
44
|
+
"@babel/traverse": "^7.24.0",
|
|
45
|
+
"@babel/types": "^7.24.0",
|
|
46
|
+
"cheerio": "^1.0.0"
|
|
47
|
+
},
|
|
48
|
+
"devDependencies": {
|
|
49
|
+
"@octokit/app": "^15.0.0",
|
|
50
|
+
"@octokit/rest": "^21.0.0",
|
|
51
|
+
"@octokit/webhooks": "^13.0.0",
|
|
52
|
+
"@types/babel__traverse": "^7.20.0",
|
|
53
|
+
"@types/cheerio": "^0.22.35",
|
|
54
|
+
"@types/jest": "^29.5.12",
|
|
55
|
+
"@types/node": "^20.12.0",
|
|
56
|
+
"jest": "^29.7.0",
|
|
57
|
+
"ts-jest": "^29.1.2",
|
|
58
|
+
"typescript": "^5.4.0",
|
|
59
|
+
"wrangler": "^3.0.0"
|
|
60
|
+
},
|
|
61
|
+
"jest": {
|
|
62
|
+
"preset": "ts-jest/presets/default-esm",
|
|
63
|
+
"testEnvironment": "node",
|
|
64
|
+
"testMatch": ["**/tests/**/*.test.ts"],
|
|
65
|
+
"moduleNameMapper": {
|
|
66
|
+
"^(\\.{1,2}/.*)\\.js$": "$1"
|
|
67
|
+
},
|
|
68
|
+
"transform": {
|
|
69
|
+
"^.+\\.tsx?$": ["ts-jest", { "useESM": true, "tsconfig": "tsconfig.test.json", "isolatedModules": true }]
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
"engines": {
|
|
73
|
+
"node": ">=20"
|
|
74
|
+
}
|
|
75
|
+
}
|