Compare commits

...

4 Commits

Author SHA1 Message Date
paoloricciuti
50ddc0c015 fix: remove unneeded frontmatter 2026-03-09 17:41:31 +01:00
paoloricciuti
6b881bd607 fix: bash script 2026-03-09 17:41:17 +01:00
paoloricciuti
7f328bcfa9 fix: remove skill: true from frontmatter 2026-03-09 17:19:10 +01:00
paoloricciuti
1a63280c49 feat: add sync skill docs 2026-03-07 10:17:51 +01:00
3 changed files with 395 additions and 1 deletions

116
.github/workflows/sync-docs-skills.yml vendored Normal file
View File

@@ -0,0 +1,116 @@
name: Sync Skills
on:
workflow_dispatch:
permissions:
contents: write
pull-requests: write
actions: write
jobs:
sync-skills:
if: github.repository == 'sveltejs/ai-tools'
name: Sync skills from svelte.dev
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Setup Node.js
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version: 24
package-manager-cache: false # pnpm is not installed yet
- name: Install pnpm
shell: bash
run: |
PNPM_VER=$(jq -r '.packageManager | if .[0:5] == "pnpm@" then .[5:] else "packageManager in package.json does not start with pnpm@\n" | halt_error(1) end' package.json)
echo installing pnpm version "$PNPM_VER"
npm i -g "pnpm@$PNPM_VER"
- name: Setup Node.js with pnpm cache
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version: 24
package-manager-cache: true # caches pnpm via packageManager field in package.json
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile --prefer-offline --ignore-scripts
- name: Clone svelte.dev
run: git clone --depth 2 https://github.com/sveltejs/svelte.dev.git "${{ runner.temp }}/svelte.dev"
- name: Discover changed skill files
id: discover
env:
SVELTE_DEV_ROOT: ${{ runner.temp }}/svelte.dev
run: |
skill_files=$(git -C "$SVELTE_DEV_ROOT" diff --name-only --diff-filter=ACMR HEAD~1 HEAD | grep '^apps/svelte.dev/content/docs/.*\.md$' | xargs -I{} grep -l '^skill: *true' "$SVELTE_DEV_ROOT/{}" || true)
echo "skill_files=$skill_files" >> "$GITHUB_OUTPUT"
- name: Sync skills
if: steps.discover.outputs.skill_files != ''
env:
SVELTE_DEV_ROOT: ${{ runner.temp }}/svelte.dev
DOCS_PREFIX: apps/svelte.dev/content/docs/
run: |
for full_path in ${{ steps.discover.outputs.skill_files }}; do
file="${full_path#$SVELTE_DEV_ROOT/}"
name=$(grep '^name: ' "$full_path" | head -1 | sed 's/^name: *//')
repo="${file#$DOCS_PREFIX}"
repo="${repo#/}"
repo="${repo%%/*}"
output_dir="tools/skills/$name"
rm -rf "$output_dir"
mkdir -p "$output_dir"
pnpm resolve-references --file "$full_path" --repo "$repo" --output "$output_dir"
done
- name: Sync plugins
if: steps.discover.outputs.skill_files != ''
run: |
pnpm sync-claude-plugin
pnpm sync-cursor-plugin
pnpm sync-opencode-plugin
pnpm generate-skill-docs
pnpm bump-plugin-versions
- name: Check for changes
id: git-check
run: |
git diff --exit-code -- tools/skills/ plugins/ packages/opencode/ documentation/docs/ || echo "changed=true" >> "$GITHUB_OUTPUT"
- name: Create Pull Request
if: steps.git-check.outputs.changed == 'true'
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
with:
token: ${{ secrets.GITHUB_TOKEN }}
commit-message: 'chore: sync skills from svelte.dev'
branch: chore/sync-skills
delete-branch: true
title: 'chore: sync skills from svelte.dev'
body: |
## Summary
Automatically synced skill markdown from `sveltejs/svelte.dev` into `tools/skills/`.
## Changes
- Cloned `sveltejs/svelte.dev`
- Filtered markdown files with `skill: true` frontmatter
- Rebuilt synced skill folders with `scripts/resolve-references.ts`
- Synced `plugins/claude/svelte/` (skills, agents)
- Synced `plugins/cursor/svelte/` (skills, agents, rules)
- Synced `packages/opencode/` (skills, instructions)
- Updated documentation
## Generated by
GitHub Action: Sync Skills
labels: |
chore
automated

