📦 deps(thirdparty): update snapshots
This commit is contained in:
@@ -13,11 +13,14 @@ const NORMALIZED_BASE_PATH = BASE_PATH && BASE_PATH !== '/' ? BASE_PATH : '';
|
||||
const DEFAULT_SITE_URL = 'https://sickn33.github.io/agentic-awesome-skills';
|
||||
|
||||
const SITE_URL = (process.env.SEO_SITE_URL || process.env.WEBSITE_BASE_URL || DEFAULT_SITE_URL).replace(/\/$/, '');
|
||||
const TOP_SKILL_COUNT = Number.parseInt(process.env.TOP_SKILL_COUNT || '40', 10);
|
||||
// Keep this curated: broad enough to form a crawlable catalog, well below the
|
||||
// full library so thin/low-signal detail pages are not mass-submitted.
|
||||
export const DEFAULT_TOP_SKILL_COUNT = 180;
|
||||
const TOP_SKILL_COUNT = Number.parseInt(process.env.TOP_SKILL_COUNT || String(DEFAULT_TOP_SKILL_COUNT), 10);
|
||||
const DEFAULT_LASTMOD = new Date().toISOString().slice(0, 10);
|
||||
|
||||
function getTopSkillCount() {
|
||||
return Number.isFinite(TOP_SKILL_COUNT) ? Math.max(TOP_SKILL_COUNT, 0) : 40;
|
||||
return Number.isFinite(TOP_SKILL_COUNT) ? Math.max(TOP_SKILL_COUNT, 0) : DEFAULT_TOP_SKILL_COUNT;
|
||||
}
|
||||
|
||||
function escapeXml(text) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { buildSitemap, getSeoLandingPaths, selectTopSkillEntries } from './generate-sitemap.js';
|
||||
import { buildSitemap, DEFAULT_TOP_SKILL_COUNT, getSeoLandingPaths, selectTopSkillEntries } from './generate-sitemap.js';
|
||||
|
||||
describe('sitemap generation script helpers', () => {
|
||||
it('builds top skill entries sorted by stars/date/name without duplicates', () => {
|
||||
@@ -62,4 +62,18 @@ describe('sitemap generation script helpers', () => {
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the default public sitemap skill count reproducible', () => {
|
||||
const catalog = Array.from({ length: DEFAULT_TOP_SKILL_COUNT + 1 }, (_, index) => ({
|
||||
id: `skill-${String(index).padStart(2, '0')}`,
|
||||
stars: DEFAULT_TOP_SKILL_COUNT + 1 - index,
|
||||
}));
|
||||
|
||||
const xml = buildSitemap(catalog, undefined, 'https://example.com');
|
||||
const skillRoutes = xml.match(/https:\/\/example\.com\/skill\//g) || [];
|
||||
|
||||
expect(skillRoutes).toHaveLength(180);
|
||||
expect(xml).toContain('https://example.com/skill/skill-179/</loc>');
|
||||
expect(xml).not.toContain('https://example.com/skill/skill-180/</loc>');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -281,6 +281,28 @@ function getRelatedLandingPagesForSkill(landingPages, skill, limit = 3) {
|
||||
return selected.slice(0, maxItems);
|
||||
}
|
||||
|
||||
function getCuratedSkillsForLandingPage(page, skills, limit = 12) {
|
||||
const maxItems = Math.max(0, limit);
|
||||
if (maxItems === 0 || !Array.isArray(skills)) return [];
|
||||
|
||||
const byId = new Map(skills.map((skill) => [skill.id, skill]));
|
||||
const editorial = (Array.isArray(page.featuredSkillIds) ? page.featuredSkillIds : [])
|
||||
.map((id) => byId.get(id))
|
||||
.filter(Boolean);
|
||||
const selectedIds = new Set(editorial.map((skill) => skill.id));
|
||||
const scored = skills
|
||||
.map((skill, index) => ({ skill, index, score: scoreLandingPageForSkill(page, skill) }))
|
||||
.filter(({ skill, score }) => score > 0 && !selectedIds.has(skill.id))
|
||||
.sort((a, b) => {
|
||||
if (a.score !== b.score) return b.score - a.score;
|
||||
const idCompare = safeText(a.skill.id).localeCompare(safeText(b.skill.id), undefined, { sensitivity: 'base' });
|
||||
return idCompare || a.index - b.index;
|
||||
})
|
||||
.map(({ skill }) => skill);
|
||||
|
||||
return [...editorial, ...scored].slice(0, maxItems);
|
||||
}
|
||||
|
||||
function buildStaticLinkList(links) {
|
||||
return links
|
||||
.map((link) => `<li><a href="${escapeHtml(link.href)}">${escapeHtml(link.label)}</a></li>`)
|
||||
@@ -299,7 +321,32 @@ function buildPrerenderFallback({ heading, description, links }) {
|
||||
].join('');
|
||||
}
|
||||
|
||||
function buildTopicFallback({ page, landingPages, siteBaseUrl }) {
|
||||
function buildHomeFallback({ landingPages, siteBaseUrl }) {
|
||||
const links = [
|
||||
{ href: routeToUrl('/workbench', siteBaseUrl), label: 'Review an AAS stack and plan' },
|
||||
{ href: routeToUrl('/plugins', siteBaseUrl), label: 'Compare specialized plugin packs' },
|
||||
...landingPages.filter((page) => page.slug).map((page) => ({
|
||||
href: routeToUrl(`/topics/${encodeURIComponent(page.slug)}`, siteBaseUrl),
|
||||
label: page.h1,
|
||||
})),
|
||||
];
|
||||
|
||||
return [
|
||||
'<main data-prerender-fallback="true">',
|
||||
'<h1>Installable AI agent skills for Codex, Claude Code, Cursor, Gemini, and Antigravity</h1>',
|
||||
'<p><strong>Find the right skill. Ship the better agent.</strong></p>',
|
||||
'<p>Browse a GitHub-backed catalog of reusable SKILL.md playbooks, specialized plugins, bundles, and workflows.</p>',
|
||||
`<nav aria-label="Catalog hubs"><ul>${buildStaticLinkList(links)}</ul></nav>`,
|
||||
'</main>',
|
||||
].join('');
|
||||
}
|
||||
|
||||
function buildTopicFallback({ page, landingPages, skills, siteBaseUrl }) {
|
||||
const curatedSkills = getCuratedSkillsForLandingPage(page, skills);
|
||||
const skillLinks = curatedSkills.map((skill) => ({
|
||||
href: routeToUrl(`/skill/${encodeURIComponent(skill.id)}`, siteBaseUrl),
|
||||
label: `@${safeText(skill.name) || safeText(skill.id)}`,
|
||||
}));
|
||||
const relatedLinks = landingPages
|
||||
.filter((landing) => landing.slug && landing.slug !== page.slug)
|
||||
.slice(0, 3)
|
||||
@@ -308,11 +355,19 @@ function buildTopicFallback({ page, landingPages, siteBaseUrl }) {
|
||||
label: landing.h1,
|
||||
}));
|
||||
|
||||
return buildPrerenderFallback({
|
||||
heading: page.h1,
|
||||
description: page.summary,
|
||||
links: relatedLinks,
|
||||
});
|
||||
const sections = (Array.isArray(page.sections) ? page.sections : [])
|
||||
.map((section) => `<section><h2>${escapeHtml(section.heading)}</h2><p>${escapeHtml(section.body)}</p></section>`)
|
||||
.join('');
|
||||
|
||||
return [
|
||||
'<main data-prerender-fallback="true">',
|
||||
`<h1>${escapeHtml(page.h1)}</h1>`,
|
||||
`<p>${escapeHtml(page.summary)}</p>`,
|
||||
sections,
|
||||
skillLinks.length > 0 ? `<nav aria-label="Recommended skills"><h2>Recommended skills</h2><ul>${buildStaticLinkList(skillLinks)}</ul></nav>` : '',
|
||||
`<nav aria-label="Related topic guides"><h2>Related topic guides</h2><ul>${buildStaticLinkList(relatedLinks)}</ul></nav>`,
|
||||
'</main>',
|
||||
].join('');
|
||||
}
|
||||
|
||||
function buildSkillFallback({ skill, landingPages, siteBaseUrl }) {
|
||||
@@ -350,11 +405,11 @@ function buildHomeMeta({ catalogCount, imageUrl, canonicalUrl }) {
|
||||
name: SITE_NAME,
|
||||
description: `Installable GitHub library of ${formattedCount}+ agentic skills, specialized plugins, bundles, and workflows for AI coding assistants.`,
|
||||
url: REPOSITORY_URL,
|
||||
sameAs: [
|
||||
sameAs: [...new Set([
|
||||
canonicalUrl,
|
||||
HOSTED_CATALOG_URL,
|
||||
'https://www.npmjs.com/package/agentic-awesome-skills',
|
||||
],
|
||||
])],
|
||||
mainEntityOfPage: canonicalUrl,
|
||||
codeRepository: REPOSITORY_URL,
|
||||
applicationCategory: 'DeveloperApplication',
|
||||
@@ -541,8 +596,8 @@ function buildPluginsMeta({ pluginCount, imageUrl, canonicalUrl }) {
|
||||
}
|
||||
|
||||
function buildWorkbenchMeta({ imageUrl, canonicalUrl }) {
|
||||
const title = 'Skill Workbench | Agentic Awesome Skills';
|
||||
const description = 'Filter canonical skill evidence, compose an exact host-aware set, and preview a version-pinned install without filesystem writes.';
|
||||
const title = 'Stack Review Workbench | Agentic Awesome Skills';
|
||||
const description = 'Review an AAS stack manifest and immutable plan locally in your browser. Imports stay in memory and cannot install or apply changes.';
|
||||
const catalogBaseUrl = canonicalUrl.replace(/\/workbench\/?$/, '');
|
||||
const catalogRootUrl = `${catalogBaseUrl}/`;
|
||||
const sourceCodeEntity = {
|
||||
@@ -572,7 +627,7 @@ function buildWorkbenchMeta({ imageUrl, canonicalUrl }) {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'WebPage',
|
||||
name: 'Agentic Awesome Skills Workbench',
|
||||
headline: 'Build a precise, inspectable skill set',
|
||||
headline: 'Review what your agent chose',
|
||||
description,
|
||||
url: canonicalUrl,
|
||||
mainEntityOfPage: canonicalUrl,
|
||||
@@ -622,7 +677,7 @@ function buildWorkbenchMeta({ imageUrl, canonicalUrl }) {
|
||||
};
|
||||
}
|
||||
|
||||
function buildTopicLandingMeta({ page, imageUrl, canonicalUrl }) {
|
||||
function buildTopicLandingMeta({ page, featuredSkills = [], imageUrl, canonicalUrl }) {
|
||||
const catalogBaseUrl = canonicalUrl.replace(/\/topics\/[^/]+\/?$/, '');
|
||||
const keywords = Array.isArray(page.keywords) ? page.keywords.join(', ') : '';
|
||||
const sourceCodeEntity = {
|
||||
@@ -631,11 +686,11 @@ function buildTopicLandingMeta({ page, imageUrl, canonicalUrl }) {
|
||||
name: SITE_NAME,
|
||||
description: 'Installable GitHub library of agentic skills, specialized plugins, bundles, and workflows for AI coding assistants.',
|
||||
url: REPOSITORY_URL,
|
||||
sameAs: [
|
||||
sameAs: [...new Set([
|
||||
canonicalUrl,
|
||||
HOSTED_CATALOG_URL,
|
||||
'https://www.npmjs.com/package/agentic-awesome-skills',
|
||||
],
|
||||
])],
|
||||
mainEntityOfPage: canonicalUrl,
|
||||
codeRepository: REPOSITORY_URL,
|
||||
applicationCategory: 'DeveloperApplication',
|
||||
@@ -679,15 +734,22 @@ function buildTopicLandingMeta({ page, imageUrl, canonicalUrl }) {
|
||||
keywords,
|
||||
mainEntity: {
|
||||
'@type': 'ItemList',
|
||||
name: `${page.eyebrow} topics`,
|
||||
itemListElement: Array.isArray(page.sections)
|
||||
? page.sections.map((section, index) => ({
|
||||
name: `${page.eyebrow} recommended skills`,
|
||||
numberOfItems: featuredSkills.length || (Array.isArray(page.sections) ? page.sections.length : 0),
|
||||
itemListElement: featuredSkills.length > 0
|
||||
? featuredSkills.map((skill, index) => ({
|
||||
'@type': 'ListItem',
|
||||
position: index + 1,
|
||||
name: safeText(skill.name) || safeText(skill.id),
|
||||
description: safeText(skill.description),
|
||||
url: routeToUrl(`/skill/${encodeURIComponent(skill.id)}`, catalogBaseUrl),
|
||||
}))
|
||||
: (Array.isArray(page.sections) ? page.sections.map((section, index) => ({
|
||||
'@type': 'ListItem',
|
||||
position: index + 1,
|
||||
name: section.heading,
|
||||
description: section.body,
|
||||
}))
|
||||
: [],
|
||||
})) : []),
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -812,7 +874,7 @@ function main() {
|
||||
const skills = readCatalog();
|
||||
const landingPages = readSeoLandingPages();
|
||||
const siteBaseUrl = getSiteBaseUrl();
|
||||
const topCount = parseCount(process.env.PRERENDER_TOP_SKILL_COUNT || process.env.TOP_SKILL_COUNT, 40);
|
||||
const topCount = parseCount(process.env.PRERENDER_TOP_SKILL_COUNT || process.env.TOP_SKILL_COUNT, 180);
|
||||
const topSkillPaths = selectTopSkillEntries(skills, topCount);
|
||||
const skillMap = new Map(skills.map((skill) => [skill.id, skill]));
|
||||
const topSkillSet = new Set(topSkillPaths.map((routePath) => routePath.replace(/^\/skill\//, '')));
|
||||
@@ -824,7 +886,7 @@ function main() {
|
||||
imageUrl: socialImage,
|
||||
canonicalUrl: homeCanonical,
|
||||
});
|
||||
writePrerenderedRoute('/', template, homeMeta);
|
||||
writePrerenderedRoute('/', template, homeMeta, buildHomeFallback({ landingPages, siteBaseUrl }));
|
||||
|
||||
const pluginsCanonical = routeToUrl('/plugins', siteBaseUrl);
|
||||
const pluginsMeta = buildPluginsMeta({
|
||||
@@ -850,6 +912,7 @@ function main() {
|
||||
const canonicalUrl = routeToUrl(routePath, siteBaseUrl);
|
||||
const landingMeta = buildTopicLandingMeta({
|
||||
page,
|
||||
featuredSkills: getCuratedSkillsForLandingPage(page, skills),
|
||||
imageUrl: socialImage,
|
||||
canonicalUrl,
|
||||
});
|
||||
@@ -857,7 +920,7 @@ function main() {
|
||||
routePath,
|
||||
template,
|
||||
landingMeta,
|
||||
buildTopicFallback({ page, landingPages, siteBaseUrl }),
|
||||
buildTopicFallback({ page, landingPages, skills, siteBaseUrl }),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import sanitizeFilename from 'sanitize-filename';
|
||||
import { getSeoLandingPaths } from './generate-sitemap.js';
|
||||
|
||||
const APP_ROOT_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const REPO_ROOT_DIR = path.resolve(APP_ROOT_DIR, '..', '..');
|
||||
const REPOSITORY_URL = 'https://github.com/sickn33/agentic-awesome-skills';
|
||||
const PACKAGE_URL = 'https://www.npmjs.com/package/agentic-awesome-skills';
|
||||
const EXPECTED_HOSTED_CATALOG_ROOT = 'https://sickn33.github.io/agentic-awesome-skills/';
|
||||
|
||||
function safeUserPath(pathValue, baseDir = process.cwd()) {
|
||||
const basePath = path.resolve(baseDir);
|
||||
@@ -25,6 +30,51 @@ function safeUserPath(pathValue, baseDir = process.cwd()) {
|
||||
return path.resolve(basePath, ...sanitizedSegments);
|
||||
}
|
||||
|
||||
function assertPlainFileInsideRoot(filePath, rootDir) {
|
||||
const resolvedRoot = path.resolve(rootDir);
|
||||
const resolvedFile = path.resolve(filePath);
|
||||
const relative = path.relative(resolvedRoot, resolvedFile);
|
||||
assert(
|
||||
relative && !relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative),
|
||||
`File must remain inside verification root: ${filePath}`,
|
||||
);
|
||||
const candidateAnchors = [REPO_ROOT_DIR, APP_ROOT_DIR, process.cwd(), os.tmpdir(), '/tmp']
|
||||
.map((candidate) => path.resolve(candidate))
|
||||
.filter((candidate, index, anchors) => anchors.indexOf(candidate) === index && fs.existsSync(candidate))
|
||||
.filter((candidate) => {
|
||||
const candidateRelative = path.relative(candidate, resolvedRoot);
|
||||
return !candidateRelative.startsWith(`..${path.sep}`) && candidateRelative !== '..' && !path.isAbsolute(candidateRelative);
|
||||
})
|
||||
.sort((left, right) => right.length - left.length);
|
||||
assert(candidateAnchors.length > 0, `Verification root is outside trusted filesystem anchors: ${resolvedRoot}`);
|
||||
const trustedAnchor = candidateAnchors[0];
|
||||
let current = trustedAnchor;
|
||||
const rootRelative = path.relative(trustedAnchor, resolvedRoot);
|
||||
for (const segment of rootRelative.split(path.sep).filter(Boolean)) {
|
||||
current = path.join(current, segment);
|
||||
const stat = fs.lstatSync(current);
|
||||
assert(!stat.isSymbolicLink(), `Verification path must not contain symlinks: ${current}`);
|
||||
}
|
||||
const rootStat = fs.lstatSync(resolvedRoot);
|
||||
assert(rootStat.isDirectory() && !rootStat.isSymbolicLink(), `Verification root must be a plain directory: ${resolvedRoot}`);
|
||||
current = resolvedRoot;
|
||||
for (const segment of relative.split(path.sep)) {
|
||||
current = path.join(current, segment);
|
||||
const stat = fs.lstatSync(current);
|
||||
assert(!stat.isSymbolicLink(), `Verification path must not contain symlinks: ${current}`);
|
||||
}
|
||||
const fileStat = fs.lstatSync(resolvedFile);
|
||||
assert(fileStat.isFile() && !fileStat.isSymbolicLink(), `Verification input must be a plain file: ${resolvedFile}`);
|
||||
const physicalRoot = fs.realpathSync(resolvedRoot);
|
||||
const physicalFile = fs.realpathSync(resolvedFile);
|
||||
const physicalRelative = path.relative(physicalRoot, physicalFile);
|
||||
assert(
|
||||
physicalRelative && !physicalRelative.startsWith(`..${path.sep}`) && physicalRelative !== '..' && !path.isAbsolute(physicalRelative),
|
||||
`Verification input escaped its physical root: ${resolvedFile}`,
|
||||
);
|
||||
return resolvedFile;
|
||||
}
|
||||
|
||||
export function extractSitemapLocations(xmlText) {
|
||||
const raw = String(xmlText ?? '');
|
||||
const matches = raw.matchAll(/<loc>(.*?)<\/loc>/g);
|
||||
@@ -45,7 +95,7 @@ function assert(condition, message) {
|
||||
function parseCliArgs(argv) {
|
||||
const defaultMinSkillUrls = parseCount(
|
||||
process.env.PRERENDER_VERIFY_MIN_SKILL_URLS || process.env.PRERENDER_TOP_SKILL_COUNT || process.env.TOP_SKILL_COUNT,
|
||||
40,
|
||||
180,
|
||||
);
|
||||
const args = {
|
||||
sitemapPath: 'dist/sitemap.xml',
|
||||
@@ -148,17 +198,29 @@ function getPackageReleaseLabel() {
|
||||
}
|
||||
|
||||
function extractMetaContent(htmlText, selectorType, selectorValue) {
|
||||
const pattern = new RegExp(
|
||||
`<meta\\s+[^>]*${selectorType}=["']${selectorValue}["'][^>]*\\scontent=["']([^"']+)["'][^>]*>`,
|
||||
'i',
|
||||
const document = new JSDOM(String(htmlText ?? '')).window.document;
|
||||
const match = [...document.querySelectorAll('meta')].find(
|
||||
(element) => element.getAttribute(selectorType) === selectorValue,
|
||||
);
|
||||
const match = htmlText.match(pattern);
|
||||
return match?.[1]?.trim();
|
||||
return match?.getAttribute('content')?.trim();
|
||||
}
|
||||
|
||||
function extractCanonicalHrefs(htmlText) {
|
||||
const document = new JSDOM(String(htmlText ?? '')).window.document;
|
||||
return [...document.querySelectorAll('link')]
|
||||
.filter((element) => (element.getAttribute('rel') || '').split(/\s+/).some((token) => token.toLowerCase() === 'canonical'))
|
||||
.map((element) => element.getAttribute('href')?.trim());
|
||||
}
|
||||
|
||||
function extractExactMetaContents(htmlText, selectorType, selectorValue) {
|
||||
const document = new JSDOM(String(htmlText ?? '')).window.document;
|
||||
return [...document.querySelectorAll('meta')]
|
||||
.filter((element) => element.getAttribute(selectorType) === selectorValue)
|
||||
.map((element) => element.getAttribute('content')?.trim());
|
||||
}
|
||||
|
||||
function extractTitle(htmlText) {
|
||||
const match = String(htmlText ?? '').match(/<title[^>]*>([\s\S]*?)<\/title>/i);
|
||||
return match?.[1]?.trim() || '';
|
||||
return new JSDOM(String(htmlText ?? '')).window.document.title.trim();
|
||||
}
|
||||
|
||||
function extractSkillCountLabels(text) {
|
||||
@@ -208,6 +270,7 @@ export function analyzeSitemap(urlText, { minSkillUrls = 1, requireHostedUrl = f
|
||||
if (requireHostedUrl) {
|
||||
assert(url.hostname !== 'localhost', `Sitemap URL must not use localhost: ${location}`);
|
||||
}
|
||||
assert(url.pathname.endsWith('/'), `Sitemap indexable route must end with a trailing slash: ${location}`);
|
||||
return { raw: location, parsed: url };
|
||||
});
|
||||
|
||||
@@ -224,7 +287,19 @@ export function analyzeSitemap(urlText, { minSkillUrls = 1, requireHostedUrl = f
|
||||
assert(Boolean(rootCandidate), 'Sitemap does not expose a homepage candidate URL.');
|
||||
|
||||
const rootUrl = new URL(rootCandidate.raw);
|
||||
if (requireHostedUrl) {
|
||||
assert(rootCandidate.raw === EXPECTED_HOSTED_CATALOG_ROOT, `Hosted sitemap root must equal ${EXPECTED_HOSTED_CATALOG_ROOT}`);
|
||||
}
|
||||
const normalizedRoot = rootUrl.pathname === '/' ? '' : rootUrl.pathname.replace(/\/+$/, '');
|
||||
const rootPrefix = normalizedRoot ? `${normalizedRoot}/` : '/';
|
||||
for (const { raw, parsed: parsedUrl } of parsed) {
|
||||
assert(parsedUrl.origin === rootUrl.origin, `Sitemap URL must share the homepage origin ${rootUrl.origin}: ${raw}`);
|
||||
assert(
|
||||
parsedUrl.pathname === rootUrl.pathname || parsedUrl.pathname === normalizedRoot || parsedUrl.pathname.startsWith(rootPrefix),
|
||||
`Sitemap URL must remain inside the homepage root ${rootUrl.pathname}: ${raw}`,
|
||||
);
|
||||
assert(!parsedUrl.username && !parsedUrl.password && !parsedUrl.search && !parsedUrl.hash, `Sitemap URL must not contain credentials, query, or fragment: ${raw}`);
|
||||
}
|
||||
const skillPrefix = `${normalizedRoot}/skill/`;
|
||||
const rootPathVariants = new Set([
|
||||
rootUrl.pathname,
|
||||
@@ -273,6 +348,7 @@ export function analyzeSitemap(urlText, { minSkillUrls = 1, requireHostedUrl = f
|
||||
|
||||
return {
|
||||
locations,
|
||||
rootUrl: rootCandidate.raw,
|
||||
rootPath: rootUrl.pathname,
|
||||
normalizedRootPath: normalizedRoot,
|
||||
skillUrls: skillRoutes.map(({ raw }) => raw),
|
||||
@@ -291,14 +367,14 @@ export function assertSitemap(urlText, { minSkillUrls = 1, requireHostedUrl = fa
|
||||
}
|
||||
|
||||
function extractJsonLdEntries(htmlText) {
|
||||
const raw = String(htmlText ?? '');
|
||||
const matches = raw.matchAll(
|
||||
/<script\b[^>]*type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi,
|
||||
);
|
||||
const entries = [];
|
||||
const document = new JSDOM(String(htmlText ?? '')).window.document;
|
||||
const scripts = [...document.querySelectorAll('script')].filter(
|
||||
(element) => (element.getAttribute('type') || '').trim().toLowerCase() === 'application/ld+json',
|
||||
);
|
||||
|
||||
for (const match of matches) {
|
||||
const text = match[1]?.trim();
|
||||
for (const script of scripts) {
|
||||
const text = script.textContent?.trim();
|
||||
if (!text) {
|
||||
continue;
|
||||
}
|
||||
@@ -320,30 +396,285 @@ function extractJsonLdEntries(htmlText) {
|
||||
return entries;
|
||||
}
|
||||
|
||||
function hasSchemaType(value, schemaType) {
|
||||
const declaredTypes = Array.isArray(value?.['@type']) ? value['@type'] : [value?.['@type']];
|
||||
return declaredTypes.some((declaredType) =>
|
||||
declaredType === schemaType ||
|
||||
declaredType === `schema:${schemaType}` ||
|
||||
declaredType === `https://schema.org/${schemaType}` ||
|
||||
declaredType === `http://schema.org/${schemaType}`,
|
||||
);
|
||||
}
|
||||
|
||||
function collectSchemaNodes(value, schemaType, output = []) {
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((entry) => collectSchemaNodes(entry, schemaType, output));
|
||||
return output;
|
||||
}
|
||||
if (!value || typeof value !== 'object') {
|
||||
return output;
|
||||
}
|
||||
if (hasSchemaType(value, schemaType)) {
|
||||
output.push(value);
|
||||
}
|
||||
Object.values(value).forEach((entry) => collectSchemaNodes(entry, schemaType, output));
|
||||
return output;
|
||||
}
|
||||
|
||||
function assertJsonLdTypes(htmlText, requiredTypes) {
|
||||
const entries = extractJsonLdEntries(htmlText);
|
||||
const types = new Set(entries.map((entry) => entry?.['@type']).filter(Boolean));
|
||||
|
||||
for (const requiredType of requiredTypes) {
|
||||
assert(types.has(requiredType), `JSON-LD missing required @type: ${requiredType}`);
|
||||
assert(entries.some((entry) => hasSchemaType(entry, requiredType)), `JSON-LD missing required @type: ${requiredType}`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertRepositoryJsonLdSignals(htmlText) {
|
||||
const entries = extractJsonLdEntries(htmlText);
|
||||
const repoUrl = 'https://github.com/sickn33/agentic-awesome-skills';
|
||||
const sourceCode = entries.find((entry) => entry?.['@type'] === 'SoftwareSourceCode');
|
||||
const organization = entries.find((entry) => entry?.['@type'] === 'Organization');
|
||||
const collectionPage = entries.find((entry) => entry?.['@type'] === 'CollectionPage');
|
||||
const sourceCode = entries.find((entry) => hasSchemaType(entry, 'SoftwareSourceCode'));
|
||||
const organization = entries.find((entry) => hasSchemaType(entry, 'Organization'));
|
||||
const collectionPage = entries.find((entry) => hasSchemaType(entry, 'CollectionPage'));
|
||||
|
||||
assert(sourceCode?.url === repoUrl, 'SoftwareSourceCode JSON-LD must use the GitHub repository as its URL.');
|
||||
assert(sourceCode?.codeRepository === repoUrl, 'SoftwareSourceCode JSON-LD must expose the GitHub repository.');
|
||||
assert(sourceCode?.url === REPOSITORY_URL, 'SoftwareSourceCode JSON-LD must use the GitHub repository as its URL.');
|
||||
assert(sourceCode?.codeRepository === REPOSITORY_URL, 'SoftwareSourceCode JSON-LD must expose the GitHub repository.');
|
||||
assert(
|
||||
typeof sourceCode?.mainEntityOfPage === 'string' && sourceCode.mainEntityOfPage.length > 0,
|
||||
'SoftwareSourceCode JSON-LD must link back to the hosted catalog page with mainEntityOfPage.',
|
||||
);
|
||||
assert(organization?.url === repoUrl, 'Organization JSON-LD must use the GitHub repository as its URL.');
|
||||
assert(collectionPage?.sameAs === repoUrl, 'CollectionPage JSON-LD must link the hosted catalog to the GitHub repository.');
|
||||
assert(organization?.url === REPOSITORY_URL, 'Organization JSON-LD must use the GitHub repository as its URL.');
|
||||
assert(collectionPage?.sameAs === REPOSITORY_URL, 'CollectionPage JSON-LD must link the hosted catalog to the GitHub repository.');
|
||||
}
|
||||
|
||||
function buildIdentityContext(rootUrl, normalizedRootPath) {
|
||||
const root = new URL(rootUrl);
|
||||
const rootPath = normalizedRootPath ? `${normalizedRootPath.replace(/\/+$/, '')}/` : '/';
|
||||
const catalogRootUrl = new URL(rootPath, root.origin).href;
|
||||
const catalogBaseUrl = catalogRootUrl.replace(/\/$/, '');
|
||||
return {
|
||||
catalogBaseUrl,
|
||||
catalogRootUrl,
|
||||
origin: root.origin,
|
||||
socialImageUrl: `${catalogBaseUrl}/social-card.png`,
|
||||
};
|
||||
}
|
||||
|
||||
function assertCurrentIdentityUrl(value, fieldName, identityContext) {
|
||||
if (typeof value !== 'string' || !/^https?:\/\//i.test(value)) {
|
||||
return;
|
||||
}
|
||||
|
||||
let parsed;
|
||||
try {
|
||||
parsed = new URL(value);
|
||||
} catch (_err) {
|
||||
throw new Error(`JSON-LD ${fieldName} must contain a valid URL: ${value}`);
|
||||
}
|
||||
|
||||
if (parsed.hostname === 'github.com' && parsed.pathname.startsWith('/sickn33/')) {
|
||||
assert(
|
||||
value === REPOSITORY_URL || value.startsWith(`${REPOSITORY_URL}/`) || value.startsWith(`${REPOSITORY_URL}#`),
|
||||
`JSON-LD ${fieldName} must not use a legacy first-party GitHub repository: ${value}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (parsed.hostname.endsWith('.github.io')) {
|
||||
const catalogPath = new URL(identityContext.catalogRootUrl).pathname.replace(/\/$/, '');
|
||||
assert(
|
||||
parsed.origin === identityContext.origin &&
|
||||
(parsed.pathname === catalogPath || parsed.pathname.startsWith(`${catalogPath}/`)),
|
||||
`JSON-LD ${fieldName} must not use a legacy first-party Pages catalog URL: ${value}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (parsed.hostname === 'npmjs.com' || parsed.hostname === 'www.npmjs.com') {
|
||||
assert(
|
||||
value === PACKAGE_URL,
|
||||
`JSON-LD ${fieldName} must not use a legacy first-party package URL: ${value}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function assertJsonLdIdentityUrls(htmlText, identityContext, routeUrl) {
|
||||
const entries = extractJsonLdEntries(htmlText);
|
||||
const identityFields = new Set(['@id', 'codeRepository', 'item', 'mainEntityOfPage', 'sameAs', 'target', 'url']);
|
||||
|
||||
function inspect(value, fieldName = '') {
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((entry) => inspect(entry, fieldName));
|
||||
return;
|
||||
}
|
||||
if (!value || typeof value !== 'object') {
|
||||
if (identityFields.has(fieldName)) {
|
||||
assertCurrentIdentityUrl(value, fieldName, identityContext);
|
||||
}
|
||||
return;
|
||||
}
|
||||
for (const [key, nestedValue] of Object.entries(value)) {
|
||||
inspect(nestedValue, key);
|
||||
}
|
||||
}
|
||||
|
||||
entries.forEach((entry) => inspect(entry));
|
||||
|
||||
const routePath = new URL(routeUrl).pathname;
|
||||
const rootPath = new URL(identityContext.catalogRootUrl).pathname;
|
||||
const relativeRoutePath = routePath.slice(rootPath.length).replace(/^\/+/, '');
|
||||
const requiresRichProjectIdentity = routePath === rootPath || relativeRoutePath.startsWith('topics/') || relativeRoutePath === 'workbench/';
|
||||
const requiresProjectOrganization = requiresRichProjectIdentity || relativeRoutePath === 'plugins/';
|
||||
const organizations = entries.filter((entry) => hasSchemaType(entry, 'Organization'));
|
||||
if (requiresProjectOrganization) {
|
||||
assert(organizations.length === 1, `${routeUrl} must expose exactly one project Organization.`);
|
||||
}
|
||||
const organizationNodes = collectSchemaNodes(entries, 'Organization');
|
||||
const allowedOrganizationIdentities = new Set([
|
||||
'https://x.com/AASkills_',
|
||||
PACKAGE_URL,
|
||||
identityContext.catalogRootUrl,
|
||||
]);
|
||||
for (const organization of organizationNodes) {
|
||||
if (organization.url === undefined && organization['@id'] === undefined) {
|
||||
continue;
|
||||
}
|
||||
assert(organization.url === REPOSITORY_URL, 'Project Organization JSON-LD must use the current repository URL.');
|
||||
assert(organization['@id'] === `${REPOSITORY_URL}#organization`, 'Project Organization JSON-LD must use the current repository @id.');
|
||||
if (organization.sameAs !== undefined) {
|
||||
const sameAs = Array.isArray(organization.sameAs) ? organization.sameAs : [organization.sameAs];
|
||||
assert(
|
||||
new Set(sameAs).size === sameAs.length && sameAs.every((value) => allowedOrganizationIdentities.has(value)),
|
||||
'Project Organization JSON-LD sameAs may contain only exact current project identities.',
|
||||
);
|
||||
}
|
||||
if (requiresRichProjectIdentity && organizations.includes(organization)) {
|
||||
const sameAs = Array.isArray(organization.sameAs) ? organization.sameAs : [organization.sameAs].filter(Boolean);
|
||||
assert(
|
||||
sameAs.length === allowedOrganizationIdentities.size &&
|
||||
[...allowedOrganizationIdentities].every((value) => sameAs.includes(value)),
|
||||
'Project Organization JSON-LD must expose exactly the current social, npm package, and catalog identities.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const sourceCodeEntries = entries.filter((entry) => hasSchemaType(entry, 'SoftwareSourceCode'));
|
||||
if (requiresRichProjectIdentity) {
|
||||
assert(sourceCodeEntries.length === 1, `${routeUrl} must expose exactly one project SoftwareSourceCode entity.`);
|
||||
}
|
||||
const expectedSourceIdentities = [...new Set([routeUrl, identityContext.catalogRootUrl, PACKAGE_URL])];
|
||||
for (const sourceCode of collectSchemaNodes(entries, 'SoftwareSourceCode')) {
|
||||
assert(sourceCode.url === REPOSITORY_URL, 'SoftwareSourceCode JSON-LD must use the current repository URL.');
|
||||
assert(sourceCode.codeRepository === REPOSITORY_URL, 'SoftwareSourceCode JSON-LD must use the current codeRepository URL.');
|
||||
if (sourceCode['@id'] !== undefined) {
|
||||
assert(
|
||||
sourceCode['@id'] === REPOSITORY_URL || sourceCode['@id'].startsWith(`${REPOSITORY_URL}#`),
|
||||
'SoftwareSourceCode JSON-LD @id must remain on the exact current repository identity.',
|
||||
);
|
||||
}
|
||||
assert(sourceCode.mainEntityOfPage === routeUrl, 'SoftwareSourceCode JSON-LD must bind mainEntityOfPage to the exact sitemap route.');
|
||||
const sameAs = Array.isArray(sourceCode.sameAs) ? sourceCode.sameAs : [];
|
||||
assert(
|
||||
sameAs.length === expectedSourceIdentities.length &&
|
||||
new Set(sameAs).size === sameAs.length &&
|
||||
expectedSourceIdentities.every((value) => sameAs.includes(value)),
|
||||
'SoftwareSourceCode JSON-LD sameAs must contain exactly the current route, catalog root, and npm package identities.',
|
||||
);
|
||||
}
|
||||
|
||||
const topLevelWebSites = entries.filter((entry) => hasSchemaType(entry, 'WebSite'));
|
||||
if (requiresRichProjectIdentity) {
|
||||
assert(topLevelWebSites.length === 1, `${routeUrl} must expose exactly one top-level project WebSite entity.`);
|
||||
}
|
||||
for (const webSite of collectSchemaNodes(entries, 'WebSite')) {
|
||||
assert(webSite.url === identityContext.catalogBaseUrl, 'WebSite JSON-LD must use the exact current catalog base URL.');
|
||||
if (webSite['@id'] !== undefined) {
|
||||
assert(
|
||||
webSite['@id'] === identityContext.catalogBaseUrl || webSite['@id'].startsWith(`${identityContext.catalogBaseUrl}#`),
|
||||
'WebSite JSON-LD @id must remain on the exact current catalog identity.',
|
||||
);
|
||||
}
|
||||
if (webSite.sameAs !== undefined) {
|
||||
assert(webSite.sameAs === REPOSITORY_URL, 'WebSite JSON-LD sameAs must use the exact current repository URL.');
|
||||
}
|
||||
if (webSite.potentialAction !== undefined) {
|
||||
assert(webSite.potentialAction?.['@type'] === 'SearchAction', 'WebSite JSON-LD potentialAction must be a SearchAction.');
|
||||
assert(
|
||||
webSite.potentialAction?.target === `${identityContext.catalogBaseUrl}/?q={search_term_string}`,
|
||||
'WebSite JSON-LD SearchAction target must remain under the exact current catalog root.',
|
||||
);
|
||||
}
|
||||
}
|
||||
if (requiresRichProjectIdentity) {
|
||||
assert(topLevelWebSites[0].sameAs === REPOSITORY_URL, 'Top-level WebSite JSON-LD must use the exact current repository identity.');
|
||||
assert(
|
||||
topLevelWebSites[0].potentialAction?.target === `${identityContext.catalogBaseUrl}/?q={search_term_string}`,
|
||||
'Top-level WebSite JSON-LD must expose the exact current catalog SearchAction.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function assertPrimaryRouteJsonLdIdentity(htmlText, routeUrl) {
|
||||
const entries = extractJsonLdEntries(htmlText);
|
||||
const applications = entries.filter((entry) => hasSchemaType(entry, 'SoftwareApplication'));
|
||||
const pageEntities = entries.filter((entry) =>
|
||||
entry && (hasSchemaType(entry, 'CollectionPage') || hasSchemaType(entry, 'WebPage')) && entry.url !== undefined,
|
||||
);
|
||||
assert(applications.length || pageEntities.length, `${routeUrl} must expose a primary route JSON-LD entity.`);
|
||||
if (applications.length) {
|
||||
assert(applications.length === 1, `${routeUrl} must expose exactly one SoftwareApplication route entity.`);
|
||||
assert(applications[0]['@id'] === routeUrl, `${routeUrl} SoftwareApplication @id must equal the sitemap route.`);
|
||||
}
|
||||
for (const primary of [...applications, ...pageEntities]) {
|
||||
assert(primary.url === routeUrl, `${routeUrl} primary JSON-LD url must equal the sitemap route.`);
|
||||
if (primary.mainEntityOfPage !== undefined) {
|
||||
const mainEntityUrl = typeof primary.mainEntityOfPage === 'string'
|
||||
? primary.mainEntityOfPage
|
||||
: primary.mainEntityOfPage?.['@id'];
|
||||
assert(mainEntityUrl === routeUrl, `${routeUrl} primary JSON-LD mainEntityOfPage must equal the sitemap route.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function assertExactMetaContent(htmlText, selectorType, selectorValue, expectedValue, routeUrl) {
|
||||
const values = extractExactMetaContents(htmlText, selectorType, selectorValue);
|
||||
assert(
|
||||
values.length === 1,
|
||||
`${routeUrl} must expose exactly one ${selectorType}="${selectorValue}" tag; got ${values.length}.`,
|
||||
);
|
||||
const actualValue = values[0];
|
||||
assert(
|
||||
actualValue === expectedValue,
|
||||
`${routeUrl} must set ${selectorType}="${selectorValue}" to exactly ${expectedValue}; got ${actualValue || 'missing'}.`,
|
||||
);
|
||||
}
|
||||
|
||||
export function assertPrerenderedRouteIdentities(routeUrls, distDir = 'dist', normalizedRootPath = '', rootUrl = '') {
|
||||
assert(routeUrls.length > 0, 'Cannot verify route identities without sitemap URLs.');
|
||||
assert(typeof rootUrl === 'string' && rootUrl, 'Route identity verification requires the explicit sitemap root URL.');
|
||||
const identityContext = buildIdentityContext(rootUrl, normalizedRootPath);
|
||||
const expectedRootPath = new URL(identityContext.catalogRootUrl).pathname;
|
||||
|
||||
for (const routeUrl of routeUrls) {
|
||||
const parsed = new URL(routeUrl);
|
||||
assert(parsed.pathname.endsWith('/'), `Indexable route must end with a trailing slash: ${routeUrl}`);
|
||||
assert(
|
||||
parsed.origin === identityContext.origin &&
|
||||
(parsed.pathname === expectedRootPath || parsed.pathname.startsWith(expectedRootPath)),
|
||||
`Sitemap route must remain within the explicit current catalog root: ${routeUrl}`,
|
||||
);
|
||||
const filePath = safeUserPath(routePathToDistFile(parsed.pathname, normalizedRootPath), distDir);
|
||||
assert(fs.existsSync(filePath), `Missing prerendered page for sitemap route: ${parsed.pathname}. Expected ${filePath}.`);
|
||||
const html = readFile(filePath, distDir);
|
||||
const canonicalHrefs = extractCanonicalHrefs(html);
|
||||
assert(canonicalHrefs.length === 1, `${routeUrl} must expose exactly one rel="canonical" link; got ${canonicalHrefs.length}.`);
|
||||
const canonical = canonicalHrefs[0];
|
||||
assert(
|
||||
canonical === routeUrl,
|
||||
`${routeUrl} must set rel="canonical" to exactly ${routeUrl}; got ${canonical || 'missing'}.`,
|
||||
);
|
||||
assertExactMetaContent(html, 'property', 'og:url', routeUrl, routeUrl);
|
||||
assertExactMetaContent(html, 'property', 'og:image', identityContext.socialImageUrl, routeUrl);
|
||||
assertExactMetaContent(html, 'name', 'twitter:image', identityContext.socialImageUrl, routeUrl);
|
||||
assertPrimaryRouteJsonLdIdentity(html, routeUrl);
|
||||
assertJsonLdIdentityUrls(html, identityContext, routeUrl);
|
||||
}
|
||||
}
|
||||
|
||||
export function assertIndexSocialMeta(htmlText) {
|
||||
@@ -352,6 +683,14 @@ export function assertIndexSocialMeta(htmlText) {
|
||||
assertMetaContent(htmlText, 'name', 'twitter:image:alt');
|
||||
}
|
||||
|
||||
export function assertWebmasterVerificationMeta(htmlText) {
|
||||
const bingVerificationToken = extractMetaContent(htmlText, 'name', 'msvalidate.01');
|
||||
assert(
|
||||
bingVerificationToken === 'CAC904EB0D2DD1B22B5F2BC540CAD654',
|
||||
'Index HTML must expose the current Bing Webmaster Tools verification token.',
|
||||
);
|
||||
}
|
||||
|
||||
function readSkillCountLabel(distDir) {
|
||||
try {
|
||||
const skills = JSON.parse(readFile(path.join(distDir, 'skills.json'), distDir));
|
||||
@@ -487,8 +826,11 @@ function assertStaticRelatedTopicLinks(htmlText, routeType) {
|
||||
function routePathToDistFile(routePath, normalizedRootPath) {
|
||||
const normalizedPath = (routePath || '/').replace(/\/+$/, '') || '/';
|
||||
const normalizedRoot = normalizedRootPath === '/' ? '' : String(normalizedRootPath || '').replace(/\/+$/, '');
|
||||
const withLeadingRoot = normalizedRoot ? `${normalizedRoot}/` : '';
|
||||
const trimmedRoute = normalizedPath.startsWith(withLeadingRoot) ? normalizedPath.slice(withLeadingRoot.length) || '/' : normalizedPath;
|
||||
const trimmedRoute = normalizedRoot && normalizedPath === normalizedRoot
|
||||
? '/'
|
||||
: normalizedRoot && normalizedPath.startsWith(`${normalizedRoot}/`)
|
||||
? normalizedPath.slice(normalizedRoot.length) || '/'
|
||||
: normalizedPath;
|
||||
const withoutLeadingSlash = trimmedRoute === '/' ? '' : trimmedRoute.replace(/^\//, '');
|
||||
const routeAsFilePath = withoutLeadingSlash ? `${withoutLeadingSlash}/index.html` : 'index.html';
|
||||
return routeAsFilePath;
|
||||
@@ -527,8 +869,8 @@ export function assertPrerenderedWorkbenchRoutes(workbenchUrls, distDir = 'dist'
|
||||
`Missing prerendered page for workbench route: ${parsed.pathname}. Expected ${filePath}.`,
|
||||
);
|
||||
const html = readFile(filePath, distDir);
|
||||
assert(extractTitle(html).includes('Skill Workbench'), 'Workbench prerender must expose its exact product title.');
|
||||
assert(extractMetaContent(html, 'name', 'description')?.includes('exact host-aware set'), 'Workbench prerender must describe exact composition.');
|
||||
assert(extractTitle(html).includes('Stack Review Workbench'), 'Workbench prerender must expose its exact review product title.');
|
||||
assert(extractMetaContent(html, 'name', 'description')?.includes('Imports stay in memory'), 'Workbench prerender must describe in-memory review.');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -546,16 +888,25 @@ export function assertPrerenderedTopicRoutes(topicUrls, distDir = 'dist', normal
|
||||
}
|
||||
}
|
||||
|
||||
export function assertRobots(robotsText) {
|
||||
export function assertRobots(robotsText, { expectedSitemapUrl = '' } = {}) {
|
||||
const lines = String(robotsText ?? '').split(/\r?\n/).map((line) => line.trim());
|
||||
const allowsRoot = lines.some((line) => line.startsWith('Allow: /'));
|
||||
const hasSitemap = lines.some((line) => /^Sitemap:\s*.+\/?sitemap\.xml$/i.test(line));
|
||||
const sitemapUrls = lines
|
||||
.map((line) => line.match(/^Sitemap:\s*(\S+)\s*$/i)?.[1])
|
||||
.filter(Boolean);
|
||||
const allowsAiSearchCrawlers = ['GPTBot', 'OAI-SearchBot', 'ClaudeBot', 'PerplexityBot'].every((crawler) =>
|
||||
lines.some((line) => line === `User-agent: ${crawler}`),
|
||||
);
|
||||
|
||||
assert(allowsRoot, 'robots.txt must allow root crawling.');
|
||||
assert(hasSitemap, 'robots.txt must expose sitemap location.');
|
||||
assert(sitemapUrls.length > 0, 'robots.txt must expose sitemap location.');
|
||||
if (expectedSitemapUrl) {
|
||||
assert(sitemapUrls.length === 1, 'robots.txt must expose exactly one sitemap location.');
|
||||
assert(
|
||||
sitemapUrls[0] === expectedSitemapUrl,
|
||||
`robots.txt must point to the current sitemap exactly: ${expectedSitemapUrl}; got ${sitemapUrls[0]}.`,
|
||||
);
|
||||
}
|
||||
assert(allowsAiSearchCrawlers, 'robots.txt must explicitly expose AI search crawler directives.');
|
||||
}
|
||||
|
||||
@@ -594,7 +945,13 @@ export function assertManifest(manifestText) {
|
||||
}
|
||||
|
||||
function readFile(filePath, baseDir = process.cwd()) {
|
||||
return fs.readFileSync(safeUserPath(filePath, baseDir), 'utf-8');
|
||||
const safePath = safeUserPath(filePath, baseDir);
|
||||
return fs.readFileSync(assertPlainFileInsideRoot(safePath, baseDir), 'utf-8');
|
||||
}
|
||||
|
||||
function readBinaryFile(filePath, baseDir = process.cwd()) {
|
||||
const safePath = safeUserPath(filePath, baseDir);
|
||||
return fs.readFileSync(assertPlainFileInsideRoot(safePath, baseDir));
|
||||
}
|
||||
|
||||
export function runVerification({
|
||||
@@ -627,11 +984,22 @@ export function runVerification({
|
||||
assertPrerenderedPluginRoutes(sitemapReport.pluginUrls, distDir, sitemapReport.normalizedRootPath);
|
||||
assertPrerenderedWorkbenchRoutes(sitemapReport.workbenchUrls, distDir, sitemapReport.normalizedRootPath);
|
||||
assertPrerenderedTopicRoutes(sitemapReport.topicUrls, distDir, sitemapReport.normalizedRootPath);
|
||||
assertPrerenderedRouteIdentities(
|
||||
sitemapReport.locations,
|
||||
distDir,
|
||||
sitemapReport.normalizedRootPath,
|
||||
sitemapReport.rootUrl,
|
||||
);
|
||||
assertIndexSocialMeta(indexHtml);
|
||||
assertIndexDiscoveryMeta(indexHtml, { expectedSkillCountLabel, requireHostedUrl });
|
||||
assertStaticIndexShell(readFile(sourceIndexPath), { expectedSkillCountLabel, requireHostedUrl });
|
||||
assertSocialCard(fs.readFileSync(socialImagePath), { expectedSkillCountLabel });
|
||||
assertRobots(readFile(robotsPath));
|
||||
assertWebmasterVerificationMeta(indexHtml);
|
||||
const sourceIndexHtml = readFile(sourceIndexPath);
|
||||
assertStaticIndexShell(sourceIndexHtml, { expectedSkillCountLabel, requireHostedUrl });
|
||||
assertWebmasterVerificationMeta(sourceIndexHtml);
|
||||
assertSocialCard(readBinaryFile(socialImagePath), { expectedSkillCountLabel });
|
||||
assertRobots(readFile(robotsPath), {
|
||||
expectedSitemapUrl: new URL('sitemap.xml', sitemapReport.rootUrl).href,
|
||||
});
|
||||
assertLlms(readFile(llmsPath), { expectedSkillCountLabel, expectedReleaseLabel });
|
||||
assertManifest(readFile(manifestPath));
|
||||
if (requireHostedUrl) {
|
||||
|
||||
@@ -6,9 +6,11 @@ import {
|
||||
assertManifest,
|
||||
assertIndexDiscoveryMeta,
|
||||
assertStaticIndexShell,
|
||||
assertWebmasterVerificationMeta,
|
||||
assertPluginsDiscoveryMeta,
|
||||
analyzeSitemap,
|
||||
assertPrerenderedPluginRoutes,
|
||||
assertPrerenderedRouteIdentities,
|
||||
assertPrerenderedSkillRoutes,
|
||||
assertPrerenderedTopicRoutes,
|
||||
assertPrerenderedWorkbenchRoutes,
|
||||
@@ -20,6 +22,95 @@ import {
|
||||
extractSitemapLocations,
|
||||
} from './verify-seo-assets.js';
|
||||
|
||||
const FIXTURE_ROOT_URL = 'https://owner.github.io/repo/';
|
||||
const FIXTURE_SOCIAL_IMAGE_URL = 'https://owner.github.io/repo/social-card.png';
|
||||
const PACKAGE_URL = 'https://www.npmjs.com/package/agentic-awesome-skills';
|
||||
|
||||
describe('Bing Webmaster Tools verification metadata', () => {
|
||||
it('requires the current verification token', () => {
|
||||
const html = '<meta name="msvalidate.01" content="CAC904EB0D2DD1B22B5F2BC540CAD654" />';
|
||||
|
||||
expect(() => assertWebmasterVerificationMeta(html)).not.toThrow();
|
||||
expect(() => assertWebmasterVerificationMeta('<meta name="msvalidate.01" content="stale" />'))
|
||||
.toThrow('current Bing Webmaster Tools verification token');
|
||||
});
|
||||
});
|
||||
|
||||
function buildRouteIdentityHtml({
|
||||
routeUrl,
|
||||
canonicalUrl = routeUrl,
|
||||
ogUrl = routeUrl,
|
||||
socialImageUrl = FIXTURE_SOCIAL_IMAGE_URL,
|
||||
jsonLd = [],
|
||||
} = {}) {
|
||||
return `<html><head>
|
||||
<link rel="canonical" href="${canonicalUrl}" />
|
||||
<meta property="og:url" content="${ogUrl}" />
|
||||
<meta property="og:image" content="${socialImageUrl}" />
|
||||
<meta name="twitter:image" content="${socialImageUrl}" />
|
||||
<script type="application/ld+json">${JSON.stringify(jsonLd)}</script>
|
||||
</head></html>`;
|
||||
}
|
||||
|
||||
function writeRouteIdentityFixture(distDir, routeUrl, html) {
|
||||
const routePath = new URL(routeUrl).pathname.replace(/^\/repo\/?/, '');
|
||||
const filePath = path.join(distDir, routePath || '.', 'index.html');
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, html);
|
||||
}
|
||||
|
||||
function currentIdentityJsonLd(routeUrl) {
|
||||
const entries = [
|
||||
{
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'WebPage',
|
||||
url: routeUrl,
|
||||
mainEntityOfPage: routeUrl,
|
||||
},
|
||||
];
|
||||
const relativeRoute = new URL(routeUrl).pathname.replace(new URL(FIXTURE_ROOT_URL).pathname, '');
|
||||
if (routeUrl === FIXTURE_ROOT_URL || relativeRoute.startsWith('topics/')) {
|
||||
const sourceCode = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'SoftwareSourceCode',
|
||||
url: 'https://github.com/sickn33/agentic-awesome-skills',
|
||||
codeRepository: 'https://github.com/sickn33/agentic-awesome-skills',
|
||||
mainEntityOfPage: routeUrl,
|
||||
sameAs: [...new Set([routeUrl, FIXTURE_ROOT_URL, 'https://www.npmjs.com/package/agentic-awesome-skills'])],
|
||||
};
|
||||
entries.push({
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Organization',
|
||||
'@id': 'https://github.com/sickn33/agentic-awesome-skills#organization',
|
||||
name: 'Agentic Awesome Skills',
|
||||
url: 'https://github.com/sickn33/agentic-awesome-skills',
|
||||
sameAs: [
|
||||
'https://x.com/AASkills_',
|
||||
'https://www.npmjs.com/package/agentic-awesome-skills',
|
||||
FIXTURE_ROOT_URL,
|
||||
],
|
||||
}, {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'WebSite',
|
||||
url: FIXTURE_ROOT_URL.replace(/\/$/, ''),
|
||||
sameAs: 'https://github.com/sickn33/agentic-awesome-skills',
|
||||
potentialAction: {
|
||||
'@type': 'SearchAction',
|
||||
target: `${FIXTURE_ROOT_URL.replace(/\/$/, '')}/?q={search_term_string}`,
|
||||
},
|
||||
}, sourceCode);
|
||||
} else if (relativeRoute === 'plugins/') {
|
||||
entries.push({
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Organization',
|
||||
'@id': 'https://github.com/sickn33/agentic-awesome-skills#organization',
|
||||
name: 'Agentic Awesome Skills',
|
||||
url: 'https://github.com/sickn33/agentic-awesome-skills',
|
||||
});
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
describe('seo assets verification helpers', () => {
|
||||
it('extracts sitemap location values in declaration order', () => {
|
||||
const xml = `
|
||||
@@ -51,6 +142,34 @@ describe('seo assets verification helpers', () => {
|
||||
expect(() => assertSitemap(xml, { minSkillUrls: 2 })).not.toThrow();
|
||||
});
|
||||
|
||||
it('rejects sitemap routes that switch away from the homepage origin', () => {
|
||||
const xml = `
|
||||
<urlset>
|
||||
<url><loc>${FIXTURE_ROOT_URL}</loc></url>
|
||||
<url><loc>https://evil.example/repo/skill/agent-a/</loc></url>
|
||||
</urlset>
|
||||
`;
|
||||
|
||||
expect(() => analyzeSitemap(xml)).toThrow('share the homepage origin');
|
||||
});
|
||||
|
||||
it('uses the explicit sitemap root when the homepage is not the first route', () => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'seo-assets-'));
|
||||
const distDir = path.join(tmpDir, 'dist');
|
||||
const routes = [`${FIXTURE_ROOT_URL}skill/agent-a/`, FIXTURE_ROOT_URL];
|
||||
const report = analyzeSitemap(`<urlset>${routes.map((url) => `<url><loc>${url}</loc></url>`).join('')}</urlset>`);
|
||||
for (const routeUrl of routes) {
|
||||
writeRouteIdentityFixture(
|
||||
distDir,
|
||||
routeUrl,
|
||||
buildRouteIdentityHtml({ routeUrl, jsonLd: currentIdentityJsonLd(routeUrl) }),
|
||||
);
|
||||
}
|
||||
|
||||
expect(report.rootUrl).toBe(FIXTURE_ROOT_URL);
|
||||
expect(() => assertPrerenderedRouteIdentities(routes, distDir, '/repo', report.rootUrl)).not.toThrow();
|
||||
});
|
||||
|
||||
it('throws when sitemap has duplicated URLs', () => {
|
||||
const xml = `
|
||||
<urlset>
|
||||
@@ -73,6 +192,25 @@ describe('seo assets verification helpers', () => {
|
||||
expect(() => assertSitemap(xml, { requireHostedUrl: true })).toThrow('localhost');
|
||||
});
|
||||
|
||||
it('rejects a self-consistent legacy hosted catalog root', () => {
|
||||
const legacyRoot = 'https://sickn33.github.io/legacy-catalog/';
|
||||
const xml = `<urlset>
|
||||
<url><loc>${legacyRoot}</loc></url>
|
||||
<url><loc>${legacyRoot}skill/agent-a/</loc></url>
|
||||
</urlset>`;
|
||||
|
||||
expect(() => assertSitemap(xml, { requireHostedUrl: true })).toThrow('Hosted sitemap root');
|
||||
});
|
||||
|
||||
it('rejects slashless sitemap routes', () => {
|
||||
const xml = `<urlset>
|
||||
<url><loc>${FIXTURE_ROOT_URL}</loc></url>
|
||||
<url><loc>${FIXTURE_ROOT_URL}skill/agent-a</loc></url>
|
||||
</urlset>`;
|
||||
|
||||
expect(() => assertSitemap(xml)).toThrow('trailing slash');
|
||||
});
|
||||
|
||||
it('requires robots directives', () => {
|
||||
const robots = `
|
||||
User-agent: *
|
||||
@@ -91,6 +229,408 @@ describe('seo assets verification helpers', () => {
|
||||
expect(() => assertRobots(robots)).not.toThrow();
|
||||
});
|
||||
|
||||
it('requires robots.txt to point exactly to the current sitemap', () => {
|
||||
const robots = `
|
||||
User-agent: *
|
||||
Allow: /
|
||||
User-agent: GPTBot
|
||||
User-agent: OAI-SearchBot
|
||||
User-agent: ClaudeBot
|
||||
User-agent: PerplexityBot
|
||||
Sitemap: https://owner.github.io/legacy-repo/sitemap.xml
|
||||
`;
|
||||
|
||||
expect(() => assertRobots(robots, { expectedSitemapUrl: `${FIXTURE_ROOT_URL}sitemap.xml` })).toThrow(
|
||||
'current sitemap exactly',
|
||||
);
|
||||
});
|
||||
|
||||
it('requires exact canonical and og:url values for every sitemap route', () => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'seo-assets-'));
|
||||
const distDir = path.join(tmpDir, 'dist');
|
||||
const routeUrl = `${FIXTURE_ROOT_URL}skill/agent-a/`;
|
||||
writeRouteIdentityFixture(
|
||||
distDir,
|
||||
routeUrl,
|
||||
buildRouteIdentityHtml({
|
||||
routeUrl,
|
||||
canonicalUrl: `${FIXTURE_ROOT_URL}skill/legacy-agent-a/`,
|
||||
jsonLd: currentIdentityJsonLd(routeUrl),
|
||||
}),
|
||||
);
|
||||
expect(() => assertPrerenderedRouteIdentities([routeUrl], distDir, '/repo', FIXTURE_ROOT_URL)).toThrow('rel="canonical"');
|
||||
|
||||
writeRouteIdentityFixture(
|
||||
distDir,
|
||||
routeUrl,
|
||||
buildRouteIdentityHtml({
|
||||
routeUrl,
|
||||
ogUrl: `${FIXTURE_ROOT_URL}skill/legacy-agent-a/`,
|
||||
jsonLd: currentIdentityJsonLd(routeUrl),
|
||||
}),
|
||||
);
|
||||
expect(() => assertPrerenderedRouteIdentities([routeUrl], distDir, '/repo', FIXTURE_ROOT_URL)).toThrow('og:url');
|
||||
});
|
||||
|
||||
it('requires exact current social-card URLs for og and Twitter images', () => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'seo-assets-'));
|
||||
const distDir = path.join(tmpDir, 'dist');
|
||||
const routeUrl = `${FIXTURE_ROOT_URL}skill/agent-a/`;
|
||||
writeRouteIdentityFixture(
|
||||
distDir,
|
||||
routeUrl,
|
||||
buildRouteIdentityHtml({
|
||||
routeUrl,
|
||||
socialImageUrl: `${FIXTURE_ROOT_URL}social-card-legacy.png`,
|
||||
jsonLd: currentIdentityJsonLd(routeUrl),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(() => assertPrerenderedRouteIdentities([routeUrl], distDir, '/repo', FIXTURE_ROOT_URL)).toThrow('og:image');
|
||||
});
|
||||
|
||||
it('rejects legacy repository and catalog URLs only in JSON-LD identity fields', () => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'seo-assets-'));
|
||||
const distDir = path.join(tmpDir, 'dist');
|
||||
const routeUrl = `${FIXTURE_ROOT_URL}skill/agent-a/`;
|
||||
writeRouteIdentityFixture(
|
||||
distDir,
|
||||
routeUrl,
|
||||
buildRouteIdentityHtml({
|
||||
routeUrl,
|
||||
jsonLd: [
|
||||
...currentIdentityJsonLd(routeUrl),
|
||||
{ '@type': 'Thing', url: 'https://github.com/sickn33/legacy-awesome-skills' },
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(() => assertPrerenderedRouteIdentities([routeUrl], distDir, '/repo', FIXTURE_ROOT_URL)).toThrow('legacy first-party GitHub');
|
||||
|
||||
writeRouteIdentityFixture(
|
||||
distDir,
|
||||
routeUrl,
|
||||
buildRouteIdentityHtml({
|
||||
routeUrl,
|
||||
jsonLd: [
|
||||
...currentIdentityJsonLd(routeUrl),
|
||||
{ '@type': 'Thing', url: 'https://owner.github.io/legacy-repo/' },
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(() => assertPrerenderedRouteIdentities([routeUrl], distDir, '/repo', FIXTURE_ROOT_URL)).toThrow('legacy first-party Pages');
|
||||
});
|
||||
|
||||
it('requires the primary route JSON-LD identity to equal the sitemap route', () => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'seo-assets-'));
|
||||
const distDir = path.join(tmpDir, 'dist');
|
||||
const routeUrl = `${FIXTURE_ROOT_URL}skill/agent-a/`;
|
||||
const wrongRoute = `${FIXTURE_ROOT_URL}skill/wrong/`;
|
||||
writeRouteIdentityFixture(
|
||||
distDir,
|
||||
routeUrl,
|
||||
buildRouteIdentityHtml({
|
||||
routeUrl,
|
||||
jsonLd: [{
|
||||
'@type': 'SoftwareApplication',
|
||||
'@id': wrongRoute,
|
||||
url: wrongRoute,
|
||||
mainEntityOfPage: wrongRoute,
|
||||
}],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(() => assertPrerenderedRouteIdentities([routeUrl], distDir, '/repo', FIXTURE_ROOT_URL)).toThrow(
|
||||
'SoftwareApplication @id',
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects duplicate SoftwareApplication route identities', () => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'seo-assets-'));
|
||||
const distDir = path.join(tmpDir, 'dist');
|
||||
const routeUrl = `${FIXTURE_ROOT_URL}skill/agent-a/`;
|
||||
const application = {
|
||||
'@type': 'SoftwareApplication',
|
||||
'@id': routeUrl,
|
||||
url: routeUrl,
|
||||
mainEntityOfPage: routeUrl,
|
||||
};
|
||||
writeRouteIdentityFixture(
|
||||
distDir,
|
||||
routeUrl,
|
||||
buildRouteIdentityHtml({ routeUrl, jsonLd: [application, { ...application }] }),
|
||||
);
|
||||
|
||||
expect(() => assertPrerenderedRouteIdentities([routeUrl], distDir, '/repo', FIXTURE_ROOT_URL)).toThrow(
|
||||
'exactly one SoftwareApplication',
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects a wrong-owner repository and a missing npm identity', () => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'seo-assets-'));
|
||||
const distDir = path.join(tmpDir, 'dist');
|
||||
const routeUrl = FIXTURE_ROOT_URL;
|
||||
const wrongRepository = currentIdentityJsonLd(routeUrl).map((entry) =>
|
||||
entry['@type'] === 'SoftwareSourceCode'
|
||||
? { ...entry, url: 'https://github.com/other-owner/agentic-awesome-skills', codeRepository: 'https://github.com/other-owner/agentic-awesome-skills' }
|
||||
: entry,
|
||||
);
|
||||
writeRouteIdentityFixture(distDir, routeUrl, buildRouteIdentityHtml({ routeUrl, jsonLd: wrongRepository }));
|
||||
expect(() => assertPrerenderedRouteIdentities([routeUrl], distDir, '/repo', FIXTURE_ROOT_URL)).toThrow(
|
||||
'current repository URL',
|
||||
);
|
||||
|
||||
const missingPackage = currentIdentityJsonLd(routeUrl).map((entry) => {
|
||||
if (!['Organization', 'SoftwareSourceCode'].includes(entry['@type'])) return entry;
|
||||
return { ...entry, sameAs: (entry.sameAs || []).filter((value) => value !== PACKAGE_URL) };
|
||||
});
|
||||
writeRouteIdentityFixture(distDir, routeUrl, buildRouteIdentityHtml({ routeUrl, jsonLd: missingPackage }));
|
||||
expect(() => assertPrerenderedRouteIdentities([routeUrl], distDir, '/repo', FIXTURE_ROOT_URL)).toThrow(
|
||||
'exactly the current social, npm package, and catalog identities',
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects drifted WebSite and nested SoftwareSourceCode identities', () => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'seo-assets-'));
|
||||
const distDir = path.join(tmpDir, 'dist');
|
||||
const routeUrl = `${FIXTURE_ROOT_URL}topics/antigravity-cli-skills/`;
|
||||
const wrongWebSite = currentIdentityJsonLd(routeUrl).map((entry) =>
|
||||
entry['@type'] === 'WebSite'
|
||||
? { ...entry, url: `${FIXTURE_ROOT_URL}topics/wrong/`, sameAs: 'https://github.com/other-owner/legacy-agentic-awesome-skills' }
|
||||
: entry,
|
||||
);
|
||||
writeRouteIdentityFixture(distDir, routeUrl, buildRouteIdentityHtml({ routeUrl, jsonLd: wrongWebSite }));
|
||||
expect(() => assertPrerenderedRouteIdentities([routeUrl], distDir, '/repo', FIXTURE_ROOT_URL)).toThrow(
|
||||
'WebSite JSON-LD must use the exact current catalog base URL',
|
||||
);
|
||||
|
||||
const nestedWrongSource = {
|
||||
'@type': 'SoftwareSourceCode',
|
||||
url: 'https://github.com/other-owner/legacy-agentic-awesome-skills',
|
||||
codeRepository: 'https://github.com/other-owner/legacy-agentic-awesome-skills',
|
||||
mainEntityOfPage: `${FIXTURE_ROOT_URL}topics/wrong/`,
|
||||
sameAs: [FIXTURE_ROOT_URL, 'https://www.npmjs.com/package/agentic-awesome-skills'],
|
||||
};
|
||||
const wrongNestedIdentity = currentIdentityJsonLd(routeUrl).map((entry) =>
|
||||
entry['@type'] === 'WebPage' ? { ...entry, about: nestedWrongSource } : entry,
|
||||
);
|
||||
writeRouteIdentityFixture(distDir, routeUrl, buildRouteIdentityHtml({ routeUrl, jsonLd: wrongNestedIdentity }));
|
||||
expect(() => assertPrerenderedRouteIdentities([routeUrl], distDir, '/repo', FIXTURE_ROOT_URL)).toThrow(
|
||||
'current repository URL',
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects extra or nested wrong-owner schema identity fields', () => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'seo-assets-'));
|
||||
const distDir = path.join(tmpDir, 'dist');
|
||||
const routeUrl = `${FIXTURE_ROOT_URL}topics/antigravity-cli-skills/`;
|
||||
const otherRepository = 'https://github.com/other-owner/legacy-agentic-awesome-skills';
|
||||
const cases = [
|
||||
currentIdentityJsonLd(routeUrl).map((entry) => entry['@type'] === 'Organization'
|
||||
? { ...entry, sameAs: [...entry.sameAs, otherRepository] }
|
||||
: entry),
|
||||
currentIdentityJsonLd(routeUrl).map((entry) => entry['@type'] === 'WebPage'
|
||||
? {
|
||||
...entry,
|
||||
author: {
|
||||
'@type': 'Organization',
|
||||
'@id': 'https://github.com/sickn33/agentic-awesome-skills#organization',
|
||||
url: 'https://github.com/sickn33/agentic-awesome-skills',
|
||||
sameAs: [otherRepository],
|
||||
},
|
||||
}
|
||||
: entry),
|
||||
currentIdentityJsonLd(routeUrl).map((entry) => entry['@type'] === 'SoftwareSourceCode'
|
||||
? { ...entry, '@id': `${otherRepository}#source` }
|
||||
: entry),
|
||||
currentIdentityJsonLd(routeUrl).map((entry) => entry['@type'] === 'WebSite'
|
||||
? { ...entry, '@id': `${otherRepository}#website` }
|
||||
: entry),
|
||||
];
|
||||
const expectedMessages = [
|
||||
'sameAs may contain only exact current project identities',
|
||||
'sameAs may contain only exact current project identities',
|
||||
'SoftwareSourceCode JSON-LD @id',
|
||||
'WebSite JSON-LD @id',
|
||||
];
|
||||
cases.forEach((jsonLd, index) => {
|
||||
writeRouteIdentityFixture(distDir, routeUrl, buildRouteIdentityHtml({ routeUrl, jsonLd }));
|
||||
expect(() => assertPrerenderedRouteIdentities([routeUrl], distDir, '/repo', FIXTURE_ROOT_URL)).toThrow(
|
||||
expectedMessages[index],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects wrong-owner project nodes declared with expanded Schema.org type IRIs', () => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'seo-assets-'));
|
||||
const distDir = path.join(tmpDir, 'dist');
|
||||
const routeUrl = `${FIXTURE_ROOT_URL}topics/antigravity-cli-skills/`;
|
||||
const otherRepository = 'https://github.com/other-owner/legacy-agentic-awesome-skills';
|
||||
const expandedNodes = [
|
||||
{
|
||||
'@type': 'https://schema.org/Organization',
|
||||
'@id': `${otherRepository}#organization`,
|
||||
url: otherRepository,
|
||||
sameAs: [otherRepository],
|
||||
},
|
||||
{
|
||||
'@type': 'http://schema.org/SoftwareSourceCode',
|
||||
'@id': `${otherRepository}#source`,
|
||||
url: otherRepository,
|
||||
codeRepository: otherRepository,
|
||||
mainEntityOfPage: `${FIXTURE_ROOT_URL}topics/wrong/`,
|
||||
sameAs: [otherRepository],
|
||||
},
|
||||
{
|
||||
'@type': 'schema:WebSite',
|
||||
'@id': `${otherRepository}#website`,
|
||||
url: `${FIXTURE_ROOT_URL}topics/wrong/`,
|
||||
sameAs: otherRepository,
|
||||
},
|
||||
];
|
||||
const expectedMessages = [
|
||||
'current repository URL',
|
||||
'current repository URL',
|
||||
'exact current catalog base URL',
|
||||
];
|
||||
expandedNodes.forEach((about, index) => {
|
||||
const jsonLd = currentIdentityJsonLd(routeUrl).map((entry) =>
|
||||
entry['@type'] === 'WebPage' ? { ...entry, about } : entry,
|
||||
);
|
||||
writeRouteIdentityFixture(distDir, routeUrl, buildRouteIdentityHtml({ routeUrl, jsonLd }));
|
||||
expect(() => assertPrerenderedRouteIdentities([routeUrl], distDir, '/repo', FIXTURE_ROOT_URL)).toThrow(
|
||||
expectedMessages[index],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects prerendered route files reached through a symlink', () => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'seo-assets-'));
|
||||
const distDir = path.join(tmpDir, 'dist');
|
||||
const externalRouteDir = path.join(tmpDir, 'external-route');
|
||||
const routeUrl = `${FIXTURE_ROOT_URL}skill/agent-a/`;
|
||||
fs.mkdirSync(path.join(distDir, 'skill'), { recursive: true });
|
||||
fs.mkdirSync(externalRouteDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(externalRouteDir, 'index.html'),
|
||||
buildRouteIdentityHtml({ routeUrl, jsonLd: currentIdentityJsonLd(routeUrl) }),
|
||||
);
|
||||
try {
|
||||
fs.symlinkSync(externalRouteDir, path.join(distDir, 'skill', 'agent-a'), 'dir');
|
||||
} catch (error) {
|
||||
if (['EPERM', 'EACCES', 'ENOTSUP'].includes(error?.code)) return;
|
||||
throw error;
|
||||
}
|
||||
|
||||
expect(() => assertPrerenderedRouteIdentities([routeUrl], distDir, '/repo', FIXTURE_ROOT_URL)).toThrow('symlinks');
|
||||
});
|
||||
|
||||
it('rejects a verification root beneath a symlinked ancestor', () => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'seo-assets-'));
|
||||
const physicalParent = path.join(tmpDir, 'physical');
|
||||
const logicalParent = path.join(tmpDir, 'logical');
|
||||
const physicalDist = path.join(physicalParent, 'dist');
|
||||
const routeUrl = `${FIXTURE_ROOT_URL}skill/agent-a/`;
|
||||
writeRouteIdentityFixture(
|
||||
physicalDist,
|
||||
routeUrl,
|
||||
buildRouteIdentityHtml({ routeUrl, jsonLd: currentIdentityJsonLd(routeUrl) }),
|
||||
);
|
||||
try {
|
||||
fs.symlinkSync(physicalParent, logicalParent, 'dir');
|
||||
} catch (error) {
|
||||
if (['EPERM', 'EACCES', 'ENOTSUP'].includes(error?.code)) return;
|
||||
throw error;
|
||||
}
|
||||
|
||||
expect(() => assertPrerenderedRouteIdentities(
|
||||
[routeUrl],
|
||||
path.join(logicalParent, 'dist'),
|
||||
'/repo',
|
||||
FIXTURE_ROOT_URL,
|
||||
)).toThrow('symlinks');
|
||||
});
|
||||
|
||||
it('rejects duplicate canonical and route identity meta tags', () => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'seo-assets-'));
|
||||
const distDir = path.join(tmpDir, 'dist');
|
||||
const routeUrl = `${FIXTURE_ROOT_URL}skill/agent-a/`;
|
||||
const baseHtml = buildRouteIdentityHtml({ routeUrl, jsonLd: currentIdentityJsonLd(routeUrl) });
|
||||
const cases = [
|
||||
['<link rel="canonical" href="https://legacy.example/">', 'exactly one rel="canonical"'],
|
||||
['<meta property="og:url" content="https://legacy.example/">', 'exactly one property="og:url"'],
|
||||
['<meta property="og:image" content="https://legacy.example/social.png">', 'exactly one property="og:image"'],
|
||||
['<meta name="twitter:image" content="https://legacy.example/social.png">', 'exactly one name="twitter:image"'],
|
||||
];
|
||||
for (const [duplicateTag, expectedMessage] of cases) {
|
||||
writeRouteIdentityFixture(distDir, routeUrl, baseHtml.replace('</head>', `${duplicateTag}</head>`));
|
||||
expect(() => assertPrerenderedRouteIdentities([routeUrl], distDir, '/repo', FIXTURE_ROOT_URL)).toThrow(expectedMessage);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects duplicate identity tags with browser-valid alternate attribute syntax', () => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'seo-assets-'));
|
||||
const distDir = path.join(tmpDir, 'dist');
|
||||
const routeUrl = `${FIXTURE_ROOT_URL}skill/agent-a/`;
|
||||
const baseHtml = buildRouteIdentityHtml({ routeUrl, jsonLd: currentIdentityJsonLd(routeUrl) });
|
||||
const cases = [
|
||||
['<link rel=canonical href=https://legacy.example/>', 'exactly one rel="canonical"'],
|
||||
['<link rel="Canonical" href="https://legacy.example/">', 'exactly one rel="canonical"'],
|
||||
['<meta property=og:url content=https://legacy.example/>', 'exactly one property="og:url"'],
|
||||
['<meta name = "twitter:image" content = "https://legacy.example/social.png">', 'exactly one name="twitter:image"'],
|
||||
];
|
||||
for (const [duplicateTag, expectedMessage] of cases) {
|
||||
writeRouteIdentityFixture(distDir, routeUrl, baseHtml.replace('</head>', `${duplicateTag}</head>`));
|
||||
expect(() => assertPrerenderedRouteIdentities([routeUrl], distDir, '/repo', FIXTURE_ROOT_URL)).toThrow(expectedMessage);
|
||||
}
|
||||
});
|
||||
|
||||
it('detects duplicate primary JSON-LD with unquoted or spaced type attributes', () => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'seo-assets-'));
|
||||
const distDir = path.join(tmpDir, 'dist');
|
||||
const routeUrl = `${FIXTURE_ROOT_URL}skill/agent-a/`;
|
||||
const application = {
|
||||
'@type': 'SoftwareApplication',
|
||||
'@id': routeUrl,
|
||||
url: routeUrl,
|
||||
mainEntityOfPage: routeUrl,
|
||||
};
|
||||
const alternateScripts = [
|
||||
`<script type=application/ld+json>${JSON.stringify(application)}</script>`,
|
||||
`<script type = "application/ld+json">${JSON.stringify(application)}</script>`,
|
||||
];
|
||||
for (const alternateScript of alternateScripts) {
|
||||
const baseHtml = buildRouteIdentityHtml({ routeUrl, jsonLd: [application] });
|
||||
writeRouteIdentityFixture(distDir, routeUrl, baseHtml.replace('</head>', `${alternateScript}</head>`));
|
||||
expect(() => assertPrerenderedRouteIdentities([routeUrl], distDir, '/repo', FIXTURE_ROOT_URL)).toThrow(
|
||||
'exactly one SoftwareApplication',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('accepts exact current identities for all generated sitemap routes without policing provenance text', () => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'seo-assets-'));
|
||||
const distDir = path.join(tmpDir, 'dist');
|
||||
const routes = [FIXTURE_ROOT_URL, `${FIXTURE_ROOT_URL}skill/agent-a/`];
|
||||
for (const routeUrl of routes) {
|
||||
writeRouteIdentityFixture(
|
||||
distDir,
|
||||
routeUrl,
|
||||
buildRouteIdentityHtml({
|
||||
routeUrl,
|
||||
jsonLd: [
|
||||
...currentIdentityJsonLd(routeUrl),
|
||||
{
|
||||
'@type': 'WebPage',
|
||||
description: 'Compatibility note: migrated from https://owner.github.io/legacy-repo/.',
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
expect(() => assertPrerenderedRouteIdentities(routes, distDir, '/repo', FIXTURE_ROOT_URL)).not.toThrow();
|
||||
});
|
||||
|
||||
it('requires llms.txt discovery signals', () => {
|
||||
const llms = `
|
||||
# Agentic Awesome Skills
|
||||
@@ -287,7 +827,7 @@ describe('seo assets verification helpers', () => {
|
||||
const xml = `
|
||||
<urlset>
|
||||
<url><loc>https://owner.github.io/repo/</loc></url>
|
||||
<url><loc>https://owner.github.io/repo/skill/agent-a</loc></url>
|
||||
<url><loc>https://owner.github.io/repo/skill/agent-a/</loc></url>
|
||||
</urlset>
|
||||
`;
|
||||
|
||||
@@ -308,7 +848,7 @@ describe('seo assets verification helpers', () => {
|
||||
const xml = `
|
||||
<urlset>
|
||||
<url><loc>https://owner.github.io/repo/</loc></url>
|
||||
<url><loc>https://owner.github.io/repo/plugins</loc></url>
|
||||
<url><loc>https://owner.github.io/repo/plugins/</loc></url>
|
||||
</urlset>
|
||||
`;
|
||||
|
||||
@@ -316,14 +856,14 @@ describe('seo assets verification helpers', () => {
|
||||
expect(() => assertPrerenderedPluginRoutes(report.pluginUrls, distDir, report.normalizedRootPath)).not.toThrow();
|
||||
});
|
||||
|
||||
it('validates the prerendered workbench route and its exact-composition promise', () => {
|
||||
it('validates the prerendered workbench route and its in-memory review promise', () => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'seo-assets-'));
|
||||
const distDir = path.join(tmpDir, 'dist');
|
||||
const routeFile = path.join(distDir, 'workbench', 'index.html');
|
||||
fs.mkdirSync(path.dirname(routeFile), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
routeFile,
|
||||
'<html><head><title>Skill Workbench | Compose an exact agent stack</title><meta name="description" content="Filter, inspect, and install an exact host-aware set of skills." /></head></html>',
|
||||
'<html><head><title>Stack Review Workbench | Agentic Awesome Skills</title><meta name="description" content="Review an AAS stack and plan. Imports stay in memory and cannot install or apply changes." /></head></html>',
|
||||
);
|
||||
|
||||
const xml = `
|
||||
@@ -349,7 +889,7 @@ describe('seo assets verification helpers', () => {
|
||||
const xml = `
|
||||
<urlset>
|
||||
<url><loc>https://owner.github.io/repo/</loc></url>
|
||||
<url><loc>https://owner.github.io/repo/skill/agent-a</loc></url>
|
||||
<url><loc>https://owner.github.io/repo/skill/agent-a/</loc></url>
|
||||
</urlset>
|
||||
`;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user