fix(web-app): Harden skills loading under base paths

This commit is contained in:
sickn33
2026-03-19 20:06:37 +01:00
parent 957c7369c5
commit d703a534ca
8 changed files with 403 additions and 58 deletions

View File

@@ -2,6 +2,7 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<base href="%BASE_URL%" />
<link rel="icon" type="image/svg+xml" href="%BASE_URL%vite.svg" />
<link rel="manifest" href="%BASE_URL%manifest.webmanifest" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />

View File

@@ -1,247 +1,247 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="https://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>http://localhost/</loc>
<loc>http://localhost/antigravity-awesome-skills/</loc>
<lastmod>2026-03-19</lastmod>
<changefreq>daily</changefreq>
<priority>1.0</priority>
</url>
<url>
<loc>http://localhost/skill/astro</loc>
<loc>http://localhost/antigravity-awesome-skills/skill/astro</loc>
<lastmod>2026-03-19</lastmod>
<changefreq>weekly</changefreq>
<priority>0.7</priority>
</url>
<url>
<loc>http://localhost/skill/hono</loc>
<loc>http://localhost/antigravity-awesome-skills/skill/hono</loc>
<lastmod>2026-03-19</lastmod>
<changefreq>weekly</changefreq>
<priority>0.7</priority>
</url>
<url>
<loc>http://localhost/skill/openclaw-github-repo-commander</loc>
<loc>http://localhost/antigravity-awesome-skills/skill/openclaw-github-repo-commander</loc>
<lastmod>2026-03-19</lastmod>
<changefreq>weekly</changefreq>
<priority>0.7</priority>
</url>
<url>
<loc>http://localhost/skill/pydantic-ai</loc>
<loc>http://localhost/antigravity-awesome-skills/skill/pydantic-ai</loc>
<lastmod>2026-03-19</lastmod>
<changefreq>weekly</changefreq>
<priority>0.7</priority>
</url>
<url>
<loc>http://localhost/skill/sveltekit</loc>
<loc>http://localhost/antigravity-awesome-skills/skill/sveltekit</loc>
<lastmod>2026-03-19</lastmod>
<changefreq>weekly</changefreq>
<priority>0.7</priority>
</url>
<url>
<loc>http://localhost/skill/goldrush-api</loc>
<loc>http://localhost/antigravity-awesome-skills/skill/goldrush-api</loc>
<lastmod>2026-03-19</lastmod>
<changefreq>weekly</changefreq>
<priority>0.7</priority>
</url>
<url>
<loc>http://localhost/skill/progressive-web-app</loc>
<loc>http://localhost/antigravity-awesome-skills/skill/progressive-web-app</loc>
<lastmod>2026-03-19</lastmod>
<changefreq>weekly</changefreq>
<priority>0.7</priority>
</url>
<url>
<loc>http://localhost/skill/trpc-fullstack</loc>
<loc>http://localhost/antigravity-awesome-skills/skill/trpc-fullstack</loc>
<lastmod>2026-03-19</lastmod>
<changefreq>weekly</changefreq>
<priority>0.7</priority>
</url>
<url>
<loc>http://localhost/skill/vibers-code-review</loc>
<loc>http://localhost/antigravity-awesome-skills/skill/vibers-code-review</loc>
<lastmod>2026-03-19</lastmod>
<changefreq>weekly</changefreq>
<priority>0.7</priority>
</url>
<url>
<loc>http://localhost/skill/ai-engineering-toolkit</loc>
<loc>http://localhost/antigravity-awesome-skills/skill/ai-engineering-toolkit</loc>
<lastmod>2026-03-19</lastmod>
<changefreq>weekly</changefreq>
<priority>0.7</priority>
</url>
<url>
<loc>http://localhost/skill/ai-native-cli</loc>
<loc>http://localhost/antigravity-awesome-skills/skill/ai-native-cli</loc>
<lastmod>2026-03-19</lastmod>
<changefreq>weekly</changefreq>
<priority>0.7</priority>
</url>
<url>
<loc>http://localhost/skill/latex-paper-conversion</loc>
<loc>http://localhost/antigravity-awesome-skills/skill/latex-paper-conversion</loc>
<lastmod>2026-03-19</lastmod>
<changefreq>weekly</changefreq>
<priority>0.7</priority>
</url>
<url>
<loc>http://localhost/skill/antigravity-skill-orchestrator</loc>
<loc>http://localhost/antigravity-awesome-skills/skill/antigravity-skill-orchestrator</loc>
<lastmod>2026-03-19</lastmod>
<changefreq>weekly</changefreq>
<priority>0.7</priority>
</url>
<url>
<loc>http://localhost/skill/k6-load-testing</loc>
<loc>http://localhost/antigravity-awesome-skills/skill/k6-load-testing</loc>
<lastmod>2026-03-19</lastmod>
<changefreq>weekly</changefreq>
<priority>0.7</priority>
</url>
<url>
<loc>http://localhost/skill/recallmax</loc>
<loc>http://localhost/antigravity-awesome-skills/skill/recallmax</loc>
<lastmod>2026-03-19</lastmod>
<changefreq>weekly</changefreq>
<priority>0.7</priority>
</url>
<url>
<loc>http://localhost/skill/tool-use-guardian</loc>
<loc>http://localhost/antigravity-awesome-skills/skill/tool-use-guardian</loc>
<lastmod>2026-03-19</lastmod>
<changefreq>weekly</changefreq>
<priority>0.7</priority>
</url>
<url>
<loc>http://localhost/skill/acceptance-orchestrator</loc>
<loc>http://localhost/antigravity-awesome-skills/skill/acceptance-orchestrator</loc>
<lastmod>2026-03-19</lastmod>
<changefreq>weekly</changefreq>
<priority>0.7</priority>
</url>
<url>
<loc>http://localhost/skill/closed-loop-delivery</loc>
<loc>http://localhost/antigravity-awesome-skills/skill/closed-loop-delivery</loc>
<lastmod>2026-03-19</lastmod>
<changefreq>weekly</changefreq>
<priority>0.7</priority>
</url>
<url>
<loc>http://localhost/skill/create-issue-gate</loc>
<loc>http://localhost/antigravity-awesome-skills/skill/create-issue-gate</loc>
<lastmod>2026-03-19</lastmod>
<changefreq>weekly</changefreq>
<priority>0.7</priority>
</url>
<url>
<loc>http://localhost/skill/electron-development</loc>
<loc>http://localhost/antigravity-awesome-skills/skill/electron-development</loc>
<lastmod>2026-03-19</lastmod>
<changefreq>weekly</changefreq>
<priority>0.7</priority>
</url>
<url>
<loc>http://localhost/skill/llm-structured-output</loc>
<loc>http://localhost/antigravity-awesome-skills/skill/llm-structured-output</loc>
<lastmod>2026-03-19</lastmod>
<changefreq>weekly</changefreq>
<priority>0.7</priority>
</url>
<url>
<loc>http://localhost/skill/ai-md</loc>
<loc>http://localhost/antigravity-awesome-skills/skill/ai-md</loc>
<lastmod>2026-03-19</lastmod>
<changefreq>weekly</changefreq>
<priority>0.7</priority>
</url>
<url>
<loc>http://localhost/skill/explain-like-socrates</loc>
<loc>http://localhost/antigravity-awesome-skills/skill/explain-like-socrates</loc>
<lastmod>2026-03-19</lastmod>
<changefreq>weekly</changefreq>
<priority>0.7</priority>
</url>
<url>
<loc>http://localhost/skill/interview-coach</loc>
<loc>http://localhost/antigravity-awesome-skills/skill/interview-coach</loc>
<lastmod>2026-03-19</lastmod>
<changefreq>weekly</changefreq>
<priority>0.7</priority>
</url>
<url>
<loc>http://localhost/skill/keyword-extractor</loc>
<loc>http://localhost/antigravity-awesome-skills/skill/keyword-extractor</loc>
<lastmod>2026-03-19</lastmod>
<changefreq>weekly</changefreq>
<priority>0.7</priority>
</url>
<url>
<loc>http://localhost/skill/local-llm-expert</loc>
<loc>http://localhost/antigravity-awesome-skills/skill/local-llm-expert</loc>
<lastmod>2026-03-19</lastmod>
<changefreq>weekly</changefreq>
<priority>0.7</priority>
</url>
<url>
<loc>http://localhost/skill/skill-check</loc>
<loc>http://localhost/antigravity-awesome-skills/skill/skill-check</loc>
<lastmod>2026-03-19</lastmod>
<changefreq>weekly</changefreq>
<priority>0.7</priority>
</url>
<url>
<loc>http://localhost/skill/yes-md</loc>
<loc>http://localhost/antigravity-awesome-skills/skill/yes-md</loc>
<lastmod>2026-03-19</lastmod>
<changefreq>weekly</changefreq>
<priority>0.7</priority>
</url>
<url>
<loc>http://localhost/skill/blueprint</loc>
<loc>http://localhost/antigravity-awesome-skills/skill/blueprint</loc>
<lastmod>2026-03-19</lastmod>
<changefreq>weekly</changefreq>
<priority>0.7</priority>
</url>
<url>
<loc>http://localhost/skill/lex</loc>
<loc>http://localhost/antigravity-awesome-skills/skill/lex</loc>
<lastmod>2026-03-19</lastmod>
<changefreq>weekly</changefreq>
<priority>0.7</priority>
</url>
<url>
<loc>http://localhost/skill/pipecat-friday-agent</loc>
<loc>http://localhost/antigravity-awesome-skills/skill/pipecat-friday-agent</loc>
<lastmod>2026-03-19</lastmod>
<changefreq>weekly</changefreq>
<priority>0.7</priority>
</url>
<url>
<loc>http://localhost/skill/progressive-estimation</loc>
<loc>http://localhost/antigravity-awesome-skills/skill/progressive-estimation</loc>
<lastmod>2026-03-19</lastmod>
<changefreq>weekly</changefreq>
<priority>0.7</priority>
</url>
<url>
<loc>http://localhost/skill/sankhya-dashboard-html-jsp-custom-best-pratices</loc>
<loc>http://localhost/antigravity-awesome-skills/skill/sankhya-dashboard-html-jsp-custom-best-pratices</loc>
<lastmod>2026-03-19</lastmod>
<changefreq>weekly</changefreq>
<priority>0.7</priority>
</url>
<url>
<loc>http://localhost/skill/seek-and-analyze-video</loc>
<loc>http://localhost/antigravity-awesome-skills/skill/seek-and-analyze-video</loc>
<lastmod>2026-03-19</lastmod>
<changefreq>weekly</changefreq>
<priority>0.7</priority>
</url>
<url>
<loc>http://localhost/skill/animejs-animation</loc>
<loc>http://localhost/antigravity-awesome-skills/skill/animejs-animation</loc>
<lastmod>2026-03-19</lastmod>
<changefreq>weekly</changefreq>
<priority>0.7</priority>
</url>
<url>
<loc>http://localhost/skill/antigravity-design-expert</loc>
<loc>http://localhost/antigravity-awesome-skills/skill/antigravity-design-expert</loc>
<lastmod>2026-03-19</lastmod>
<changefreq>weekly</changefreq>
<priority>0.7</priority>
</url>
<url>
<loc>http://localhost/skill/audit-skills</loc>
<loc>http://localhost/antigravity-awesome-skills/skill/audit-skills</loc>
<lastmod>2026-03-19</lastmod>
<changefreq>weekly</changefreq>
<priority>0.7</priority>
</url>
<url>
<loc>http://localhost/skill/daily</loc>
<loc>http://localhost/antigravity-awesome-skills/skill/daily</loc>
<lastmod>2026-03-19</lastmod>
<changefreq>weekly</changefreq>
<priority>0.7</priority>
</url>
<url>
<loc>http://localhost/skill/design-spells</loc>
<loc>http://localhost/antigravity-awesome-skills/skill/design-spells</loc>
<lastmod>2026-03-19</lastmod>
<changefreq>weekly</changefreq>
<priority>0.7</priority>
</url>
<url>
<loc>http://localhost/skill/frontend-slides</loc>
<loc>http://localhost/antigravity-awesome-skills/skill/frontend-slides</loc>
<lastmod>2026-03-19</lastmod>
<changefreq>weekly</changefreq>
<priority>0.7</priority>

