mirror of
https://github.com/sveltejs/ai-tools.git
synced 2026-07-04 19:45:32 +08:00
Compare commits
4 Commits
opencode-s
...
add-sync-s
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
50ddc0c015 | ||
|
|
6b881bd607 | ||
|
|
7f328bcfa9 | ||
|
|
1a63280c49 |
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'@sveltejs/mcp': patch
|
||||
---
|
||||
|
||||
feat: display similar result & error at the end
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@sveltejs/opencode": patch
|
||||
---
|
||||
|
||||
feat(opencode): mcp enabled option is passed to opencode
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'@sveltejs/opencode': patch
|
||||
---
|
||||
|
||||
feat: allow enabling a specific skill in opencode plugin
|
||||
116
.github/workflows/sync-docs-skills.yml
vendored
Normal file
116
.github/workflows/sync-docs-skills.yml
vendored
Normal 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
|
||||
5
.github/workflows/sync-plugins.yml
vendored
5
.github/workflows/sync-plugins.yml
vendored
@@ -53,7 +53,7 @@ jobs:
|
||||
run: pnpm sync-cursor-plugin
|
||||
|
||||
- name: Sync OpenCode plugin
|
||||
run: pnpm sync-opencode-plugin && pnpm generate-opencode-jsonschema
|
||||
run: pnpm sync-opencode-plugin
|
||||
|
||||
- name: Generate skills documentation
|
||||
run: pnpm generate-skill-docs
|
||||
@@ -69,7 +69,6 @@ jobs:
|
||||
plugins/cursor/svelte/ \
|
||||
packages/opencode/skills/ \
|
||||
packages/opencode/instructions/ \
|
||||
packages/opencode/schema.json \
|
||||
documentation/docs/ \
|
||||
|| echo "changed=true" >> $GITHUB_OUTPUT
|
||||
|
||||
@@ -91,7 +90,7 @@ jobs:
|
||||
## Changes
|
||||
- Synced `plugins/claude/svelte/` (skills, agents with `permissionMode`)
|
||||
- Synced `plugins/cursor/svelte/` (skills, agents, rules)
|
||||
- Synced `packages/opencode/` (skills, instructions, schema)
|
||||
- Synced `packages/opencode/` (skills, instructions)
|
||||
- Updated documentation
|
||||
|
||||
## Generated by
|
||||
|
||||
@@ -32,7 +32,7 @@ The default configuration for the Svelte OpenCode plugin looks like this...
|
||||
"enabled": true
|
||||
},
|
||||
"skills": {
|
||||
"enabled": true // it can also be an array of all the skills to enable like ['svelte-core-bestpractices']
|
||||
"enabled": true
|
||||
},
|
||||
"instructions": {
|
||||
"enabled": true
|
||||
@@ -40,6 +40,6 @@ The default configuration for the Svelte OpenCode plugin looks like this...
|
||||
}
|
||||
```
|
||||
|
||||
...but if you prefer, you can enable only the subagent, only the MCP, only the skills (`enabled` supports both a boolean or an array containing the name of all the skills to enable), or configure the kind of MCP server you want to use (`local` or `remote`).
|
||||
...but if you prefer, you can enable only the subagent, only the MCP, only the skills, or configure the kind of MCP server you want to use (`local` or `remote`).
|
||||
|
||||
You can place this file in `./.opencode/svelte.json` (in your project), in `~/.config/opencode/svelte.json` or, if you have an `OPENCODE_CONFIG_DIR` environment variable specified, at `$OPENCODE_CONFIG_DIR/svelte.json`.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -91,56 +91,16 @@ export async function get_documentation_handler({
|
||||
}
|
||||
});
|
||||
|
||||
const successes = results.filter((r) => r.success);
|
||||
const failed_sections = sections.filter(
|
||||
(s) =>
|
||||
!available_sections.some(
|
||||
(a) => a.title.toLowerCase() === s.toLowerCase() || a.slug === s || a.url === s,
|
||||
),
|
||||
);
|
||||
const has_any_success = results.some((result) => result.success);
|
||||
let final_text = results.map((r) => r.content).join('\n\n---\n\n');
|
||||
|
||||
if (successes.length > 0 && failed_sections.length === 0) {
|
||||
return successes.map((r) => r.content).join('\n\n---\n\n');
|
||||
}
|
||||
|
||||
const parts: string[] = [];
|
||||
|
||||
if (successes.length > 0) {
|
||||
parts.push(successes.map((r) => r.content).join('\n\n---\n\n'));
|
||||
}
|
||||
|
||||
const fuzzy_results = failed_sections.map((requested) => {
|
||||
const lower = requested.toLowerCase();
|
||||
const matches = available_sections.filter(
|
||||
(a) =>
|
||||
a.title.toLowerCase().includes(lower) ||
|
||||
a.slug.includes(lower) ||
|
||||
lower.includes(a.slug.split('/').pop() ?? '') ||
|
||||
a.use_cases.toLowerCase().includes(lower),
|
||||
);
|
||||
return { requested, matches };
|
||||
});
|
||||
|
||||
const has_fuzzy = fuzzy_results.some((r) => r.matches.length > 0);
|
||||
|
||||
// Full list only when no successes and no fuzzy matches
|
||||
if (successes.length === 0 && !has_fuzzy) {
|
||||
if (!has_any_success) {
|
||||
const formatted_sections = await format_sections_list();
|
||||
parts.push(`${SECTIONS_LIST_INTRO}\n\n${formatted_sections}\n\n${SECTIONS_LIST_OUTRO}`);
|
||||
|
||||
final_text += `\n\n---\n\n${SECTIONS_LIST_INTRO}\n\n${formatted_sections}\n\n${SECTIONS_LIST_OUTRO}`;
|
||||
}
|
||||
|
||||
// Similar results then errors
|
||||
for (const { requested, matches } of fuzzy_results) {
|
||||
if (matches.length > 0) {
|
||||
const match_list = matches.map((m) => `- title: ${m.title}, section: ${m.slug}`).join('\n');
|
||||
parts.push(
|
||||
`${matches.length} similar result${matches.length > 1 ? 's' : ''} for "${requested}":\n${match_list}`,
|
||||
);
|
||||
}
|
||||
parts.push(`Section not found: "${requested}".`);
|
||||
}
|
||||
|
||||
return parts.join('\n\n---\n\n');
|
||||
return final_text;
|
||||
}
|
||||
|
||||
export function get_documentation(server: SvelteMcp) {
|
||||
|
||||
@@ -16,57 +16,31 @@ const default_config = {
|
||||
enabled: true,
|
||||
},
|
||||
skills: {
|
||||
enabled: true as boolean | string[],
|
||||
enabled: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const config_schema = v.object({
|
||||
mcp: v.pipe(
|
||||
v.optional(
|
||||
v.object({
|
||||
type: v.optional(v.picklist(['remote', 'local'])),
|
||||
enabled: v.optional(v.boolean()),
|
||||
}),
|
||||
),
|
||||
v.description(
|
||||
"Configuration for the MCP. You can chose if it should be enabled or not and the transport to use 'remote' (default) and 'local'.",
|
||||
),
|
||||
mcp: v.optional(
|
||||
v.object({
|
||||
type: v.optional(v.picklist(['remote', 'local'])),
|
||||
enabled: v.optional(v.boolean()),
|
||||
}),
|
||||
),
|
||||
subagent: v.pipe(
|
||||
v.optional(
|
||||
v.object({
|
||||
enabled: v.optional(v.boolean()),
|
||||
}),
|
||||
),
|
||||
v.description('Configuration for the subagent. You can choose if it should be enabled or not.'),
|
||||
subagent: v.optional(
|
||||
v.object({
|
||||
enabled: v.optional(v.boolean()),
|
||||
}),
|
||||
),
|
||||
instructions: v.pipe(
|
||||
v.optional(
|
||||
v.object({
|
||||
enabled: v.optional(v.boolean()),
|
||||
}),
|
||||
),
|
||||
v.description(
|
||||
'Configuration for the automatic AGENTS.md injection. You can choose if it should be enabled or not.',
|
||||
),
|
||||
v.description(
|
||||
'Configuration for the automatic AGENTS.md injection. You can choose if it should be enabled or not.',
|
||||
),
|
||||
instructions: v.optional(
|
||||
v.object({
|
||||
enabled: v.optional(v.boolean()),
|
||||
}),
|
||||
),
|
||||
skills: v.pipe(
|
||||
v.optional(
|
||||
v.object({
|
||||
enabled: v.pipe(
|
||||
v.optional(v.union([v.boolean(), v.array(v.string())])),
|
||||
v.description(
|
||||
'It can be either a boolean or an array containing the skills that you want to enable',
|
||||
),
|
||||
),
|
||||
}),
|
||||
),
|
||||
v.description(
|
||||
'Configuration for the skills. You can choose if it they should be enabled or not, or specify an array of skill names to enable only specific skills.',
|
||||
),
|
||||
skills: v.optional(
|
||||
v.object({
|
||||
enabled: v.optional(v.boolean()),
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
|
||||
@@ -39,35 +39,23 @@ export const svelte_plugin: Plugin = async (ctx) => {
|
||||
input.instructions.push(...instructions_paths.map((file) => join(instructions_dir, file)));
|
||||
}
|
||||
|
||||
const skills_enabled = mcp_config.skills?.enabled;
|
||||
if (skills_enabled !== false) {
|
||||
if (mcp_config.skills?.enabled !== false) {
|
||||
const skills_dir = join(current_dir, 'skills');
|
||||
if (Array.isArray(skills_enabled)) {
|
||||
// only add specific skill directories by name
|
||||
for (const skill_name of skills_enabled) {
|
||||
const skill_path = join(skills_dir, skill_name);
|
||||
// @ts-expect-error -- skills is a new opencode feature
|
||||
input.skills.paths.push(skill_path);
|
||||
}
|
||||
} else {
|
||||
// @ts-expect-error -- skills is a new opencode feature
|
||||
input.skills.paths.push(skills_dir);
|
||||
}
|
||||
// @ts-expect-error -- skills is a new opencode feature
|
||||
input.skills.paths.push(skills_dir);
|
||||
}
|
||||
|
||||
// if the user doesn't have the MCP server already we add one based on config
|
||||
if (!input.mcp[svelte_mcp_name]) {
|
||||
if (!input.mcp[svelte_mcp_name] && mcp_config.mcp?.enabled !== false) {
|
||||
if (mcp_config.mcp?.type === 'remote') {
|
||||
input.mcp[svelte_mcp_name] = {
|
||||
type: 'remote',
|
||||
url: 'https://mcp.svelte.dev/mcp',
|
||||
enabled: mcp_config.mcp?.enabled ?? true,
|
||||
};
|
||||
} else {
|
||||
input.mcp[svelte_mcp_name] = {
|
||||
type: 'local',
|
||||
command: ['npx', '-y', '@sveltejs/mcp'],
|
||||
enabled: mcp_config.mcp?.enabled ?? true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,8 +14,7 @@
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [],
|
||||
"description": "Configuration for the MCP. You can chose if it should be enabled or not and the transport to use 'remote' (default) and 'local'."
|
||||
"required": []
|
||||
},
|
||||
"subagent": {
|
||||
"type": "object",
|
||||
@@ -24,8 +23,7 @@
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [],
|
||||
"description": "Configuration for the subagent. You can choose if it should be enabled or not."
|
||||
"required": []
|
||||
},
|
||||
"instructions": {
|
||||
"type": "object",
|
||||
@@ -34,39 +32,16 @@
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [],
|
||||
"description": "Configuration for the automatic AGENTS.md injection. You can choose if it should be enabled or not."
|
||||
"required": []
|
||||
},
|
||||
"skills": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "boolean"
|
||||
},
|
||||
{
|
||||
"type": "array",
|
||||
"items": {
|
||||
"anyOf": [
|
||||
{
|
||||
"enum": [
|
||||
"svelte-code-writer",
|
||||
"svelte-core-bestpractices"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"description": "It can be either a boolean or an array containing the skills that you want to enable"
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [],
|
||||
"description": "Configuration for the skills. You can choose if it they should be enabled or not, or specify an array of skill names to enable only specific skills."
|
||||
"required": []
|
||||
}
|
||||
},
|
||||
"required": [],
|
||||
|
||||
@@ -3,33 +3,6 @@ import { config_schema } from '../config.js';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
function get_skill_names(skills_dir: string) {
|
||||
if (!fs.existsSync(skills_dir)) return [];
|
||||
return fs
|
||||
.readdirSync(skills_dir, { withFileTypes: true })
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.map((entry) => entry.name);
|
||||
}
|
||||
|
||||
const skills_dir = path.resolve('./skills');
|
||||
const skill_names = get_skill_names(skills_dir);
|
||||
const schema = config_schema;
|
||||
const json_schema = toJsonSchema(schema);
|
||||
|
||||
// Post-process: inject skill name suggestions into the items schema.
|
||||
// This is the JSON Schema equivalent of `"a" | "b" | (string & {})` —
|
||||
// editors will autocomplete the known names but any string is still valid.
|
||||
if (skill_names.length > 0) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const enabled = (json_schema as any).properties?.skills?.properties?.enabled;
|
||||
if (enabled?.anyOf) {
|
||||
const array_branch = enabled.anyOf.find((s: Record<string, unknown>) => s.type === 'array');
|
||||
if (array_branch) {
|
||||
array_branch.items = {
|
||||
anyOf: [{ enum: skill_names }, { type: 'string' }],
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
const json_schema = toJsonSchema(config_schema);
|
||||
|
||||
fs.writeFileSync(path.resolve('./schema.json'), JSON.stringify(json_schema, null, '\t'));
|
||||
|
||||
276
scripts/resolve-references.ts
Normal file
276
scripts/resolve-references.ts
Normal 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}`);
|
||||
Reference in New Issue
Block a user