@nebulacomponents/citable 0.1.0 → 1.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/CHANGELOG.md +51 -0
- package/README.md +2 -2
- package/dist/universal/.agents/skills/citable/VERSION +1 -1
- package/dist/universal/.agents/skills/citable/manifest.json +5 -5
- package/dist/universal/.agents/skills/citable/schemas/finding.schema.json +253 -50
- package/dist/universal/.claude/skills/citable/VERSION +1 -1
- package/dist/universal/.claude/skills/citable/manifest.json +5 -5
- package/dist/universal/.claude/skills/citable/schemas/finding.schema.json +253 -50
- package/dist/universal/.cursor/skills/citable/VERSION +1 -1
- package/dist/universal/.cursor/skills/citable/manifest.json +5 -5
- package/dist/universal/.cursor/skills/citable/schemas/finding.schema.json +253 -50
- package/dist/universal/.gemini/skills/citable/VERSION +1 -1
- package/dist/universal/.gemini/skills/citable/manifest.json +5 -5
- package/dist/universal/.gemini/skills/citable/schemas/finding.schema.json +253 -50
- package/dist/universal/.github/skills/citable/VERSION +1 -1
- package/dist/universal/.github/skills/citable/manifest.json +5 -5
- package/dist/universal/.github/skills/citable/schemas/finding.schema.json +253 -50
- package/dist/universal/.kiro/skills/citable/VERSION +1 -1
- package/dist/universal/.kiro/skills/citable/manifest.json +5 -5
- package/dist/universal/.kiro/skills/citable/schemas/finding.schema.json +253 -50
- package/dist/universal/.opencode/skills/citable/VERSION +1 -1
- package/dist/universal/.opencode/skills/citable/manifest.json +5 -5
- package/dist/universal/.opencode/skills/citable/schemas/finding.schema.json +253 -50
- package/dist/universal/.pi/agent/skills/citable/VERSION +1 -1
- package/dist/universal/.pi/agent/skills/citable/manifest.json +5 -5
- package/dist/universal/.pi/agent/skills/citable/schemas/finding.schema.json +253 -50
- package/dist/universal/.qoder/skills/citable/VERSION +1 -1
- package/dist/universal/.qoder/skills/citable/manifest.json +5 -5
- package/dist/universal/.qoder/skills/citable/schemas/finding.schema.json +253 -50
- package/dist/universal/.rovodev/skills/citable/VERSION +1 -1
- package/dist/universal/.rovodev/skills/citable/manifest.json +5 -5
- package/dist/universal/.rovodev/skills/citable/schemas/finding.schema.json +253 -50
- package/dist/universal/.trae/skills/citable/VERSION +1 -1
- package/dist/universal/.trae/skills/citable/manifest.json +5 -5
- package/dist/universal/.trae/skills/citable/schemas/finding.schema.json +253 -50
- package/dist/universal/.trae-cn/skills/citable/VERSION +1 -1
- package/dist/universal/.trae-cn/skills/citable/manifest.json +5 -5
- package/dist/universal/.trae-cn/skills/citable/schemas/finding.schema.json +253 -50
- package/dist/universal/manifest.json +51 -51
- package/package.json +4 -1
- package/schemas/finding.schema.json +253 -50
- package/src/crawler/fetch.js +42 -9
- package/src/detectors/agent.js +495 -0
- package/src/detectors/cwv.js +171 -0
- package/src/detectors/framework.js +1 -1
- package/src/detectors/hreflang.js +164 -0
- package/src/detectors/index.js +4 -1
- package/src/installer/index.js +7 -2
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hreflang detectors for international SEO
|
|
3
|
+
*
|
|
4
|
+
* Checks hreflang annotations for correctness, completeness, and reciprocity.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { defineDetector } from './framework.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* HREFLANG-001: hreflang link present and valid
|
|
11
|
+
*/
|
|
12
|
+
export const HREFLANG_001 = defineDetector({
|
|
13
|
+
id: 'HREFLANG-001',
|
|
14
|
+
name: 'hreflang link valid',
|
|
15
|
+
namespace: 'HREFLANG',
|
|
16
|
+
discipline: ['seo'],
|
|
17
|
+
severity: 'high',
|
|
18
|
+
deterministic: true,
|
|
19
|
+
description: 'Page has valid hreflang link elements',
|
|
20
|
+
remediation: 'Use valid ISO 639-1 language codes and absolute URLs for hreflang targets',
|
|
21
|
+
verification: 'Check each hreflang link element for valid language code and absolute URL',
|
|
22
|
+
check: (ctx) => {
|
|
23
|
+
const hits = [];
|
|
24
|
+
const page = ctx.page;
|
|
25
|
+
if (!page || !page.head) return hits;
|
|
26
|
+
|
|
27
|
+
const hreflangLinks = page.head.querySelectorAll('link[rel="alternate"][hreflang]');
|
|
28
|
+
|
|
29
|
+
if (hreflangLinks.length === 0) {
|
|
30
|
+
// Not a hit - many pages don't need hreflang
|
|
31
|
+
return hits;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
for (const link of hreflangLinks) {
|
|
35
|
+
const hreflang = link.getAttribute('hreflang');
|
|
36
|
+
const href = link.getAttribute('href');
|
|
37
|
+
|
|
38
|
+
// Check for invalid hreflang codes
|
|
39
|
+
if (!hreflang || hreflang === '') {
|
|
40
|
+
hits.push({
|
|
41
|
+
subject: ctx.url,
|
|
42
|
+
evidence: ['Empty hreflang attribute found'],
|
|
43
|
+
remediation: 'Provide a valid hreflang code (e.g., "en", "en-US", "x-default")',
|
|
44
|
+
});
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Check for x-default
|
|
49
|
+
if (hreflang === 'x-default') {
|
|
50
|
+
// x-default is valid and recommended
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Validate hreflang format (ISO 639-1 or 639-1-3166-1)
|
|
55
|
+
const validPattern = /^[a-z]{2}(-[A-Z]{2})?$/;
|
|
56
|
+
if (!validPattern.test(hreflang)) {
|
|
57
|
+
hits.push({
|
|
58
|
+
subject: ctx.url,
|
|
59
|
+
evidence: ['Invalid hreflang code: "' + hreflang + '"'],
|
|
60
|
+
remediation: 'Use valid ISO 639-1 language code (e.g., "en") or language-region format (e.g., "en-US")',
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Check href is absolute URL
|
|
65
|
+
if (href && !href.startsWith('http')) {
|
|
66
|
+
hits.push({
|
|
67
|
+
subject: ctx.url,
|
|
68
|
+
evidence: ['hreflang "' + hreflang + '" uses relative URL: "' + href + '"'],
|
|
69
|
+
remediation: 'Use absolute URLs for hreflang targets',
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return hits;
|
|
75
|
+
},
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* HREFLANG-002: Self-referencing hreflang exists
|
|
80
|
+
*/
|
|
81
|
+
export const HREFLANG_002 = defineDetector({
|
|
82
|
+
id: 'HREFLANG-002',
|
|
83
|
+
name: 'hreflang self-reference',
|
|
84
|
+
namespace: 'HREFLANG',
|
|
85
|
+
discipline: ['seo'],
|
|
86
|
+
severity: 'medium',
|
|
87
|
+
deterministic: true,
|
|
88
|
+
description: 'Page references itself in hreflang',
|
|
89
|
+
remediation: 'Add a self-referencing hreflang link pointing to the current URL',
|
|
90
|
+
verification: 'Check if any hreflang link href matches the current page URL',
|
|
91
|
+
check: (ctx) => {
|
|
92
|
+
const page = ctx.page;
|
|
93
|
+
if (!page || !page.head) return [];
|
|
94
|
+
|
|
95
|
+
const hreflangLinks = page.head.querySelectorAll('link[rel="alternate"][hreflang]');
|
|
96
|
+
if (hreflangLinks.length === 0) return [];
|
|
97
|
+
|
|
98
|
+
// Find self-reference
|
|
99
|
+
let hasSelfRef = false;
|
|
100
|
+
for (const link of hreflangLinks) {
|
|
101
|
+
const href = link.getAttribute('href');
|
|
102
|
+
if (href === ctx.url) {
|
|
103
|
+
hasSelfRef = true;
|
|
104
|
+
break;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (!hasSelfRef && hreflangLinks.length > 0) {
|
|
109
|
+
return [{
|
|
110
|
+
subject: ctx.url,
|
|
111
|
+
evidence: ['Page has hreflang links but does not reference itself'],
|
|
112
|
+
remediation: 'Add a self-referencing hreflang link (e.g., if page is English, include <link rel="alternate" hreflang="en" href="CURRENT_URL" />)',
|
|
113
|
+
}];
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return [];
|
|
117
|
+
},
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* HREFLANG-003: x-default present for international pages
|
|
122
|
+
*/
|
|
123
|
+
export const HREFLANG_003 = defineDetector({
|
|
124
|
+
id: 'HREFLANG-003',
|
|
125
|
+
name: 'hreflang x-default',
|
|
126
|
+
namespace: 'HREFLANG',
|
|
127
|
+
discipline: ['seo'],
|
|
128
|
+
severity: 'medium',
|
|
129
|
+
deterministic: true,
|
|
130
|
+
determinismNote: 'False positive possible if x-default is intentionally omitted',
|
|
131
|
+
description: 'Pages with multiple hreflang include x-default',
|
|
132
|
+
remediation: 'Add x-default hreflang for users outside targeted locales',
|
|
133
|
+
verification: 'Check for presence of hreflang="x-default" when multiple hreflang exist',
|
|
134
|
+
check: (ctx) => {
|
|
135
|
+
const page = ctx.page;
|
|
136
|
+
if (!page || !page.head) return [];
|
|
137
|
+
|
|
138
|
+
const hreflangLinks = page.head.querySelectorAll('link[rel="alternate"][hreflang]');
|
|
139
|
+
if (hreflangLinks.length < 2) {
|
|
140
|
+
// Only check x-default for pages that have multiple hreflang
|
|
141
|
+
return [];
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const hasDefault = Array.from(hreflangLinks).some(
|
|
145
|
+
link => link.getAttribute('hreflang') === 'x-default'
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
if (!hasDefault) {
|
|
149
|
+
return [{
|
|
150
|
+
subject: ctx.url,
|
|
151
|
+
evidence: ['Page has ' + hreflangLinks.length + ' hreflang links but no x-default'],
|
|
152
|
+
remediation: 'Add <link rel="alternate" hreflang="x-default" href="FALLBACK_URL" /> for users outside targeted locales',
|
|
153
|
+
}];
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return [];
|
|
157
|
+
},
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
export const hreflangDetectors = [
|
|
161
|
+
HREFLANG_001,
|
|
162
|
+
HREFLANG_002,
|
|
163
|
+
HREFLANG_003,
|
|
164
|
+
];
|
package/src/detectors/index.js
CHANGED
|
@@ -10,8 +10,11 @@ import schemaData from './schemaData.js';
|
|
|
10
10
|
import link from './link.js';
|
|
11
11
|
import geoReco from './geoReco.js';
|
|
12
12
|
import lifeMeas from './lifeMeas.js';
|
|
13
|
+
import { hreflangDetectors } from './hreflang.js';
|
|
14
|
+
import { cwvDetectors } from './cwv.js';
|
|
15
|
+
import { AGENT_DETECTORS } from './agent.js';
|
|
13
16
|
|
|
14
|
-
export const ALL_DETECTORS = [...tech, ...crawl, ...arch, ...page, ...ans, ...entity, ...claim, ...evd, ...schemaData, ...link, ...geoReco, ...lifeMeas];
|
|
17
|
+
export const ALL_DETECTORS = [...tech, ...crawl, ...arch, ...page, ...ans, ...entity, ...claim, ...evd, ...schemaData, ...link, ...geoReco, ...lifeMeas, ...hreflangDetectors, ...cwvDetectors, ...AGENT_DETECTORS];
|
|
15
18
|
|
|
16
19
|
const ids = new Set();
|
|
17
20
|
for (const d of ALL_DETECTORS) {
|
package/src/installer/index.js
CHANGED
|
@@ -698,8 +698,13 @@ function installOrUpdateTarget(target, args, options = {}, command = 'install')
|
|
|
698
698
|
}
|
|
699
699
|
|
|
700
700
|
function ensureTargetInsideScope(targetPath, scopeRoot) {
|
|
701
|
-
|
|
702
|
-
|
|
701
|
+
// Use realpathSync when possible to resolve symlinks (e.g. /tmp → /private/tmp on macOS)
|
|
702
|
+
// so that cross-symlink paths compare correctly.
|
|
703
|
+
function realOrResolve(p) {
|
|
704
|
+
try { return fs.realpathSync(p); } catch { return path.resolve(p); }
|
|
705
|
+
}
|
|
706
|
+
const resolvedTarget = realOrResolve(targetPath);
|
|
707
|
+
const resolvedScope = realOrResolve(scopeRoot);
|
|
703
708
|
const rel = path.relative(resolvedScope, resolvedTarget);
|
|
704
709
|
if (rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel))) return;
|
|
705
710
|
throw new CitableInstallerError(`refusing to write outside selected scope: ${resolvedTarget}`, EXIT_CODES.integrityFailure);
|