View File

@@ -6,6 +6,7 @@ interface SkillContextType {
skills: Skill[];
stars: StarMap;
loading: boolean;
error: string | null;
refreshSkills: () => Promise<void>;
}
@@ -13,6 +14,7 @@ interface SkillsIndexUrlInput {
baseUrl: string;
origin: string;
pathname: string;
documentBaseUrl?: string;
}
const SkillContext = createContext<SkillContextType | undefined>(undefined);
@@ -30,37 +32,61 @@ function normalizeBasePath(baseUrl: string): string {
return normalizedPath.endsWith('/') ? normalizedPath : `${normalizedPath}/`;
}
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 getSkillsIndexCandidateUrls({
baseUrl,
origin,
pathname,
documentBaseUrl,
}: SkillsIndexUrlInput): string[] {
const normalizedPathname = pathname.startsWith('/') ? pathname : `/${pathname}`;
const firstSegment = normalizedPathname.split('/').filter(Boolean)[0];
const rootPath = firstSegment ? `/${firstSegment}/` : '/';
const baseCandidate = new URL(
'skills.json',
documentBaseUrl || new URL(normalizeBasePath(baseUrl), origin),
).href;
const pathSegments = normalizedPathname.split('/').filter(Boolean);
const pathCandidates = pathSegments.map((_, index) => {
const prefix = `/${pathSegments.slice(0, index + 1).join('/')}/`;
return `${origin}${prefix}skills.json`;
});
const candidates = new Set<string>([
return appendBackupCandidates([
baseCandidate,
new URL('skills.json', new URL(normalizeBasePath(baseUrl), origin)).href,
`${origin}/skills.json`,
`${origin}${rootPath}skills.json`,
...pathCandidates,
]);
return Array.from(candidates);
}
export function SkillProvider({ children }: { children: React.ReactNode }) {
const [skills, setSkills] = useState<Skill[]>([]);
const [stars, setStars] = useState<StarMap>({});
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchSkillsAndStars = useCallback(async (silent = false) => {
if (!silent) setLoading(true);
setError(null);
try {
// Fetch skills index
const candidateUrls = getSkillsIndexCandidateUrls({
baseUrl: import.meta.env.BASE_URL,
origin: window.location.origin,
pathname: window.location.pathname,
documentBaseUrl: window.document.baseURI,
});
let data: Skill[] | null = null;
@@ -73,7 +99,16 @@ export function SkillProvider({ children }: { children: React.ReactNode }) {
throw new Error(`Request failed (${res.status}) for ${url}`);
}
const parsed = await res.json();
const rawBody = await res.text();
let parsed: unknown;
try {
parsed = JSON.parse(rawBody);
} catch {
const contentType = res.headers.get('content-type') || 'unknown content type';
throw new Error(`Non-JSON response from ${url} (${contentType})`);
}
if (!Array.isArray(parsed) || parsed.length === 0) {
throw new Error(`Invalid or empty payload from ${url}`);
}
@@ -120,6 +155,8 @@ export function SkillProvider({ children }: { children: React.ReactNode }) {
}
} catch (err) {
const message = err instanceof Error ? err.message : 'Unable to load the skills catalog.';
setError(message);
console.error('SkillContext: Failed to load skills', err);
} finally {
if (!silent) setLoading(false);
@@ -138,8 +175,9 @@ export function SkillProvider({ children }: { children: React.ReactNode }) {
skills,
stars,
loading,
error,
refreshSkills
}), [skills, stars, loading, refreshSkills]);
}), [skills, stars, loading, error, refreshSkills]);
return (
<SkillContext.Provider value={value}>

View File

@@ -30,10 +30,17 @@ describe('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',
]);
});
@@ -43,10 +50,13 @@ describe('getSkillsIndexCandidateUrls', () => {
baseUrl: './',
origin: 'https://sickn33.github.io',
pathname: '/antigravity-awesome-skills/',
documentBaseUrl: 'https://sickn33.github.io/antigravity-awesome-skills/',
}),
).toEqual([
'https://sickn33.github.io/skills.json',
'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',
]);
});
});
@@ -70,13 +80,20 @@ describe('SkillProvider', () => {
];
(global.fetch as Mock)
.mockResolvedValueOnce({
ok: false,
status: 404,
})
.mockResolvedValueOnce({
ok: false,
status: 404,
})
.mockResolvedValueOnce({
ok: true,
json: async () => mockSkills,
headers: {
get: () => 'application/json',
},
text: async () => JSON.stringify(mockSkills),
});
render(
@@ -94,4 +111,43 @@ describe('SkillProvider', () => {
await Promise.resolve();
});
});
it('falls back to the bundled backup catalog when the primary index is invalid', async () => {
const mockSkills = [
{
id: 'skill-backup',
path: 'skills/skill-backup',
category: 'core',
name: 'Backup skill',
description: 'Loaded from backup',
},
];
(global.fetch as Mock)
.mockResolvedValueOnce({
ok: true,
headers: {
get: () => 'text/html',
},
text: async () => '<!doctype html><html></html>',
})
.mockResolvedValueOnce({
ok: true,
headers: {
get: () => 'application/json',
},
text: async () => JSON.stringify(mockSkills),
});
render(
<SkillProvider>
<SkillsProbe />
</SkillProvider>,
);
await waitFor(() => {
expect(screen.getByTestId('loading').textContent).toBe('ready');
expect(screen.getByTestId('count').textContent).toBe('1');
});
});
});

