fix(repo): Harden catalog sync and release integrity
Tighten the repo-state automation so canonical bot commits remain predictable while leaving main clean after each sync. Make the public catalog UI more honest by hiding dev-only sync, turning stars into explicit browser-local saves, aligning risk types, and removing hardcoded catalog counts. Add shared public asset URL helpers, risk suggestion plumbing, safer unpack/sync guards, and CI coverage gates so release and maintainer workflows catch drift earlier.
This commit is contained in:
61
apps/web-app/src/utils/__tests__/publicAssetUrls.test.ts
Normal file
61
apps/web-app/src/utils/__tests__/publicAssetUrls.test.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
getAbsolutePublicAssetUrl,
|
||||
getSkillMarkdownCandidateUrls,
|
||||
getSkillsIndexCandidateUrls,
|
||||
normalizeBasePath,
|
||||
} from '../publicAssetUrls';
|
||||
|
||||
describe('public asset URL helpers', () => {
|
||||
it('normalizes dot-relative BASE_URL values', () => {
|
||||
expect(normalizeBasePath('./')).toBe('/');
|
||||
expect(normalizeBasePath('/antigravity-awesome-skills/')).toBe('/antigravity-awesome-skills/');
|
||||
});
|
||||
|
||||
it('builds stable skills index candidates for gh-pages routes', () => {
|
||||
expect(
|
||||
getSkillsIndexCandidateUrls({
|
||||
baseUrl: '/antigravity-awesome-skills/',
|
||||
origin: 'https://sickn33.github.io',
|
||||
pathname: '/antigravity-awesome-skills/skill/some-id',
|
||||
documentBaseUrl: 'https://sickn33.github.io/antigravity-awesome-skills/',
|
||||
}),
|
||||
).toEqual([
|
||||
'https://sickn33.github.io/antigravity-awesome-skills/skills.json',
|
||||
'https://sickn33.github.io/antigravity-awesome-skills/skills.json.backup',
|
||||
'https://sickn33.github.io/skills.json',
|
||||
'https://sickn33.github.io/skills.json.backup',
|
||||
'https://sickn33.github.io/antigravity-awesome-skills/skill/skills.json',
|
||||
'https://sickn33.github.io/antigravity-awesome-skills/skill/skills.json.backup',
|
||||
'https://sickn33.github.io/antigravity-awesome-skills/skill/some-id/skills.json',
|
||||
'https://sickn33.github.io/antigravity-awesome-skills/skill/some-id/skills.json.backup',
|
||||
]);
|
||||
});
|
||||
|
||||
it('builds stable markdown candidates for gh-pages routes', () => {
|
||||
expect(
|
||||
getSkillMarkdownCandidateUrls({
|
||||
baseUrl: '/antigravity-awesome-skills/',
|
||||
origin: 'https://sickn33.github.io',
|
||||
pathname: '/antigravity-awesome-skills/skill/react-patterns',
|
||||
documentBaseUrl: 'https://sickn33.github.io/antigravity-awesome-skills/',
|
||||
skillPath: 'skills/react-patterns',
|
||||
}),
|
||||
).toEqual([
|
||||
'https://sickn33.github.io/antigravity-awesome-skills/skills/react-patterns/SKILL.md',
|
||||
'https://sickn33.github.io/skills/react-patterns/SKILL.md',
|
||||
'https://sickn33.github.io/antigravity-awesome-skills/skill/skills/react-patterns/SKILL.md',
|
||||
'https://sickn33.github.io/antigravity-awesome-skills/skill/react-patterns/skills/react-patterns/SKILL.md',
|
||||
]);
|
||||
});
|
||||
|
||||
it('resolves absolute public asset URLs from the shared base path logic', () => {
|
||||
expect(
|
||||
getAbsolutePublicAssetUrl('/skill/react-patterns', {
|
||||
baseUrl: '/antigravity-awesome-skills/',
|
||||
origin: 'https://sickn33.github.io',
|
||||
}),
|
||||
).toBe('https://sickn33.github.io/antigravity-awesome-skills/skill/react-patterns');
|
||||
});
|
||||
});
|
||||
@@ -28,8 +28,8 @@ describe('SEO helpers', () => {
|
||||
it('builds homepage metadata with the canonical catalog message', () => {
|
||||
const meta = buildHomeMeta(10);
|
||||
|
||||
expect(meta.title).toContain('1,326+');
|
||||
expect(meta.description).toContain('1,326+ installable agentic skills');
|
||||
expect(meta.title).toContain('10 installable AI skills');
|
||||
expect(meta.description).toContain('10 installable agentic skills');
|
||||
expect(meta.canonicalPath).toBe('/');
|
||||
expect(meta.ogTitle).toBe(meta.title);
|
||||
expect(meta.ogImage).toBe(DEFAULT_SOCIAL_IMAGE);
|
||||
|
||||
119
apps/web-app/src/utils/publicAssetUrls.ts
Normal file
119
apps/web-app/src/utils/publicAssetUrls.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
export interface PublicAssetUrlInput {
|
||||
baseUrl: string;
|
||||
origin: string;
|
||||
pathname: string;
|
||||
documentBaseUrl?: string;
|
||||
}
|
||||
|
||||
export type SkillsIndexUrlInput = PublicAssetUrlInput;
|
||||
|
||||
export interface SkillMarkdownUrlInput extends PublicAssetUrlInput {
|
||||
skillPath: string;
|
||||
}
|
||||
|
||||
function stripLeadingSlashes(path: string): string {
|
||||
return path.replace(/^\/+/, '');
|
||||
}
|
||||
|
||||
function normalizePathname(pathname: string): string {
|
||||
return pathname.startsWith('/') ? pathname : `/${pathname}`;
|
||||
}
|
||||
|
||||
function getResolvedDocumentBaseUrl({
|
||||
baseUrl,
|
||||
origin,
|
||||
documentBaseUrl,
|
||||
}: Pick<PublicAssetUrlInput, 'baseUrl' | 'origin' | 'documentBaseUrl'>): URL {
|
||||
if (documentBaseUrl) {
|
||||
return new URL(documentBaseUrl);
|
||||
}
|
||||
|
||||
return new URL(normalizeBasePath(baseUrl), origin);
|
||||
}
|
||||
|
||||
function getPathCandidateUrls(pathname: string, assetPath: string, origin: string): string[] {
|
||||
const pathSegments = normalizePathname(pathname).split('/').filter(Boolean);
|
||||
|
||||
return pathSegments.map((_, index) => {
|
||||
const prefix = `/${pathSegments.slice(0, index + 1).join('/')}/`;
|
||||
return `${origin}${prefix}${assetPath}`;
|
||||
});
|
||||
}
|
||||
|
||||
function uniqueUrls(urls: string[]): string[] {
|
||||
return Array.from(new Set(urls));
|
||||
}
|
||||
|
||||
function appendBackupCandidates(urls: string[]): string[] {
|
||||
const candidates = new Set<string>();
|
||||
|
||||
urls.forEach((url) => {
|
||||
candidates.add(url);
|
||||
|
||||
if (url.endsWith('skills.json')) {
|
||||
candidates.add(`${url}.backup`);
|
||||
}
|
||||
});
|
||||
|
||||
return Array.from(candidates);
|
||||
}
|
||||
|
||||
export function normalizeBasePath(baseUrl: string): string {
|
||||
const normalizedSegments = baseUrl
|
||||
.trim()
|
||||
.split('/')
|
||||
.filter((segment) => segment.length > 0 && segment !== '.');
|
||||
|
||||
const normalizedPath = normalizedSegments.length > 0
|
||||
? `/${normalizedSegments.join('/')}`
|
||||
: '/';
|
||||
|
||||
return normalizedPath.endsWith('/') ? normalizedPath : `${normalizedPath}/`;
|
||||
}
|
||||
|
||||
export function getAbsolutePublicAssetUrl(
|
||||
assetPath: string,
|
||||
{
|
||||
baseUrl,
|
||||
origin,
|
||||
}: Pick<PublicAssetUrlInput, 'baseUrl' | 'origin'>,
|
||||
): string {
|
||||
const resolvedAssetPath = stripLeadingSlashes(assetPath.trim());
|
||||
return new URL(resolvedAssetPath || '.', new URL(normalizeBasePath(baseUrl), origin)).href;
|
||||
}
|
||||
|
||||
export function getSkillsIndexCandidateUrls({
|
||||
baseUrl,
|
||||
origin,
|
||||
pathname,
|
||||
documentBaseUrl,
|
||||
}: SkillsIndexUrlInput): string[] {
|
||||
const assetPath = 'skills.json';
|
||||
|
||||
return appendBackupCandidates(uniqueUrls([
|
||||
new URL(assetPath, getResolvedDocumentBaseUrl({ baseUrl, origin, documentBaseUrl })).href,
|
||||
new URL(assetPath, new URL(normalizeBasePath(baseUrl), origin)).href,
|
||||
`${origin}/${assetPath}`,
|
||||
...getPathCandidateUrls(pathname, assetPath, origin),
|
||||
]));
|
||||
}
|
||||
|
||||
export function getSkillMarkdownCandidateUrls({
|
||||
baseUrl,
|
||||
origin,
|
||||
pathname,
|
||||
documentBaseUrl,
|
||||
skillPath,
|
||||
}: SkillMarkdownUrlInput): string[] {
|
||||
const normalizedSkillPath = skillPath
|
||||
.replace(/^\/+/, '')
|
||||
.replace(/\/SKILL\.md$/i, '');
|
||||
const assetPath = `${normalizedSkillPath}/SKILL.md`;
|
||||
|
||||
return uniqueUrls([
|
||||
new URL(assetPath, getResolvedDocumentBaseUrl({ baseUrl, origin, documentBaseUrl })).href,
|
||||
new URL(assetPath, new URL(normalizeBasePath(baseUrl), origin)).href,
|
||||
`${origin}/${assetPath}`,
|
||||
...getPathCandidateUrls(pathname, assetPath, origin),
|
||||
]);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { SeoJsonLdValue, SeoMeta, TwitterCard, Skill } from '../types';
|
||||
import { getAbsolutePublicAssetUrl } from './publicAssetUrls';
|
||||
|
||||
export const APP_HOME_CATALOG_COUNT = 1326;
|
||||
export const DEFAULT_TOP_SKILL_COUNT = 40;
|
||||
export const DEFAULT_SOCIAL_IMAGE = 'social-card.svg';
|
||||
const SITE_NAME = 'Antigravity Awesome Skills';
|
||||
@@ -46,15 +46,17 @@ export function getCanonicalUrl(canonicalPath: string, siteBaseUrl?: string): st
|
||||
}
|
||||
|
||||
export function getAssetCanonicalUrl(canonicalPath: string): string {
|
||||
const baseUrl = import.meta.env.BASE_URL || '/';
|
||||
const normalizedBase = toCanonicalPath(baseUrl);
|
||||
const appBase = normalizedBase === '/' ? '' : normalizedBase;
|
||||
return `${window.location.origin}${appBase}${toCanonicalPath(canonicalPath)}`;
|
||||
return getAbsolutePublicAssetUrl(toCanonicalPath(canonicalPath), {
|
||||
baseUrl: import.meta.env.BASE_URL || '/',
|
||||
origin: window.location.origin,
|
||||
});
|
||||
}
|
||||
|
||||
export function getAbsoluteAssetUrl(assetPath: string): string {
|
||||
const normalizedAsset = toCanonicalPath(assetPath);
|
||||
return getAssetCanonicalUrl(normalizedAsset);
|
||||
return getAbsolutePublicAssetUrl(toCanonicalPath(assetPath), {
|
||||
baseUrl: import.meta.env.BASE_URL || '/',
|
||||
origin: window.location.origin,
|
||||
});
|
||||
}
|
||||
|
||||
function getCatalogBaseUrl(canonicalUrl: string): string {
|
||||
@@ -93,12 +95,15 @@ function buildWebSiteSchema(canonicalUrl: string): Record<string, unknown> {
|
||||
}
|
||||
|
||||
function buildSoftwareSourceCodeSchema(canonicalUrl: string, visibleCount: number): Record<string, unknown> {
|
||||
const visibleCountLabel = `${visibleCount.toLocaleString('en-US')}+`;
|
||||
const visibleCountLabel = visibleCount > 0
|
||||
? `${visibleCount.toLocaleString('en-US')} agentic skills`
|
||||
: 'agentic skills';
|
||||
|
||||
return {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'SoftwareSourceCode',
|
||||
name: SITE_NAME,
|
||||
description: `Installable GitHub library of ${visibleCountLabel} agentic skills for AI coding assistants.`,
|
||||
description: `Installable GitHub library of ${visibleCountLabel} for AI coding assistants.`,
|
||||
url: canonicalUrl,
|
||||
codeRepository: 'https://github.com/sickn33/antigravity-awesome-skills',
|
||||
programmingLanguage: {
|
||||
@@ -275,10 +280,14 @@ export function isTopSkill(skillId: string, skills: ReadonlyArray<Skill>, limit
|
||||
}
|
||||
|
||||
export function buildHomeMeta(skillCount: number): SeoMeta {
|
||||
const visibleCount = Math.max(skillCount, APP_HOME_CATALOG_COUNT);
|
||||
const visibleCountLabel = `${visibleCount.toLocaleString('en-US')}+`;
|
||||
const title = `Antigravity Awesome Skills | ${visibleCountLabel} installable AI skills catalog`;
|
||||
const description = `Explore ${visibleCountLabel} installable agentic skills for Claude Code, Cursor, Codex CLI, Gemini CLI, and Antigravity. Browse bundles, workflows, FAQs, and integration guides in one place.`;
|
||||
const visibleCount = Math.max(skillCount, 0);
|
||||
const visibleCountLabel = visibleCount > 0
|
||||
? `${visibleCount.toLocaleString('en-US')} installable AI skills`
|
||||
: 'installable AI skills';
|
||||
const title = `Antigravity Awesome Skills | ${visibleCountLabel} catalog`;
|
||||
const description = visibleCount > 0
|
||||
? `Explore ${visibleCount.toLocaleString('en-US')} installable agentic skills for Claude Code, Cursor, Codex CLI, Gemini CLI, and Antigravity. Browse bundles, workflows, FAQs, and integration guides in one place.`
|
||||
: 'Explore installable agentic skills for Claude Code, Cursor, Codex CLI, Gemini CLI, and Antigravity. Browse bundles, workflows, FAQs, and integration guides in one place.';
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
|
||||
Reference in New Issue
Block a user