@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.
Files changed (42) hide show
  1. package/LICENSE +21 -0
  2. package/dist/src/billing/polar.js +127 -0
  3. package/dist/src/billing/repo-limits.js +60 -0
  4. package/dist/src/billing/subscription-service.js +123 -0
  5. package/dist/src/billing/tier.js +14 -0
  6. package/dist/src/billing/types.js +1 -0
  7. package/dist/src/cli/discover.js +62 -0
  8. package/dist/src/cli/fix.js +24 -0
  9. package/dist/src/cli/format-terminal.js +66 -0
  10. package/dist/src/cli/index.js +204 -0
  11. package/dist/src/cli/init.js +48 -0
  12. package/dist/src/cli/staged.js +24 -0
  13. package/dist/src/config/seocode-config.js +97 -0
  14. package/dist/src/engine/page-detector.js +82 -0
  15. package/dist/src/engine/pr-delta.js +31 -0
  16. package/dist/src/engine/rule-engine.js +112 -0
  17. package/dist/src/engine/rule-loader.js +35 -0
  18. package/dist/src/engine/rules/declarative-evaluator.js +105 -0
  19. package/dist/src/engine/rules/heading-rules.js +43 -0
  20. package/dist/src/engine/rules/helpers.js +16 -0
  21. package/dist/src/engine/rules/image-rules.js +82 -0
  22. package/dist/src/engine/rules/index.js +28 -0
  23. package/dist/src/engine/rules/link-rules.js +39 -0
  24. package/dist/src/engine/rules/meta-rules.js +34 -0
  25. package/dist/src/engine/rules/performance-rules.js +24 -0
  26. package/dist/src/engine/rules/schema-rules.js +228 -0
  27. package/dist/src/engine/rules/suggest.js +103 -0
  28. package/dist/src/engine/rules/technical-rules.js +100 -0
  29. package/dist/src/parsers/frameworks/ast-value.js +83 -0
  30. package/dist/src/parsers/frameworks/astro.js +107 -0
  31. package/dist/src/parsers/frameworks/index.js +36 -0
  32. package/dist/src/parsers/frameworks/nextjs.js +170 -0
  33. package/dist/src/parsers/frameworks/remix.js +130 -0
  34. package/dist/src/parsers/frameworks/roles.js +63 -0
  35. package/dist/src/parsers/frameworks/types.js +1 -0
  36. package/dist/src/parsers/html-parser.js +118 -0
  37. package/dist/src/parsers/index.js +62 -0
  38. package/dist/src/parsers/jsx-parser.js +174 -0
  39. package/dist/src/types/index.js +1 -0
  40. package/dist/src/types/worker-env.js +1 -0
  41. package/package.json +75 -0
  42. 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
+ }