fix(web-app): Harden skills loading under base paths
This commit is contained in:
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user