View File

@@ -28,7 +28,9 @@
"sync-claude-plugin": "node scripts/sync-claude-plugin.ts",
"sync-cursor-plugin": "node scripts/sync-cursor-plugin.ts",
"sync-opencode-plugin": "node scripts/sync-opencode-plugin.ts",
"bump-plugin-versions": "node scripts/bump-plugin-versions.ts"
"bump-plugin-versions": "node scripts/bump-plugin-versions.ts",
"resolve-references": "node scripts/resolve-references.ts",
"postresolve-references": "pnpm format"
},
"keywords": [
"svelte",

View File

@@ -0,0 +1,276 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import { parseArgs } from 'node:util';
const { values } = parseArgs({
options: {
file: { type: 'string', short: 'f' },
repo: { type: 'string', short: 'r' },
output: { type: 'string', short: 'o' },
},
});
const { file, repo, output } = values;
if (!file || !repo || !output) {
console.error(
'Usage: resolve-references --file <path-or-content> --repo <repo> --output <folder>',
);
process.exit(1);
}
export function remove_llm_ignore_blocks(content: string): string {
return content.replace(/<!--\s*llm-ignore-start\s*-->[\s\S]*?<!--\s*llm-ignore-end\s*-->/g, '');
}
/**
* Determines whether the input string is a file path or raw markdown content.
* If it's a file, reads and returns its content. Otherwise returns the string as-is.
*/
async function get_content(input: string) {
try {
const stat = await fs.stat(input);
if (stat.isFile()) {
return await fs.readFile(input, 'utf-8');
}
} catch {
// not a file path — treat as raw content
}
return input;
}
/**
* Extracts a section from markdown content based on a heading id (hash).
* Finds the heading whose text (lowercased, spaces replaced with `-`) matches
* the hash and returns everything from that heading up to the next heading of
* the same or higher level.
*/
function extract_section(content: string, hash: string) {
const lines = content.split('\n');
let start_index = -1;
let heading_level = 0;
for (let i = 0; i < lines.length; i++) {
const line = lines[i]!;
const heading_match = line.match(/^(#{1,6})\s+(.+)/);
if (!heading_match) continue;
const level = heading_match[1]!.length;
const text = heading_match[2]!;
const slug = text.toLowerCase().replace(/\s+/g, '-');
if (slug === hash.toLowerCase()) {
start_index = i;
heading_level = level;
continue;
}
if (start_index !== -1 && level <= heading_level) {
return lines.slice(start_index, i).join('\n').trim();
}
}
if (start_index !== -1) {
return lines.slice(start_index).join('\n').trim();
}
return content;
}
/**
* Removes the `title`, `skill`, and `NOTE` fields from markdown frontmatter, if present.
* Removes the entire frontmatter block if they were the only fields.
*/
function remove_frontmatter_unneeded_fields(content: string) {
const frontmatter_match = content.match(/^---\n([\s\S]*?)\n---\n?/);
if (!frontmatter_match) return content;
const frontmatter = frontmatter_match[1]!;
const lines = frontmatter.split('\n').filter((line) => !line.match(/^(title|skill|NOTE)\s*:/));
if (lines.length === 0) {
// frontmatter is now empty — remove the whole block
return content.slice(frontmatter_match[0].length);
}
return `---\n${lines.join('\n')}\n---\n` + content.slice(frontmatter_match[0].length);
}
/**
* Derives a file-safe name from a URL path segment.
* e.g. "some/deep/path" -> "path"
*/
function derive_name(link: string) {
const without_hash = link.split('#')[0]!;
const segments = without_hash.split('/').filter(Boolean);
return segments[segments.length - 1] ?? 'reference';
}
const content = remove_llm_ignore_blocks(
remove_frontmatter_unneeded_fields(await get_content(file)),
);
// Match markdown links that are either:
// 1. Relative paths (not starting with http://, https://, mailto:, #, or /)
// 2. Absolute /docs/ paths (e.g. /docs/svelte/each)
const relative_link_regex = /\[([^\]]*)\]\((?!https?:\/\/|mailto:|#|\/)([^)]+)\)/g;
const docs_link_regex = /\[([^\]]*)\]\((\/docs\/[^)]+)\)/g;
interface Link_Info {
full_match: string;
text: string;
href: string;
hash: string | undefined;
clean_path: string;
is_absolute_docs: boolean;
}
const links: Link_Info[] = [];
let match;
while ((match = relative_link_regex.exec(content)) !== null) {
const href = match[2]!;
const hash_index = href.indexOf('#');
const has_hash = hash_index !== -1;
links.push({
full_match: match[0],
text: match[1]!,
href,
hash: has_hash ? href.slice(hash_index + 1) : undefined,
clean_path: has_hash ? href.slice(0, hash_index) : href,
is_absolute_docs: false,
});
}
while ((match = docs_link_regex.exec(content)) !== null) {
const href = match[2]!;
const hash_index = href.indexOf('#');
const has_hash = hash_index !== -1;
links.push({
full_match: match[0],
text: match[1]!,
href,
hash: has_hash ? href.slice(hash_index + 1) : undefined,
clean_path: has_hash ? href.slice(0, hash_index) : href,
is_absolute_docs: true,
});
}
if (links.length === 0) {
console.log('No relative links found in the markdown.');
process.exit(0);
}
console.log(`Found ${links.length} relative link(s) to resolve.`);
const references_dir = path.join(output, 'references');
await fs.mkdir(references_dir, { recursive: true });
let updated_content = content;
// Track names we've already used to avoid collisions
const used_names = new Map<string, number>();
for (const link of links) {
const base_name = derive_name(link.clean_path);
const count = used_names.get(base_name) ?? 0;
used_names.set(base_name, count + 1);
const name = count > 0 ? `${base_name}-${count}` : base_name;
// For absolute /docs/ links, fetch directly from svelte.dev (supports cross-repo links).
// For relative links, prepend /docs/{repo}/.
const url = link.is_absolute_docs
? `https://svelte.dev${link.clean_path}/llms.txt`
: `https://svelte.dev/docs/${repo}/${link.clean_path}/llms.txt`;
console.log(`Fetching: ${url}${link.hash ? ` (section: #${link.hash})` : ''}`);
try {
const response = await fetch(url);
if (!response.ok) {
console.warn(` Warning: ${response.status} ${response.statusText} for ${url}`);
continue;
}
let fetched_content = await response.text();
if (link.hash) {
fetched_content = extract_section(fetched_content, link.hash);
}
const ref_filename = `${name}.md`;
const ref_path = path.join(references_dir, ref_filename);
await fs.writeFile(ref_path, remove_llm_ignore_blocks(remove_cut_preambles(fetched_content)));
console.log(` Saved: references/${ref_filename}`);
// Replace the link in the markdown
const new_link = `[${link.text}](references/${ref_filename})`;
updated_content = updated_content.replace(link.full_match, new_link);
} catch (error) {
console.warn(` Error fetching ${url}:`, error);
}
}
/**
* In fenced code blocks, removes everything from the start of the block
* up to and including a `// ---cut---` comment. If no such comment exists
* the code block is left unchanged.
*/
function remove_cut_preambles(content: string) {
const lines = content.split('\n');
const result: string[] = [];
let in_code_block = false;
let code_block_buffer: string[] = [];
let fence_line = '';
for (const line of lines) {
if (!in_code_block && line.match(/^```\w*$/)) {
in_code_block = true;
fence_line = line;
code_block_buffer = [];
continue;
}
if (in_code_block && line.match(/^```$/)) {
// End of code block — check if there was a cut comment
const cut_index = code_block_buffer.findIndex((l) => l.match(/^\s*\/\/\s*---cut---\s*$/));
result.push(fence_line);
if (cut_index !== -1) {
result.push(...code_block_buffer.slice(cut_index + 1));
} else {
result.push(...code_block_buffer);
}
result.push(line);
in_code_block = false;
code_block_buffer = [];
continue;
}
if (in_code_block) {
code_block_buffer.push(line);
} else {
result.push(line);
}
}
// If file ends mid-code-block, flush as-is
if (in_code_block) {
result.push(fence_line);
result.push(...code_block_buffer);
}
return result.join('\n');
}
// Write the updated markdown content to the output folder
updated_content = remove_cut_preambles(updated_content);
const output_filename = path.join(output, 'SKILL.md');
await fs.writeFile(output_filename, updated_content);
console.log(`\nUpdated markdown written to: ${output_filename}`);