seo-lint-next 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/dist/cli.cjs ADDED
@@ -0,0 +1,1023 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ var __create = Object.create;
4
+ var __defProp = Object.defineProperty;
5
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
6
+ var __getOwnPropNames = Object.getOwnPropertyNames;
7
+ var __getProtoOf = Object.getPrototypeOf;
8
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
9
+ var __copyProps = (to, from, except, desc) => {
10
+ if (from && typeof from === "object" || typeof from === "function") {
11
+ for (let key of __getOwnPropNames(from))
12
+ if (!__hasOwnProp.call(to, key) && key !== except)
13
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
14
+ }
15
+ return to;
16
+ };
17
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
18
+ // If the importer is in node compatibility mode or this is not an ESM
19
+ // file that has been converted to a CommonJS file using a Babel-
20
+ // compatible transform (i.e. "__esModule" has not been set), then set
21
+ // "default" to the CommonJS "module.exports" for node compatibility.
22
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
23
+ mod
24
+ ));
25
+
26
+ // src/cli.ts
27
+ var import_node_fs5 = __toESM(require("fs"), 1);
28
+ var import_node_path6 = __toESM(require("path"), 1);
29
+ var import_node_process = __toESM(require("process"), 1);
30
+ var import_parser = __toESM(require("@typescript-eslint/parser"), 1);
31
+ var import_fast_glob = __toESM(require("fast-glob"), 1);
32
+ var import_eslint = require("eslint");
33
+
34
+ // src/rules/no-accidental-noindex.ts
35
+ var import_node_fs2 = __toESM(require("fs"), 1);
36
+ var import_node_path3 = __toESM(require("path"), 1);
37
+
38
+ // src/utils/ast.ts
39
+ var import_node_path = __toESM(require("path"), 1);
40
+ function getNodeName(node) {
41
+ const typed = node;
42
+ if (!typed) return void 0;
43
+ if (typed.type === "Identifier") return typed.name;
44
+ if (typed.type === "Literal") return String(typed.value);
45
+ if (typed.type === "Property" || typed.type === "PropertyDefinition") {
46
+ return getNodeName(typed.key);
47
+ }
48
+ if (typed.type === "JSXIdentifier") return typed.name;
49
+ return void 0;
50
+ }
51
+ function objectProperties(node) {
52
+ const typed = node;
53
+ if (!typed || typed.type !== "ObjectExpression") return [];
54
+ return (typed.properties ?? []).filter((property) => property.type === "Property").map((property) => ({
55
+ key: getNodeName(property.key) ?? "",
56
+ value: property.value,
57
+ node: property
58
+ })).filter((property) => property.key.length > 0);
59
+ }
60
+ function getObjectProperty(node, key) {
61
+ return objectProperties(node).find((property) => property.key === key);
62
+ }
63
+ function getPathProperty(node, keys) {
64
+ let current = node;
65
+ let found;
66
+ for (const key of keys) {
67
+ found = getObjectProperty(current, key);
68
+ if (!found) return void 0;
69
+ current = found.value;
70
+ }
71
+ return found;
72
+ }
73
+ function getStaticString(node) {
74
+ const typed = node;
75
+ if (!typed) return void 0;
76
+ if (typed.type === "Literal" && typeof typed.value === "string") return typed.value;
77
+ if (typed.type === "TemplateLiteral") {
78
+ const expressions = typed.expressions ?? [];
79
+ const quasis = typed.quasis ?? [];
80
+ if (expressions.length === 0) {
81
+ return quasis.map((quasi) => quasi.value?.cooked ?? "").join("");
82
+ }
83
+ }
84
+ return void 0;
85
+ }
86
+ function isNonEmptyStringNode(node) {
87
+ const value = getStaticString(node);
88
+ return typeof value === "string" && value.trim().length > 0;
89
+ }
90
+ function findMetadataObject(program) {
91
+ for (const statement of program.body ?? []) {
92
+ if (statement.type !== "ExportNamedDeclaration") continue;
93
+ const declaration = statement.declaration;
94
+ if (!declaration || declaration.type !== "VariableDeclaration") continue;
95
+ for (const item of declaration.declarations ?? []) {
96
+ if (getNodeName(item.id) === "metadata" && item.init?.type === "ObjectExpression") {
97
+ return item.init;
98
+ }
99
+ }
100
+ }
101
+ return void 0;
102
+ }
103
+ function findGenerateMetadataReturns(program) {
104
+ const returns = [];
105
+ walk(program, (node) => {
106
+ if (node.type !== "ExportNamedDeclaration") return;
107
+ const declaration = node.declaration;
108
+ if (!declaration) return;
109
+ const isNamedFunction = declaration.type === "FunctionDeclaration" && getNodeName(declaration.id) === "generateMetadata";
110
+ const isVariable = declaration.type === "VariableDeclaration" && (declaration.declarations ?? []).some(
111
+ (item) => getNodeName(item.id) === "generateMetadata"
112
+ );
113
+ if (!isNamedFunction && !isVariable) return;
114
+ walk(declaration, (child) => {
115
+ if (child.type === "ReturnStatement" && child.argument?.type === "ObjectExpression") {
116
+ returns.push(child.argument);
117
+ }
118
+ });
119
+ });
120
+ return returns;
121
+ }
122
+ function metadataSources(program) {
123
+ const staticMetadata = findMetadataObject(program);
124
+ return [staticMetadata, ...findGenerateMetadataReturns(program)].filter(Boolean);
125
+ }
126
+ function hasNullishFallback(node) {
127
+ let found = false;
128
+ walk(node, (child) => {
129
+ if (child.type === "LogicalExpression" && child.operator === "??" && isNonEmptyStringNode(child.right)) {
130
+ found = true;
131
+ }
132
+ });
133
+ return found;
134
+ }
135
+ function hasDynamicInterpolation(node) {
136
+ const typed = node;
137
+ if (!typed) return false;
138
+ if (typed.type === "TemplateLiteral") {
139
+ return (typed.expressions ?? []).length > 0;
140
+ }
141
+ let found = false;
142
+ walk(typed, (child) => {
143
+ if (child.type === "Identifier" && child.name === "params") found = true;
144
+ });
145
+ return found;
146
+ }
147
+ function walk(node, visitor) {
148
+ if (!node || typeof node.type !== "string") return;
149
+ visitor(node);
150
+ for (const [key, value] of Object.entries(node)) {
151
+ if (key === "parent" || key === "loc" || key === "range" || key === "tokens" || key === "comments")
152
+ continue;
153
+ if (!value) continue;
154
+ if (Array.isArray(value)) {
155
+ for (const item of value) {
156
+ if (item && typeof item === "object" && typeof item.type === "string") {
157
+ walk(item, visitor);
158
+ }
159
+ }
160
+ } else if (typeof value === "object" && typeof value.type === "string") {
161
+ walk(value, visitor);
162
+ }
163
+ }
164
+ }
165
+ function jsxAttribute(node, name) {
166
+ const attributes = node.attributes ?? [];
167
+ return attributes.find(
168
+ (attribute) => attribute.type === "JSXAttribute" && getNodeName(attribute.name) === name
169
+ );
170
+ }
171
+ function jsxAttributeString(node, name) {
172
+ const attribute = jsxAttribute(node, name);
173
+ if (!attribute) return void 0;
174
+ const value = attribute.value;
175
+ if (!value) return "";
176
+ if (value.type === "Literal") return String(value.value);
177
+ if (value.type === "JSXExpressionContainer") return getStaticString(value.expression);
178
+ return void 0;
179
+ }
180
+
181
+ // src/utils/files.ts
182
+ var import_node_fs = __toESM(require("fs"), 1);
183
+ var import_node_path2 = __toESM(require("path"), 1);
184
+ function findProjectRoot(start = process.cwd()) {
185
+ let current = import_node_path2.default.resolve(start);
186
+ while (current !== import_node_path2.default.dirname(current)) {
187
+ if (import_node_fs.default.existsSync(import_node_path2.default.join(current, "package.json"))) return current;
188
+ current = import_node_path2.default.dirname(current);
189
+ }
190
+ return import_node_path2.default.resolve(start);
191
+ }
192
+ function findAppDir(root = findProjectRoot()) {
193
+ const candidates = [import_node_path2.default.join(root, "app"), import_node_path2.default.join(root, "src", "app")];
194
+ return candidates.find((candidate) => import_node_fs.default.existsSync(candidate) && import_node_fs.default.statSync(candidate).isDirectory());
195
+ }
196
+ function isPageFile(filename) {
197
+ return /(^|\/)page\.[cm]?[jt]sx?$/.test(filename.split(import_node_path2.default.sep).join("/"));
198
+ }
199
+ function isLayoutFile(filename) {
200
+ return /(^|\/)layout\.[cm]?[jt]sx?$/.test(filename.split(import_node_path2.default.sep).join("/"));
201
+ }
202
+ function isRootLayoutFile(filename) {
203
+ const normalized = filename.split(import_node_path2.default.sep).join("/");
204
+ return /(^|\/)(src\/)?app\/layout\.[cm]?[jt]sx?$/.test(normalized);
205
+ }
206
+ function isDynamicRoute(filename) {
207
+ return /\[[^/]+\]/.test(filename);
208
+ }
209
+ function routeIsPrivate(filename, privateRoutes = ["/admin", "/api", "/auth", "/private"]) {
210
+ const normalized = filename.split(import_node_path2.default.sep).join("/");
211
+ return privateRoutes.some(
212
+ (route) => normalized.includes(`/app${route}/`) || normalized.includes(`/src/app${route}/`)
213
+ );
214
+ }
215
+ function hasMetadataBase(root = findProjectRoot()) {
216
+ const appDir = findAppDir(root);
217
+ if (!appDir) return false;
218
+ for (const ext of ["ts", "tsx", "js", "jsx", "mts", "mjs"]) {
219
+ const file = import_node_path2.default.join(appDir, `layout.${ext}`);
220
+ if (import_node_fs.default.existsSync(file) && import_node_fs.default.readFileSync(file, "utf8").includes("metadataBase")) return true;
221
+ }
222
+ return false;
223
+ }
224
+ function routeSegmentDir(filename) {
225
+ return import_node_path2.default.dirname(filename);
226
+ }
227
+ function hasOpenGraphImageRoute(filename) {
228
+ const dir = routeSegmentDir(filename);
229
+ return [
230
+ "opengraph-image.tsx",
231
+ "opengraph-image.ts",
232
+ "opengraph-image.png",
233
+ "opengraph-image.jpg",
234
+ "opengraph-image.jpeg"
235
+ ].some((name) => import_node_fs.default.existsSync(import_node_path2.default.join(dir, name)));
236
+ }
237
+ function routeFromFilename(filename) {
238
+ const normalized = filename.split(import_node_path2.default.sep).join("/");
239
+ const appIndex = normalized.lastIndexOf("/app/");
240
+ const relative = appIndex >= 0 ? normalized.slice(appIndex + 5) : normalized;
241
+ const route = relative.replace(/\/(page|layout)\.[cm]?[jt]sx?$/, "").replace(/\/?$/, "").replace(/\/\([^/]+\)/g, "").replace(/\/route$/, "");
242
+ return `/${route}`.replace(/\/+/g, "/") || "/";
243
+ }
244
+
245
+ // src/utils/rule.ts
246
+ function createRule(name, description, create, type = "problem") {
247
+ return {
248
+ meta: {
249
+ type,
250
+ docs: {
251
+ description,
252
+ recommended: true,
253
+ url: `https://seo-lint-next.dev/rules/${name}`
254
+ },
255
+ schema: [
256
+ {
257
+ type: "object",
258
+ additionalProperties: true
259
+ }
260
+ ],
261
+ messages: {}
262
+ },
263
+ create
264
+ };
265
+ }
266
+ function report(context, node, message) {
267
+ context.report({ node, message });
268
+ }
269
+
270
+ // src/rules/no-accidental-noindex.ts
271
+ var checkedRobots = false;
272
+ var noAccidentalNoindex = createRule(
273
+ "no-accidental-noindex",
274
+ "prevent public pages and robots files from disabling indexing",
275
+ (context) => ({
276
+ "Program:exit"(program) {
277
+ const filename = context.getFilename();
278
+ const options = context.options[0] ?? {};
279
+ const privateRoutes = options.privateRoutes;
280
+ if (isPageFile(filename) && !routeIsPrivate(filename, privateRoutes)) {
281
+ for (const source of metadataSources(program)) {
282
+ const robots = getPathProperty(source, ["robots"]);
283
+ if (!robots) continue;
284
+ if (getStaticString(robots.value)?.toLowerCase().includes("noindex")) {
285
+ report(
286
+ context,
287
+ robots.node,
288
+ 'Public page sets robots: "noindex"; this can remove the page from search indexes.'
289
+ );
290
+ }
291
+ const index = getObjectProperty(robots.value, "index");
292
+ if (index?.value.type === "Literal" && index.value.value === false) {
293
+ report(context, index.node, "Public page sets robots.index to false.");
294
+ }
295
+ }
296
+ }
297
+ walk(program, (node) => {
298
+ if (node.type !== "ConditionalExpression" && node.type !== "LogicalExpression") return;
299
+ const text = context.sourceCode.getText(node);
300
+ if (/NODE_ENV/.test(text) && /noindex/.test(text) && /production/.test(text)) {
301
+ report(
302
+ context,
303
+ node,
304
+ "Environment-gated noindex logic should be reviewed; production pages must not emit noindex."
305
+ );
306
+ }
307
+ });
308
+ if (checkedRobots) return;
309
+ checkedRobots = true;
310
+ const root = findProjectRoot(process.cwd());
311
+ const appDir = findAppDir(root);
312
+ const robotsTs = appDir ? ["robots.ts", "robots.js"].map((name) => import_node_path3.default.join(appDir, name)).find(import_node_fs2.default.existsSync) : void 0;
313
+ if (robotsTs) {
314
+ const text = import_node_fs2.default.readFileSync(robotsTs, "utf8");
315
+ if (/disallow\s*:\s*['"`]\/['"`]/i.test(text) && !/allow\s*:\s*['"`]\/['"`]/i.test(text)) {
316
+ report(context, program, `${robotsTs} appears to disallow "/" without an allow rule.`);
317
+ }
318
+ }
319
+ const publicRobots = import_node_path3.default.join(root, "public", "robots.txt");
320
+ if (import_node_fs2.default.existsSync(publicRobots)) {
321
+ const text = import_node_fs2.default.readFileSync(publicRobots, "utf8");
322
+ if (/^\s*Disallow:\s*\/\s*$/im.test(text) && !/^\s*Allow:\s*\/.+/im.test(text)) {
323
+ report(context, program, 'public/robots.txt disallows all crawling with "Disallow: /".');
324
+ }
325
+ }
326
+ const nextConfig = ["next.config.js", "next.config.mjs", "next.config.ts"].map((name) => import_node_path3.default.join(root, name)).find(import_node_fs2.default.existsSync);
327
+ if (nextConfig) {
328
+ const text = import_node_fs2.default.readFileSync(nextConfig, "utf8");
329
+ if (/X-Robots-Tag/i.test(text) && /noindex/i.test(text)) {
330
+ report(
331
+ context,
332
+ program,
333
+ "next.config sets X-Robots-Tag: noindex; verify this is not applied to public routes."
334
+ );
335
+ }
336
+ }
337
+ }
338
+ })
339
+ );
340
+
341
+ // src/rules/no-broken-heading-hierarchy.ts
342
+ var noBrokenHeadingHierarchy = createRule(
343
+ "no-broken-heading-hierarchy",
344
+ "require one h1 and sequential heading levels",
345
+ (context) => {
346
+ const headings = [];
347
+ return {
348
+ JSXOpeningElement(node) {
349
+ if (!isPageFile(context.getFilename())) return;
350
+ const name = getNodeName(node.name);
351
+ if (!name || !/^h[1-6]$/.test(name)) return;
352
+ const ariaHidden = jsxAttributeString(node, "aria-hidden");
353
+ headings.push({ level: Number(name.slice(1)), node, empty: ariaHidden === "true" });
354
+ },
355
+ "Program:exit"(program) {
356
+ if (!isPageFile(context.getFilename())) return;
357
+ const h1s = headings.filter((heading) => heading.level === 1);
358
+ if (h1s.length === 0)
359
+ report(context, program, "Page has no <h1>; each indexable page should expose one primary heading.");
360
+ if (h1s.length > 1) {
361
+ for (const h1 of h1s.slice(1))
362
+ report(context, h1.node, "Page has more than one <h1>; keep one primary topic heading.");
363
+ }
364
+ for (let index = 1; index < headings.length; index += 1) {
365
+ if (headings[index].level > headings[index - 1].level + 1) {
366
+ report(
367
+ context,
368
+ headings[index].node,
369
+ `Heading level jumps from h${headings[index - 1].level} to h${headings[index].level}.`
370
+ );
371
+ }
372
+ }
373
+ }
374
+ };
375
+ },
376
+ "suggestion"
377
+ );
378
+
379
+ // src/rules/no-img-missing-alt.ts
380
+ var meaningless = /* @__PURE__ */ new Set(["image", "img", "photo", "picture", "screenshot"]);
381
+ var noImgMissingAlt = createRule(
382
+ "no-img-missing-alt",
383
+ "require useful alt text and stable Next/Image sizing",
384
+ (context) => {
385
+ let imageIndex = 0;
386
+ return {
387
+ JSXOpeningElement(node) {
388
+ const typed = node;
389
+ const name = getNodeName(node.name);
390
+ if (name !== "img" && name !== "Image") return;
391
+ imageIndex += 1;
392
+ if (name === "img")
393
+ report(context, typed, "Use next/image instead of raw <img> for optimized image delivery.");
394
+ const altAttribute = jsxAttribute(typed, "alt");
395
+ if (!altAttribute) {
396
+ report(context, typed, `${name} is missing an alt attribute.`);
397
+ } else {
398
+ const alt = jsxAttributeString(typed, "alt");
399
+ if (alt === void 0) return;
400
+ if (alt.length > 125)
401
+ report(context, altAttribute, "Alt text should stay under 125 characters.");
402
+ const normalized = alt.trim().toLowerCase();
403
+ if (normalized && (meaningless.has(normalized) || /\.(png|jpe?g|webp|gif|svg)$/i.test(normalized))) {
404
+ report(
405
+ context,
406
+ altAttribute,
407
+ 'Alt text is too generic; describe the image content or use alt="" for decorative images.'
408
+ );
409
+ }
410
+ }
411
+ if (name === "Image") {
412
+ if (!jsxAttribute(typed, "width") || !jsxAttribute(typed, "height")) {
413
+ report(context, typed, "<Image> should include width and height props to prevent layout shift.");
414
+ }
415
+ if (imageIndex === 1 && !jsxAttribute(typed, "priority")) {
416
+ report(
417
+ context,
418
+ typed,
419
+ "The first <Image> in a page is likely above the fold; add priority when it is the LCP image."
420
+ );
421
+ }
422
+ }
423
+ }
424
+ };
425
+ },
426
+ "suggestion"
427
+ );
428
+
429
+ // src/rules/no-invalid-json-ld.ts
430
+ var requiredFields = {
431
+ Article: ["headline", "author", "datePublished"],
432
+ BlogPosting: ["headline", "author", "datePublished"],
433
+ Product: ["name", "offers"],
434
+ FAQPage: ["mainEntity"],
435
+ BreadcrumbList: ["itemListElement"],
436
+ Organization: ["name", "url"],
437
+ WebSite: ["name", "url"],
438
+ Person: ["name"],
439
+ Event: ["name", "startDate", "location"]
440
+ };
441
+ var recognized = new Set(Object.keys(requiredFields));
442
+ var noInvalidJsonLd = createRule(
443
+ "no-invalid-json-ld",
444
+ "validate JSON-LD syntax and required schema fields",
445
+ (context) => {
446
+ let scriptCount = 0;
447
+ return {
448
+ JSXElement(node) {
449
+ const opening = node.openingElement;
450
+ if (getNodeName(opening?.name) !== "script") return;
451
+ if (jsxAttributeString(opening, "type") !== "application/ld+json") return;
452
+ scriptCount += 1;
453
+ const html = (opening.attributes ?? []).find(
454
+ (attribute) => getNodeName(attribute.name) === "dangerouslySetInnerHTML"
455
+ );
456
+ if (html) {
457
+ const text2 = context.sourceCode.getText(html);
458
+ if (!/JSON\.stringify/.test(text2) && !/__html\s*:\s*['"`{]/.test(text2)) {
459
+ report(
460
+ context,
461
+ html,
462
+ "JSON-LD dangerouslySetInnerHTML should pass JSON.stringify(data) or a static JSON string."
463
+ );
464
+ }
465
+ return;
466
+ }
467
+ const text = extractScriptText(node.children ?? []);
468
+ if (!text) return;
469
+ try {
470
+ validateJsonLd(JSON.parse(text), (message) => report(context, node, message));
471
+ } catch {
472
+ report(context, node, "JSON-LD script contains invalid JSON.");
473
+ }
474
+ },
475
+ "Program:exit"(program) {
476
+ if (!isPageFile(context.getFilename())) return;
477
+ const route = routeFromFilename(context.getFilename());
478
+ if (/\/(blog|posts|product|products|articles)(\/|$)/.test(route) && scriptCount === 0) {
479
+ report(context, program, "Content page is missing an application/ld+json structured data block.");
480
+ }
481
+ let websiteSchema = false;
482
+ walk(program, (child) => {
483
+ if (child.type === "Literal" && child.value === "WebSite") websiteSchema = true;
484
+ });
485
+ if (/\/layout\.[cm]?[jt]sx?$/.test(context.getFilename()) && !websiteSchema) {
486
+ report(
487
+ context,
488
+ program,
489
+ "Root layout should include WebSite JSON-LD with SearchAction for sitelinks search eligibility."
490
+ );
491
+ }
492
+ }
493
+ };
494
+ }
495
+ );
496
+ function extractScriptText(children) {
497
+ return children.map((child) => {
498
+ if (typeof child.value === "string") return child.value;
499
+ if (child.type === "JSXExpressionContainer") return getStaticString(child.expression) ?? "";
500
+ return "";
501
+ }).join("").trim();
502
+ }
503
+ function validateJsonLd(value, onError) {
504
+ const items = Array.isArray(value) ? value : [value];
505
+ const byType = /* @__PURE__ */ new Map();
506
+ for (const item of items) {
507
+ if (!item || typeof item !== "object") continue;
508
+ const record = item;
509
+ if (record["@context"] !== "https://schema.org")
510
+ onError('JSON-LD @context must be exactly "https://schema.org".');
511
+ const type = String(record["@type"] ?? "");
512
+ if (!recognized.has(type))
513
+ onError(`JSON-LD @type "${type || "(missing)"}" is not in the built-in recognized type list.`);
514
+ const missing = (requiredFields[type] ?? []).filter((field) => record[field] == null);
515
+ if (missing.length > 0) onError(`JSON-LD ${type} is missing required field(s): ${missing.join(", ")}.`);
516
+ const serialized = JSON.stringify(record);
517
+ const previous = byType.get(type);
518
+ if (previous && previous !== serialized)
519
+ onError(`Multiple JSON-LD ${type} blocks contain conflicting data.`);
520
+ byType.set(type, serialized);
521
+ }
522
+ }
523
+
524
+ // src/rules/no-missing-canonical.ts
525
+ var noMissingCanonical = createRule(
526
+ "no-missing-canonical",
527
+ "require canonical URLs for App Router pages",
528
+ (context) => ({
529
+ "Program:exit"(program) {
530
+ const filename = context.getFilename();
531
+ if (!isPageFile(filename)) return;
532
+ const sources = metadataSources(program);
533
+ const canonical = sources.map((source) => getPathProperty(source, ["alternates", "canonical"])).find(Boolean);
534
+ if (!canonical) {
535
+ report(
536
+ context,
537
+ program,
538
+ "Page is missing alternates.canonical; duplicate URL variants can split ranking signals."
539
+ );
540
+ return;
541
+ }
542
+ const value = getStaticString(canonical.value);
543
+ if (value && !/^https?:\/\//.test(value) && !value.startsWith("/") && !hasMetadataBase()) {
544
+ report(
545
+ context,
546
+ canonical.node,
547
+ "Relative alternates.canonical requires metadataBase in the root app layout."
548
+ );
549
+ }
550
+ if (isDynamicRoute(filename) && value && !hasDynamicInterpolation(canonical.value)) {
551
+ report(
552
+ context,
553
+ canonical.node,
554
+ "Dynamic routes need a param-specific canonical, not a hardcoded URL string."
555
+ );
556
+ }
557
+ }
558
+ })
559
+ );
560
+
561
+ // src/rules/no-missing-description.ts
562
+ var seenDescriptions = /* @__PURE__ */ new Map();
563
+ var noMissingDescription = createRule(
564
+ "no-missing-description",
565
+ "require useful Next.js metadata descriptions",
566
+ (context) => ({
567
+ "Program:exit"(program) {
568
+ const filename = context.getFilename();
569
+ if (!isPageFile(filename) && !isLayoutFile(filename)) return;
570
+ const option = context.options[0] ?? {};
571
+ const min = option.descriptionLength?.min ?? 120;
572
+ const max = option.descriptionLength?.max ?? 160;
573
+ const sources = metadataSources(program);
574
+ const description = sources.map((source) => getPathProperty(source, ["description"])).find(Boolean);
575
+ if (!description) {
576
+ report(
577
+ context,
578
+ program,
579
+ "Page is missing metadata.description or a generateMetadata() description return value."
580
+ );
581
+ return;
582
+ }
583
+ const value = getStaticString(description.value);
584
+ if (typeof value !== "string" || value.trim().length === 0) {
585
+ report(context, description.node, "metadata.description must not be empty or whitespace.");
586
+ return;
587
+ }
588
+ if (value.length < min || value.length > max) {
589
+ report(
590
+ context,
591
+ description.node,
592
+ `metadata.description should be ${min}-${max} characters; found ${value.length}.`
593
+ );
594
+ }
595
+ const title = sources.map((source) => getPathProperty(source, ["title"])).find(Boolean);
596
+ const titleValue = title ? getStaticString(title.value) : void 0;
597
+ if (titleValue && value.startsWith(titleValue)) {
598
+ report(
599
+ context,
600
+ description.node,
601
+ "metadata.description should not start with the exact page title."
602
+ );
603
+ }
604
+ const route = routeFromFilename(filename);
605
+ const previous = seenDescriptions.get(value);
606
+ if (previous && previous !== route) {
607
+ report(
608
+ context,
609
+ description.node,
610
+ `Duplicate metadata.description also appears in ${previous}.`
611
+ );
612
+ } else {
613
+ seenDescriptions.set(value, route);
614
+ }
615
+ }
616
+ }),
617
+ "suggestion"
618
+ );
619
+
620
+ // src/rules/no-missing-metadata-base.ts
621
+ var noMissingMetadataBase = createRule(
622
+ "no-missing-metadata-base",
623
+ "require production metadataBase in the root app layout",
624
+ (context) => ({
625
+ "Program:exit"(program) {
626
+ const filename = context.getFilename();
627
+ if (!isRootLayoutFile(filename)) return;
628
+ const metadata = metadataSources(program)[0];
629
+ const metadataBase = metadata ? getPathProperty(metadata, ["metadataBase"]) : void 0;
630
+ if (!metadataBase) {
631
+ report(
632
+ context,
633
+ program,
634
+ "Root app layout is missing metadata.metadataBase; relative canonicals and OG images will not resolve safely."
635
+ );
636
+ return;
637
+ }
638
+ const value = metadataBase.value;
639
+ if (value.type !== "NewExpression" || value.callee?.type !== "Identifier" || value.callee.name !== "URL") {
640
+ report(
641
+ context,
642
+ metadataBase.node,
643
+ 'metadataBase should be created with new URL("https://example.com").'
644
+ );
645
+ return;
646
+ }
647
+ const firstArg = Array.isArray(value.arguments) ? value.arguments[0] : void 0;
648
+ const literal = getStaticString(firstArg);
649
+ if (literal) {
650
+ if (!literal.startsWith("https://")) {
651
+ report(
652
+ context,
653
+ metadataBase.node,
654
+ "metadataBase must use an absolute https:// production URL."
655
+ );
656
+ }
657
+ if (/localhost|127\.0\.0\.1|0\.0\.0\.0/.test(literal)) {
658
+ report(
659
+ context,
660
+ metadataBase.node,
661
+ "metadataBase must not point at localhost in committed source."
662
+ );
663
+ }
664
+ }
665
+ }
666
+ })
667
+ );
668
+
669
+ // src/rules/no-missing-og-tags.ts
670
+ var import_node_fs3 = __toESM(require("fs"), 1);
671
+ var import_node_path4 = __toESM(require("path"), 1);
672
+ var import_image_size = require("image-size");
673
+ var noMissingOgTags = createRule(
674
+ "no-missing-og-tags",
675
+ "require Open Graph and Twitter metadata",
676
+ (context) => ({
677
+ "Program:exit"(program) {
678
+ const filename = context.getFilename();
679
+ if (!isPageFile(filename)) return;
680
+ const source = metadataSources(program)[0];
681
+ if (!source) return;
682
+ const openGraph = getPathProperty(source, ["openGraph"]);
683
+ if (!openGraph) {
684
+ if (!hasOpenGraphImageRoute(filename)) report(context, program, "Page is missing openGraph metadata.");
685
+ return;
686
+ }
687
+ for (const key of ["title", "description", "type"]) {
688
+ if (!getObjectProperty(openGraph.value, key)) {
689
+ report(context, openGraph.node, `openGraph.${key} is missing.`);
690
+ }
691
+ }
692
+ const images = getObjectProperty(openGraph.value, "images");
693
+ if (!images && !hasOpenGraphImageRoute(filename)) {
694
+ report(
695
+ context,
696
+ openGraph.node,
697
+ "openGraph.images is missing and no opengraph-image file exists in this route segment."
698
+ );
699
+ } else if (images?.value.type === "ArrayExpression" && (images.value.elements ?? []).length === 0) {
700
+ report(context, images.node, "openGraph.images must include at least one image.");
701
+ }
702
+ if (images?.value.type === "ArrayExpression") {
703
+ for (const image of images.value.elements ?? []) {
704
+ if (!image) continue;
705
+ const urlNode = getObjectProperty(image, "url")?.value ?? image;
706
+ const url = getStaticString(urlNode);
707
+ if (url?.startsWith("/") && !hasMetadataBase()) {
708
+ report(
709
+ context,
710
+ images.node,
711
+ "Relative openGraph image URLs require metadataBase in the root app layout."
712
+ );
713
+ }
714
+ const width = Number(getObjectProperty(image, "width")?.value?.value ?? 0);
715
+ const height = Number(getObjectProperty(image, "height")?.value?.value ?? 0);
716
+ if (width && width < 800 || height && height < 418) {
717
+ report(
718
+ context,
719
+ images.node,
720
+ "Open Graph image dimensions should be at least 800x418, ideally 1200x630."
721
+ );
722
+ }
723
+ if (url?.startsWith("/")) {
724
+ const publicFile = import_node_path4.default.join(process.cwd(), "public", url);
725
+ if (import_node_fs3.default.existsSync(publicFile)) {
726
+ const size = (0, import_image_size.imageSize)(import_node_fs3.default.readFileSync(publicFile));
727
+ if ((size.width ?? 0) < 800 || (size.height ?? 0) < 418) {
728
+ report(context, images.node, `Open Graph image file ${url} is smaller than 800x418.`);
729
+ }
730
+ }
731
+ }
732
+ }
733
+ }
734
+ const twitter = getObjectProperty(source, "twitter");
735
+ if (!twitter) {
736
+ report(
737
+ context,
738
+ source,
739
+ "twitter metadata is missing; set twitter.card and twitter.images for share previews."
740
+ );
741
+ return;
742
+ }
743
+ const card = getObjectProperty(twitter.value, "card");
744
+ if (!card)
745
+ report(
746
+ context,
747
+ twitter.node,
748
+ 'twitter.card is missing; use "summary_large_image" for OG-style previews.'
749
+ );
750
+ if (getStaticString(card?.value) === "summary_large_image" && !getObjectProperty(twitter.value, "images")) {
751
+ report(
752
+ context,
753
+ twitter.node,
754
+ 'twitter.images is required when twitter.card is "summary_large_image".'
755
+ );
756
+ }
757
+ }
758
+ }),
759
+ "suggestion"
760
+ );
761
+
762
+ // src/rules/no-missing-sitemap.ts
763
+ var import_node_fs4 = __toESM(require("fs"), 1);
764
+ var import_node_path5 = __toESM(require("path"), 1);
765
+ var checked = false;
766
+ var noMissingSitemap = createRule(
767
+ "no-missing-sitemap",
768
+ "require a Next.js sitemap and robots sitemap reference",
769
+ (context) => ({
770
+ "Program:exit"(program) {
771
+ if (checked) return;
772
+ checked = true;
773
+ const root = findProjectRoot(process.cwd());
774
+ const appDir = findAppDir(root);
775
+ const sitemap = appDir ? ["sitemap.ts", "sitemap.js", "sitemap.mjs"].map((name) => import_node_path5.default.join(appDir, name)).find(import_node_fs4.default.existsSync) : void 0;
776
+ const nextSitemap = import_node_path5.default.join(root, "next-sitemap.config.js");
777
+ if (!sitemap && !import_node_fs4.default.existsSync(nextSitemap)) {
778
+ report(
779
+ context,
780
+ program,
781
+ "Missing app/sitemap.ts or next-sitemap.config.js; search engines may discover pages slowly."
782
+ );
783
+ return;
784
+ }
785
+ if (sitemap) {
786
+ const text = import_node_fs4.default.readFileSync(sitemap, "utf8");
787
+ if (!/export\s+default\s+(async\s+)?function|export\s+default\s+\w+/.test(text)) {
788
+ report(context, program, `${sitemap} should export a default sitemap function.`);
789
+ }
790
+ if (/url\s*:\s*['"`]\//.test(text)) {
791
+ report(context, program, "Sitemap entries must use absolute URLs, not relative paths.");
792
+ }
793
+ if (/\[[^/]+\]/.test(text) && !/lastModified/.test(text)) {
794
+ report(context, program, "Dynamic sitemap entries should include lastModified.");
795
+ }
796
+ }
797
+ const robots = appDir ? ["robots.ts", "robots.js"].map((name) => import_node_path5.default.join(appDir, name)).find(import_node_fs4.default.existsSync) : void 0;
798
+ if (robots && !/sitemap\s*:/.test(import_node_fs4.default.readFileSync(robots, "utf8"))) {
799
+ report(context, program, "app/robots.ts should include a sitemap field pointing to the sitemap URL.");
800
+ }
801
+ }
802
+ }),
803
+ "suggestion"
804
+ );
805
+
806
+ // src/rules/no-missing-title.ts
807
+ var seenTitles = /* @__PURE__ */ new Map();
808
+ var noMissingTitle = createRule(
809
+ "no-missing-title",
810
+ "require useful Next.js metadata titles",
811
+ (context) => ({
812
+ "Program:exit"(program) {
813
+ const filename = context.getFilename();
814
+ if (!isPageFile(filename) && !isLayoutFile(filename)) return;
815
+ const sources = metadataSources(program);
816
+ const title = sources.map((source) => getPathProperty(source, ["title"])).find(Boolean);
817
+ if (!title) {
818
+ report(context, program, "Page is missing metadata.title or a generateMetadata() title return value.");
819
+ return;
820
+ }
821
+ if (title.value.type === "Literal" && String(title.value.value ?? "").trim().length === 0) {
822
+ report(context, title.node, "metadata.title must not be empty or whitespace.");
823
+ }
824
+ const titleString = getStaticString(title.value);
825
+ if (titleString?.trim()) {
826
+ const route = routeFromFilename(filename);
827
+ const previous = seenTitles.get(titleString);
828
+ if (previous && previous !== filename) {
829
+ report(
830
+ context,
831
+ title.node,
832
+ `Duplicate metadata.title "${titleString}" also appears in ${previous}.`
833
+ );
834
+ } else {
835
+ seenTitles.set(titleString, route);
836
+ }
837
+ }
838
+ if (isLayoutFile(filename) && !getPathProperty(title.value, ["template"])) {
839
+ report(
840
+ context,
841
+ title.node,
842
+ 'Root/layout metadata.title should define a title.template such as "%s | Brand".'
843
+ );
844
+ }
845
+ if (title.value.type !== "ObjectExpression" && !getStaticString(title.value) && !hasNullishFallback(title.value)) {
846
+ report(
847
+ context,
848
+ title.node,
849
+ 'Dynamic metadata.title should include a non-empty fallback with ?? "Fallback title".'
850
+ );
851
+ }
852
+ }
853
+ })
854
+ );
855
+
856
+ // src/eslint.ts
857
+ var rules = {
858
+ "no-missing-title": noMissingTitle,
859
+ "no-missing-metadata-base": noMissingMetadataBase,
860
+ "no-missing-description": noMissingDescription,
861
+ "no-missing-canonical": noMissingCanonical,
862
+ "no-missing-og-tags": noMissingOgTags,
863
+ "no-broken-heading-hierarchy": noBrokenHeadingHierarchy,
864
+ "no-img-missing-alt": noImgMissingAlt,
865
+ "no-accidental-noindex": noAccidentalNoindex,
866
+ "no-missing-sitemap": noMissingSitemap,
867
+ "no-invalid-json-ld": noInvalidJsonLd
868
+ };
869
+ var recommendedRules = {
870
+ "seo-lint-next/no-missing-title": "error",
871
+ "seo-lint-next/no-missing-metadata-base": "error",
872
+ "seo-lint-next/no-missing-description": "warn",
873
+ "seo-lint-next/no-missing-canonical": "error",
874
+ "seo-lint-next/no-missing-og-tags": "warn",
875
+ "seo-lint-next/no-broken-heading-hierarchy": "warn",
876
+ "seo-lint-next/no-img-missing-alt": "warn",
877
+ "seo-lint-next/no-accidental-noindex": "error",
878
+ "seo-lint-next/no-missing-sitemap": "warn",
879
+ "seo-lint-next/no-invalid-json-ld": "error"
880
+ };
881
+ var plugin = {
882
+ meta: {
883
+ name: "seo-lint-next",
884
+ version: "0.1.0"
885
+ },
886
+ rules,
887
+ configs: {
888
+ recommended: [
889
+ {
890
+ plugins: {
891
+ "seo-lint-next": null
892
+ },
893
+ rules: recommendedRules
894
+ }
895
+ ],
896
+ legacyRecommended: {
897
+ plugins: ["seo-lint-next"],
898
+ rules: recommendedRules
899
+ }
900
+ }
901
+ };
902
+ plugin.configs.recommended[0].plugins["seo-lint-next"] = plugin;
903
+ var eslint_default = plugin;
904
+
905
+ // src/cli.ts
906
+ var defaultSeverities = {
907
+ "no-missing-title": 2,
908
+ "no-missing-metadata-base": 2,
909
+ "no-missing-description": 1,
910
+ "no-missing-canonical": 2,
911
+ "no-missing-og-tags": 1,
912
+ "no-broken-heading-hierarchy": 1,
913
+ "no-img-missing-alt": 1,
914
+ "no-accidental-noindex": 2,
915
+ "no-missing-sitemap": 1,
916
+ "no-invalid-json-ld": 2
917
+ };
918
+ async function main() {
919
+ const options = parseArgs(import_node_process.default.argv.slice(2));
920
+ const cwd = import_node_process.default.cwd();
921
+ const appDir = import_node_path6.default.resolve(cwd, options.dir);
922
+ if (!import_node_fs5.default.existsSync(appDir)) {
923
+ console.error(`seo-lint-next: app directory not found: ${options.dir}`);
924
+ import_node_process.default.exitCode = 2;
925
+ return;
926
+ }
927
+ const files = await (0, import_fast_glob.default)(["**/*.{ts,tsx,js,jsx,mts,mjs}"], {
928
+ cwd: appDir,
929
+ absolute: true,
930
+ ignore: ["**/node_modules/**", "**/.next/**"]
931
+ });
932
+ const linter = createLinter();
933
+ const rules2 = Object.fromEntries(
934
+ Object.keys(defaultSeverities).map((ruleName) => [
935
+ `seo-lint-next/${ruleName}`,
936
+ options.strict && defaultSeverities[ruleName] === 1 ? 2 : defaultSeverities[ruleName]
937
+ ])
938
+ );
939
+ const results = files.map((filePath) => {
940
+ const text = import_node_fs5.default.readFileSync(filePath, "utf8");
941
+ const messages = linter.verify(
942
+ text,
943
+ {
944
+ parser: "@typescript-eslint/parser",
945
+ parserOptions: {
946
+ ecmaVersion: "latest",
947
+ sourceType: "module",
948
+ ecmaFeatures: { jsx: true }
949
+ },
950
+ rules: rules2
951
+ },
952
+ { filename: filePath }
953
+ );
954
+ return { filePath, messages };
955
+ });
956
+ if (options.format === "json") {
957
+ console.log(JSON.stringify(results, null, 2));
958
+ } else {
959
+ printStylish(results);
960
+ }
961
+ const hasErrors = results.some((result) => result.messages.some((message) => message.severity === 2));
962
+ import_node_process.default.exitCode = hasErrors ? 1 : 0;
963
+ }
964
+ function createLinter() {
965
+ let linter;
966
+ try {
967
+ linter = new import_eslint.Linter({ configType: "eslintrc" });
968
+ } catch {
969
+ linter = new import_eslint.Linter();
970
+ }
971
+ linter.defineParser("@typescript-eslint/parser", import_parser.default);
972
+ for (const [ruleName, rule] of Object.entries(eslint_default.rules)) {
973
+ linter.defineRule(`seo-lint-next/${ruleName}`, rule);
974
+ }
975
+ return linter;
976
+ }
977
+ function parseArgs(args) {
978
+ const options = { dir: "app", format: "stylish", strict: false };
979
+ for (let index = 0; index < args.length; index += 1) {
980
+ const arg = args[index];
981
+ if (arg === "--dir") options.dir = args[++index] ?? options.dir;
982
+ else if (arg === "--format") {
983
+ const format = args[++index];
984
+ if (format === "json" || format === "stylish") options.format = format;
985
+ else throw new Error(`Unsupported format: ${format}`);
986
+ } else if (arg === "--strict") options.strict = true;
987
+ else if (arg === "--help" || arg === "-h") {
988
+ console.log(`seo-lint-next
989
+
990
+ Usage:
991
+ seo-lint-next [--dir app] [--format stylish|json] [--strict]
992
+
993
+ Options:
994
+ --dir App Router directory to lint. Defaults to app.
995
+ --format Output format. Defaults to stylish.
996
+ --strict Treat warnings as errors.
997
+ `);
998
+ import_node_process.default.exit(0);
999
+ }
1000
+ }
1001
+ return options;
1002
+ }
1003
+ function printStylish(results) {
1004
+ let count = 0;
1005
+ for (const result of results) {
1006
+ if (result.messages.length === 0) continue;
1007
+ console.log(result.filePath);
1008
+ for (const message of result.messages) {
1009
+ count += 1;
1010
+ const level = message.severity === 2 ? "error" : "warning";
1011
+ const line = String(message.line ?? 1).padStart(4);
1012
+ const column = String(message.column ?? 1).padEnd(3);
1013
+ console.log(` ${line}:${column} ${level.padEnd(7)} ${message.message} ${message.ruleId ?? ""}`);
1014
+ }
1015
+ console.log("");
1016
+ }
1017
+ if (count > 0) console.log(`${count} SEO issue${count === 1 ? "" : "s"} found.`);
1018
+ }
1019
+ main().catch((error) => {
1020
+ console.error(error instanceof Error ? error.message : error);
1021
+ import_node_process.default.exitCode = 2;
1022
+ });
1023
+ //# sourceMappingURL=cli.cjs.map