View File

@@ -9,7 +9,7 @@ import { usePageMeta } from '../hooks/usePageMeta';
import { APP_HOME_CATALOG_COUNT, buildHomeMeta } from '../utils/seo';
export function Home(): React.ReactElement {
const { skills, stars, loading, refreshSkills } = useSkills();
const { skills, stars, loading, error, refreshSkills } = useSkills();
const [search, setSearch] = useState('');
const [debouncedSearch, setDebouncedSearch] = useState('');
const [categoryFilter, setCategoryFilter] = useState('all');
@@ -235,6 +235,18 @@ export function Home(): React.ReactElement {
</div>
))}
</div>
) : error && skills.length === 0 ? (
<div className="py-12 text-center px-4 sm:px-6 lg:px-8">
<AlertCircle className="mx-auto h-12 w-12 text-red-400" />
<h3 className="mt-4 text-lg font-semibold text-slate-900 dark:text-slate-100">Unable to load skills</h3>
<p className="mt-2 text-slate-500 dark:text-slate-400">{error}</p>
<button
onClick={() => void refreshSkills()}
className="mt-5 inline-flex items-center justify-center rounded-lg bg-indigo-600 px-4 py-2.5 text-sm font-semibold text-white hover:bg-indigo-700"
>
Retry loading catalog
</button>
</div>
) : filteredSkills.length === 0 ? (
<div className="py-12 text-center px-4 sm:px-6 lg:px-8">
<AlertCircle className="mx-auto h-12 w-12 text-slate-400" />

View File

@@ -11,6 +11,58 @@ import rehypeHighlight from 'rehype-highlight';
// Lazy load heavy markdown component
const Markdown = lazy(() => import('react-markdown'));
interface SkillMarkdownUrlInput {
baseUrl: string;
origin: string;
pathname: string;
documentBaseUrl?: string;
skillPath: string;
}
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 getSkillMarkdownCandidateUrls({
baseUrl,
origin,
pathname,
documentBaseUrl,
skillPath,
}: SkillMarkdownUrlInput): string[] {
const normalizedSkillPath = skillPath
.replace(/^\/+/, '')
.replace(/\/SKILL\.md$/i, '');
const assetPath = `${normalizedSkillPath}/SKILL.md`;
const normalizedPathname = pathname.startsWith('/') ? pathname : `/${pathname}`;
const pathSegments = normalizedPathname.split('/').filter(Boolean);
const pathCandidates = pathSegments.map((_, index) => {
const prefix = `/${pathSegments.slice(0, index + 1).join('/')}/`;
return `${origin}${prefix}${assetPath}`;
});
return Array.from(new Set([
new URL(assetPath, documentBaseUrl || new URL(normalizeBasePath(baseUrl), origin)).href,
new URL(assetPath, new URL(normalizeBasePath(baseUrl), origin)).href,
`${origin}/${assetPath}`,
...pathCandidates,
]));
}
function looksLikeHtmlDocument(text: string): boolean {
const trimmed = text.trim().toLowerCase();
return trimmed.startsWith('<!doctype html') || trimmed.startsWith('<html');
}
/** Split YAML frontmatter (--- ... ---) and markdown body */
function splitFrontmatter(md: string): { frontmatter: string; body: string } {
const match = md.match(/^(---[\s\S]*?---)\s*\n?/);
@@ -61,6 +113,7 @@ export function SkillDetail(): React.ReactElement {
const [error, setError] = useState<string | null>(null);
const [customContext, setCustomContext] = useState('');
const [commandCopied, setCommandCopied] = useState(false);
const [retryToken, setRetryToken] = useState(0);
const installCommand = 'npx antigravity-awesome-skills';
const skill = useMemo(() => skills.find(s => s.id === id), [skills, id]);
@@ -99,17 +152,47 @@ export function SkillDetail(): React.ReactElement {
const loadMarkdown = async () => {
setContentLoading(true);
setError(null);
try {
const cleanPath = skill.path.startsWith('skills/')
? skill.path.replace('skills/', '')
: skill.path;
const base = import.meta.env.BASE_URL;
const mdRes = await fetch(`${base}skills/${cleanPath}/SKILL.md`);
if (!mdRes.ok) throw new Error('Skill file not found');
const candidateUrls = getSkillMarkdownCandidateUrls({
baseUrl: import.meta.env.BASE_URL,
origin: window.location.origin,
pathname: window.location.pathname,
documentBaseUrl: window.document.baseURI,
skillPath: `skills/${cleanPath}`,
});
const text = await mdRes.text();
setContent(text);
let markdown: string | null = null;
let lastError: Error | null = null;
for (const url of candidateUrls) {
try {
const mdRes = await fetch(url);
if (!mdRes.ok) {
throw new Error(`Request failed (${mdRes.status}) for ${url}`);
}
const text = await mdRes.text();
if (looksLikeHtmlDocument(text)) {
throw new Error(`HTML fallback returned instead of markdown for ${url}`);
}
markdown = text;
break;
} catch (err) {
lastError = err instanceof Error ? err : new Error(String(err));
}
}
if (markdown === null) {
throw lastError || new Error('Skill file not found');
}
setContent(markdown);
} catch (err) {
console.error('Failed to load skill content', err);
setError(err instanceof Error ? err.message : 'Could not load skill content.');
@@ -119,7 +202,7 @@ export function SkillDetail(): React.ReactElement {
};
loadMarkdown();
}, [skill, contextLoading]);
}, [skill, contextLoading, retryToken]);
const copyToClipboard = () => {
if (!skill) return;
@@ -175,6 +258,12 @@ export function SkillDetail(): React.ReactElement {
<AlertTriangle className="h-12 w-12 text-red-500 mx-auto mb-4" />
<h2 className="text-2xl font-bold text-slate-900 dark:text-white mb-2">Failed to Load Content</h2>
<p className="text-slate-500 mt-2">{error || 'Skill details could not be loaded.'}</p>
<button
onClick={() => setRetryToken((value) => value + 1)}
className="mt-6 inline-flex items-center justify-center rounded-lg bg-indigo-600 px-4 py-2.5 text-sm font-semibold text-white hover:bg-indigo-700"
>
Retry loading content
</button>
<Link to="/" className="mt-8 inline-flex items-center text-indigo-600 font-medium hover:underline">
<ArrowLeft className="mr-2 h-4 w-4" /> Back to Catalog
</Link>

View File

@@ -45,6 +45,7 @@ describe('Home', () => {
skills: [],
stars: {},
loading: true,
error: null,
});
renderWithRouter(<Home />, { useProvider: false });
@@ -61,6 +62,7 @@ describe('Home', () => {
skills: mockSkills,
stars: {},
loading: false,
error: null,
});
renderWithRouter(<Home />, { useProvider: false });
@@ -80,6 +82,7 @@ describe('Home', () => {
skills: mockSkills,
stars: {},
loading: false,
error: null,
});
renderWithRouter(<Home />, { useProvider: false });
@@ -101,6 +104,7 @@ describe('Home', () => {
skills: [],
stars: {},
loading: false,
error: null,
});
renderWithRouter(<Home />, { useProvider: false });
@@ -134,6 +138,7 @@ describe('Home', () => {
skills: mockSkills,
stars: {},
loading: false,
error: null,
});
renderWithRouter(<Home />, { useProvider: false });
@@ -158,6 +163,7 @@ describe('Home', () => {
skills: mockSkills,
stars: {},
loading: false,
error: null,
});
renderWithRouter(<Home />, { useProvider: false });
@@ -182,6 +188,7 @@ describe('Home', () => {
skills: mockSkills,
stars: { 'skill-1': 5 },
loading: false,
error: null,
refreshSkills,
});
@@ -201,4 +208,27 @@ describe('Home', () => {
});
});
});
it('shows a catalog load error instead of a generic empty state', async () => {
const refreshSkills = vi.fn().mockResolvedValue(undefined);
(useSkills as Mock).mockReturnValue({
skills: [],
stars: {},
loading: false,
error: 'Non-JSON response from /skills.json (text/html)',
refreshSkills,
});
renderWithRouter(<Home />, { useProvider: false });
await waitFor(() => {
expect(screen.getByText(/Unable to load skills/i)).toBeInTheDocument();
expect(screen.getByText(/Non-JSON response/i)).toBeInTheDocument();
});
fireEvent.click(screen.getByRole('button', { name: /Retry loading catalog/i }));
expect(refreshSkills).toHaveBeenCalled();
});
});

