diff --git a/apps/web-app/src/pages/SkillDetail.tsx b/apps/web-app/src/pages/SkillDetail.tsx
index 5f12f0ed..039e1e62 100644
--- a/apps/web-app/src/pages/SkillDetail.tsx
+++ b/apps/web-app/src/pages/SkillDetail.tsx
@@ -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('(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 {
Failed to Load Content
{error || 'Skill details could not be loaded.'}
+
Back to Catalog
diff --git a/apps/web-app/src/pages/__tests__/Home.test.tsx b/apps/web-app/src/pages/__tests__/Home.test.tsx
index 53777476..b8ed1b54 100644
--- a/apps/web-app/src/pages/__tests__/Home.test.tsx
+++ b/apps/web-app/src/pages/__tests__/Home.test.tsx
@@ -45,6 +45,7 @@ describe('Home', () => {
skills: [],
stars: {},
loading: true,
+ error: null,
});
renderWithRouter(
, { useProvider: false });
@@ -61,6 +62,7 @@ describe('Home', () => {
skills: mockSkills,
stars: {},
loading: false,
+ error: null,
});
renderWithRouter(
, { useProvider: false });
@@ -80,6 +82,7 @@ describe('Home', () => {
skills: mockSkills,
stars: {},
loading: false,
+ error: null,
});
renderWithRouter(
, { useProvider: false });
@@ -101,6 +104,7 @@ describe('Home', () => {
skills: [],
stars: {},
loading: false,
+ error: null,
});
renderWithRouter(
, { useProvider: false });
@@ -134,6 +138,7 @@ describe('Home', () => {
skills: mockSkills,
stars: {},
loading: false,
+ error: null,
});
renderWithRouter(
, { useProvider: false });
@@ -158,6 +163,7 @@ describe('Home', () => {
skills: mockSkills,
stars: {},
loading: false,
+ error: null,
});
renderWithRouter(
, { 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(
, { 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();
+ });
});
diff --git a/apps/web-app/src/pages/__tests__/SkillDetail.test.tsx b/apps/web-app/src/pages/__tests__/SkillDetail.test.tsx
index e7ad4df6..2dfadd38 100644
--- a/apps/web-app/src/pages/__tests__/SkillDetail.test.tsx
+++ b/apps/web-app/src/pages/__tests__/SkillDetail.test.tsx
@@ -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 () => '',
+ })
+ .mockResolvedValueOnce({
+ ok: true,
+ text: async () => '# Loaded from fallback',
+ });
+
+ renderWithRouter(
, {
+ 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(
, {
+ 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');
});
});