Skip to content

Commit 43ece34

Browse files
authored
refactor: restructure skill markdown URLs and improve the site (#171)
* refactor: restructure skill markdown URLs and improve site * add note about not relying on cache * fix the text on how it works * add the source commit for a skill * add times and commit to skill page
1 parent d1e4fb8 commit 43ece34

11 files changed

Lines changed: 114 additions & 43 deletions

File tree

public/.ic-assets.json5

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,17 @@
3737
"Access-Control-Allow-Headers": "Content-Type"
3838
}
3939
},
40+
{
41+
// User discovery endpoints — SKILL.md files
42+
"match": "skills/**/*.md",
43+
"headers": {
44+
"Content-Type": "text/markdown; charset=utf-8",
45+
"Cache-Control": "public, max-age=300, must-revalidate",
46+
"Access-Control-Allow-Origin": "*",
47+
"Access-Control-Allow-Methods": "GET",
48+
"Access-Control-Allow-Headers": "Content-Type"
49+
}
50+
},
4051
{
4152
// Matomo analytics
4253
"match": "matomo.js",

src/lib/skills.ts

Lines changed: 43 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@
55
import { getCollection, type CollectionEntry } from 'astro:content';
66
import fs from 'node:fs/promises';
77
import path from 'node:path';
8+
import { execFile } from 'node:child_process';
9+
import { promisify } from 'node:util';
10+
11+
const execFileAsync = promisify(execFile);
812

913

1014
export type Skill = CollectionEntry<'skills'>;
@@ -48,21 +52,31 @@ export async function getSkillsByCategory(): Promise<Array<{ category: string; s
4852
return categories.map((category) => ({ category, skills: byCat.get(category)! }));
4953
}
5054

55+
export interface SkillGitInfo {
56+
sha: string;
57+
updatedAt: string;
58+
}
59+
5160
/**
52-
* Returns the last-modified ISO date string for a skill, derived from its
53-
* SKILL.md file mtime. Used as the "updated" timestamp in UI, JSON-LD, and
54-
* the RSS feed so freshness signals match the underlying file.
61+
* Returns the last commit SHA and author date for a skill's SKILL.md from git.
62+
* Git commit time is used instead of filesystem mtime because mtime varies
63+
* across CI clones and checkout orders, while the commit date is stable and
64+
* content-tied. Falls back to the current time / 'main' if git is unavailable.
5565
*/
56-
export async function getSkillUpdatedAt(skill: Skill): Promise<string> {
57-
// Astro's content loader exposes the source filePath in `filePath`.
66+
export async function getSkillGitInfo(skill: Skill): Promise<SkillGitInfo> {
5867
const rel = skill.filePath ?? `upstream/skills/${skill.id}/SKILL.md`;
5968
const abs = path.resolve(process.cwd(), rel);
6069
try {
61-
const stat = await fs.stat(abs);
62-
return stat.mtime.toISOString();
63-
} catch {
64-
return new Date().toISOString();
65-
}
70+
const { stdout } = await execFileAsync('git', ['log', '-1', '--format=%H|%aI', '--', abs]);
71+
const [sha, date] = stdout.trim().split('|');
72+
if (sha && date) return { sha, updatedAt: date };
73+
} catch { /* fall through */ }
74+
return { sha: 'main', updatedAt: new Date().toISOString() };
75+
}
76+
77+
/** @deprecated Use getSkillGitInfo instead. */
78+
export async function getSkillUpdatedAt(skill: Skill): Promise<string> {
79+
return (await getSkillGitInfo(skill)).updatedAt;
6680
}
6781

6882
/**
@@ -80,11 +94,29 @@ export function skillUrl(slug: string): string {
8094
return `/skills/${slug}/`;
8195
}
8296

83-
/** Canonical GitHub permalink for a skill. */
97+
/** Human-facing raw markdown URL for a skill. */
98+
export function skillMarkdownUrl(slug: string): string {
99+
return `/skills/${slug}/SKILL.md`;
100+
}
101+
102+
/** Canonical GitHub permalink for a skill (main branch). */
84103
export function githubUrl(slug: string): string {
85104
return `https://github.com/dfinity/icskills/blob/main/skills/${slug}/SKILL.md`;
86105
}
87106

107+
/** GitHub permalink pinned to a specific commit SHA. */
108+
export function githubCommitUrl(slug: string, sha: string): string {
109+
return `https://github.com/dfinity/icskills/blob/${sha}/skills/${slug}/SKILL.md`;
110+
}
111+
112+
/**
113+
* Returns the full SHA of the last git commit that touched a skill's SKILL.md.
114+
* Falls back to 'main' if git is unavailable or the file has no history.
115+
*/
116+
export async function getSkillCommitHash(skill: Skill): Promise<string> {
117+
return (await getSkillGitInfo(skill)).sha;
118+
}
119+
88120
/**
89121
* List all files in a skill's directory, with SKILL.md first.
90122
* Used by the .well-known/skills/index.json endpoint.

src/pages/api/skills.json.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ export const GET: APIRoute = async () => {
1818
updated: await getSkillUpdatedAt(skill),
1919
urls: {
2020
html: absUrl(skillUrl(skill.id)),
21-
markdown: absUrl(`/skills/${skill.id}.md`),
21+
markdown: absUrl(`/.well-known/skills/${skill.id}/SKILL.md`),
2222
json: absUrl(`/api/skills/${skill.id}.json`),
2323
source: githubUrl(skill.id),
2424
},

src/pages/api/skills/[slug].json.ts

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@
55
import type { APIRoute } from 'astro';
66
import {
77
getAllSkills,
8-
getSkillUpdatedAt,
9-
githubUrl,
8+
getSkillGitInfo,
9+
githubCommitUrl,
1010
skillUrl,
1111
} from '../../../lib/skills';
1212
import { SITE, absUrl } from '../../../lib/site';
@@ -22,7 +22,8 @@ export const GET: APIRoute = async ({ props }) => {
2222
const skill = skills.find((s) => s.id === slug);
2323
if (!skill) return new Response('Not found', { status: 404 });
2424

25-
const updatedAt = await getSkillUpdatedAt(skill);
25+
const { sha, updatedAt } = await getSkillGitInfo(skill);
26+
const sourceUrl = githubCommitUrl(slug, sha);
2627
const payload = {
2728
name: skill.data.name,
2829
title: skill.data.metadata.title,
@@ -33,9 +34,9 @@ export const GET: APIRoute = async ({ props }) => {
3334
updated: updatedAt,
3435
urls: {
3536
html: absUrl(skillUrl(slug)),
36-
markdown: absUrl(`/skills/${slug}.md`),
37+
markdown: absUrl(`/.well-known/skills/${slug}/SKILL.md`),
3738
json: absUrl(`/api/skills/${slug}.json`),
38-
source: githubUrl(slug),
39+
source: sourceUrl,
3940
},
4041
publisher: SITE.author,
4142
canonicalRepo: SITE.repo,
@@ -44,7 +45,7 @@ export const GET: APIRoute = async ({ props }) => {
4445
return new Response(JSON.stringify(payload, null, 2) + '\n', {
4546
headers: {
4647
'Content-Type': 'application/json; charset=utf-8',
47-
'X-Content-Source': githubUrl(slug),
48+
'X-Content-Source': sourceUrl,
4849
},
4950
});
5051
};

src/pages/get-started/index.astro

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,11 +43,13 @@ Before writing any ICP code, fetch the skills index and remember each skill's na
4343
${absUrl('/.well-known/skills/index.json')}
4444
4545
When a task matches a skill's description, fetch its content on-demand from:
46-
${absUrl('/.well-known/skills/')}{'{name}'}/SKILL.md
46+
${absUrl('/.well-known/skills/')}{skill-name}/SKILL.md
4747
4848
Skills contain correct dependency versions, configuration formats, and common pitfalls
4949
that prevent build failures. Always prefer skill guidance over general documentation
50-
when both cover the same topic.`}</code></pre>
50+
when both cover the same topic.
51+
52+
Skills are updated frequently - do not rely on a previously cached version.`}</code></pre>
5153
<p>
5254
For Claude Code, also add a <code>CLAUDE.md</code> that reads:
5355
<code>Read and follow the instructions in AGENTS.md.</code>
@@ -67,7 +69,7 @@ when both cover the same topic.`}</code></pre>
6769
<p>URLs are stable and predictable:</p>
6870
<ul>
6971
<li><code>{absUrl('/skills/')}{'{name}/'}</code> — human-readable HTML page</li>
70-
<li><code>{absUrl('/skills/')}{'{name}.md'}</code> — raw Markdown (Content-Type: text/markdown)</li>
72+
<li><code>{absUrl('/skills/')}{'{name}/SKILL.md'}</code> — raw Markdown (Content-Type: text/markdown)</li>
7173
<li><code>{absUrl('/api/skills/')}{'{name}.json'}</code> — structured metadata (JSON)</li>
7274
</ul>
7375
<p>

src/pages/how-it-works/index.astro

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,26 +10,25 @@ import { SITE } from '../../lib/site';
1010
<h1>How it works</h1>
1111

1212
<p class="lede">
13-
This site is a trust-optimised, pre-rendered mirror of the skill files in
14-
<a href={SITE.repo.url} rel="noopener external">{SITE.repo.name}</a>. Every page exists
13+
This site is a pre-rendered mirror of the skill files in
14+
<a href={SITE.repo.url} rel="noopener external">{SITE.repo.name}</a>. Every Skill exists
1515
as HTML, raw Markdown, and JSON.
1616
</p>
1717

1818
<h2>Where the content comes from</h2>
1919
<p>
20-
Each skill is a Markdown file at <code>skills/&lt;name&gt;/SKILL.md</code> in the upstream
21-
repository, with YAML frontmatter conforming to the
22-
<a href="https://skills.internetcomputer.org/skills/skill.schema.json">IC Skill schema</a>.
23-
The content follows the <a href="https://agentskills.io/specification" rel="noopener external">Agent Skills specification</a>
24-
so it can be consumed directly by agents. This site pins an exact Git commit of the
25-
upstream repo as a submodule — the Markdown bytes served here are the bytes in that commit.
20+
Each skill is a Markdown file at <code>skills/&lt;name&gt;/SKILL.md</code> in the repository,
21+
with YAML frontmatter conforming to the <a href="/skills/skill.schema.json">IC Skill schema</a>.
22+
The content follows the <a href="https://agentskills.io/specification" rel="noopener external">Agent Skills Specification</a>
23+
so it can be consumed directly by agents. This site is continuously deployed from the main branch of the
24+
<a href={SITE.repo.url} rel="noopener external">{SITE.repo.name}</a> repository.
2625
</p>
2726

2827
<h2>Representations</h2>
2928
<p>Every skill is available in three formats, linked from the skill page and cross-referenced in JSON-LD:</p>
3029
<ul>
3130
<li><strong>HTML</strong> (<code>/skills/&lt;slug&gt;/</code>) — human-readable page with navigation.</li>
32-
<li><strong>Markdown</strong> (<code>/skills/&lt;slug&gt;.md</code>) — the original SKILL.md, served as <code>text/markdown</code>. Prefer this for LLM ingestion.</li>
31+
<li><strong>Markdown</strong> (<code>/skills/&lt;slug&gt;/SKILL.md</code>) — the original SKILL.md, served as <code>text/markdown</code>. Prefer this for LLM ingestion.</li>
3332
<li><strong>JSON</strong> (<code>/api/skills/&lt;slug&gt;.json</code>) — structured metadata, stable shape.</li>
3433
</ul>
3534
<p>

src/pages/index.astro

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
---
22
import BaseLayout from '../layouts/BaseLayout.astro';
33
import { SITE, absUrl } from '../lib/site';
4-
import { getAllSkills, getSkillsByCategory, skillUrl } from '../lib/skills';
4+
import { getAllSkills, getSkillsByCategory, skillUrl, skillMarkdownUrl } from '../lib/skills';
55
66
const all = await getAllSkills();
77
const grouped = await getSkillsByCategory();
@@ -78,14 +78,14 @@ const collectionLd = {
7878
</div>
7979
<ul class="skill-list">
8080
{skills.map((s) => (
81-
<li class="skill-row">
81+
<li class="skill-row" data-skill-id={s.id}>
8282
<div class="skill-title">
8383
<a href={skillUrl(s.id)}>{s.data.metadata.title}</a>
8484
</div>
8585
<p class="skill-desc">{s.data.description}</p>
8686
<div class="skill-links">
8787
<a href={skillUrl(s.id)}>Read</a>
88-
<a href={`/skills/${s.id}.md`} rel="alternate" type="text/markdown">Markdown</a>
88+
<a href={skillMarkdownUrl(s.id)} rel="alternate" type="text/markdown">Markdown</a>
8989
<a href={`/api/skills/${s.id}.json`} rel="alternate" type="application/json">JSON</a>
9090
</div>
9191
</li>
@@ -127,7 +127,8 @@ const collectionLd = {
127127
rows.forEach(function (row) {
128128
var title = row.querySelector('.skill-title');
129129
var desc = row.querySelector('.skill-desc');
130-
var text = (catName + ' ' + (title ? title.textContent : '') + ' ' + (desc ? desc.textContent : '')).toLowerCase();
130+
var skillId = row.getAttribute('data-skill-id') || '';
131+
var text = (catName + ' ' + skillId + ' ' + (title ? title.textContent : '') + ' ' + (desc ? desc.textContent : '')).toLowerCase();
131132
var match = catMatch || !q || text.indexOf(q) !== -1;
132133
row.style.display = match ? '' : 'none';
133134
if (match) visible++;

src/pages/llms.txt.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ export const GET: APIRoute = () => {
1313
${absUrl('/.well-known/skills/index.json')}
1414
1515
2. When a task matches a skill's description, fetch the skill content from its url.
16-
Skills are updated frequently do not rely on a previously cached version.
16+
Skills are updated frequently - do not rely on a previously cached version.
1717
1818
Example: for the skill named "internet-identity", its url is:
1919
${absUrl('/.well-known/skills/internet-identity/SKILL.md')}
Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
1-
// Raw markdown endpoint: /skills/{slug}.md returns the unmodified SKILL.md
1+
// Raw markdown endpoint: /skills/{slug}/SKILL.md returns the unmodified SKILL.md
22
// bytes (frontmatter + body). Served with text/markdown so LLM crawlers can
33
// parse it natively. An attribution header line is prepended as a markdown
44
// comment so the origin stays visible even if the file is copy-pasted.
55

66
import type { APIRoute } from 'astro';
7-
import { getAllSkills, getSkillRawMarkdown, getSkillUpdatedAt, githubUrl } from '../../lib/skills';
8-
import { SITE, absUrl } from '../../lib/site';
7+
import { getAllSkills, getSkillRawMarkdown, getSkillUpdatedAt, githubUrl } from '../../../lib/skills';
8+
import { SITE, absUrl } from '../../../lib/site';
99

1010
export async function getStaticPaths() {
1111
const skills = await getAllSkills();

src/pages/skills/[slug]/index.astro

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,10 @@ import BaseLayout from '../../../layouts/BaseLayout.astro';
33
import { SITE, absUrl } from '../../../lib/site';
44
import {
55
getAllSkills,
6-
getSkillUpdatedAt,
7-
githubUrl,
6+
getSkillGitInfo,
7+
githubCommitUrl,
88
skillUrl,
9+
skillMarkdownUrl,
910
type Skill,
1011
} from '../../../lib/skills';
1112
import { render } from 'astro:content';
@@ -22,10 +23,10 @@ interface Props {
2223
const { skill } = Astro.props;
2324
const { Content } = await render(skill);
2425
25-
const updatedAt = await getSkillUpdatedAt(skill);
26-
const mdPath = `/skills/${skill.id}.md`;
26+
const { sha: commitHash, updatedAt } = await getSkillGitInfo(skill);
27+
const mdPath = skillMarkdownUrl(skill.id);
2728
const jsonPath = `/api/skills/${skill.id}.json`;
28-
const gh = githubUrl(skill.id);
29+
const gh = githubCommitUrl(skill.id, commitHash);
2930
const canonicalPath = skillUrl(skill.id);
3031
3132
const techArticleLd = {

0 commit comments

Comments
 (0)