View File

@@ -4,6 +4,7 @@ import { SkillDetail } from '../SkillDetail';
import { renderWithRouter } from '../../utils/testUtils';
import { createMockSkill } from '../../factories/skill';
import { useSkills } from '../../context/SkillContext';
import { getSkillMarkdownCandidateUrls } from '../SkillDetail';
// Mock the SkillStarButton component
vi.mock('../../components/SkillStarButton', () => ({
@@ -32,6 +33,26 @@ describe('SkillDetail', () => {
beforeEach(() => {
vi.clearAllMocks();
localStorage.clear();
window.history.pushState({}, '', '/');
});
describe('Markdown URL resolution', () => {
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',
]);
});
});
describe('Loading state', () => {
@@ -109,6 +130,104 @@ describe('SkillDetail', () => {
'content',
'@react-patterns | Antigravity Awesome Skills',
);
expect(global.fetch).toHaveBeenCalled();
});
});
it('falls back to the next markdown candidate when the first response is html', async () => {
const mockSkill = createMockSkill({
id: 'fallback-skill',
name: 'fallback-skill',
});
(useSkills as Mock).mockReturnValue({
skills: [mockSkill],
stars: {},
loading: false,
});
window.history.pushState({}, '', '/skill/fallback-skill');
global.fetch = vi.fn()
.mockResolvedValueOnce({
ok: true,
text: async () => '<!doctype html><html></html>',
})
.mockResolvedValueOnce({
ok: true,
text: async () => '# Loaded from fallback',
});
renderWithRouter(<SkillDetail />, {
route: '/skill/fallback-skill',
path: '/skill/:id',
useProvider: false
});
await waitFor(() => {
expect(screen.getByTestId('markdown-content')).toHaveTextContent('Loaded from fallback');
});
});
it('shows a retry action when markdown loading fails', async () => {
const mockSkill = createMockSkill({
id: 'broken-skill',
name: 'broken-skill',
});
(useSkills as Mock).mockReturnValue({
skills: [mockSkill],
stars: {},
loading: false,
});
window.history.pushState({}, '', '/skill/broken-skill');
global.fetch = vi.fn()
.mockResolvedValueOnce({
ok: false,
status: 404,
text: async () => '',
})
.mockResolvedValueOnce({
ok: false,
status: 404,
text: async () => '',
})
.mockResolvedValueOnce({
ok: false,
status: 404,
text: async () => '',
})
.mockResolvedValueOnce({
ok: false,
status: 404,
text: async () => '',
})
.mockResolvedValueOnce({
ok: false,
status: 404,
text: async () => '',
})
.mockResolvedValueOnce({
ok: true,
text: async () => '# Recovered content',
});
renderWithRouter(<SkillDetail />, {
route: '/skill/broken-skill',
path: '/skill/:id',
useProvider: false
});
await waitFor(() => {
expect(screen.getByText(/Failed to Load Content/i)).toBeInTheDocument();
});
fireEvent.click(screen.getByRole('button', { name: /Retry loading content/i }));
await waitFor(() => {
expect(screen.getByTestId('markdown-content')).toHaveTextContent('Recovered content');
});
});