web-skill 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +194 -0
- package/package.json +53 -0
- package/skills/use-web-skill/SKILL.md +41 -0
- package/src/index.ts +20 -0
- package/src/markdown.ts +230 -0
- package/src/runtime.ts +185 -0
- package/src/types.ts +89 -0
- package/src/utils.ts +90 -0
- package/src/vite-plugin.ts +91 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 dimbreak
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
# web-skill
|
|
2
|
+
|
|
3
|
+
`web-skill` helps a web app publish task-level automation APIs for agents and browser tooling.
|
|
4
|
+
|
|
5
|
+
From one shared config it can:
|
|
6
|
+
|
|
7
|
+
- register functions under `window._web_skills`
|
|
8
|
+
- generate one `SKILL.md` file per published skill
|
|
9
|
+
- inject `<link rel="web-skill" ...>` tags into the HTML `<head>` through Vite
|
|
10
|
+
|
|
11
|
+
The package is source-first and works best in TypeScript apps that already use Vite and Zod.
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npm install web-skill zod
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
If you want the Vite plugin in your own project:
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
npm install vite
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Quick start
|
|
26
|
+
|
|
27
|
+
Create one generator, point it at the app actions you already trust, and install it at startup:
|
|
28
|
+
|
|
29
|
+
```ts
|
|
30
|
+
import { createWebSkillGenerator } from "web-skill";
|
|
31
|
+
import { z } from "zod";
|
|
32
|
+
import { useProcurementStore } from "./stores/procurement-store";
|
|
33
|
+
|
|
34
|
+
const webSkills = createWebSkillGenerator();
|
|
35
|
+
const procurementStore = useProcurementStore;
|
|
36
|
+
|
|
37
|
+
const procurementSkill = webSkills.newSkill({
|
|
38
|
+
name: "erpProcurement",
|
|
39
|
+
title: "ERP procurement console API for supplier lookup and purchase order preparation",
|
|
40
|
+
description: "Expose ERP procurement actions to browser agents and the dev console.",
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
procurementSkill.addFunction(
|
|
44
|
+
(input) => procurementStore.getState().findSupplierItem(input),
|
|
45
|
+
"findSupplierItem",
|
|
46
|
+
{
|
|
47
|
+
description: "Search supplier catalog items and return normalized matches.",
|
|
48
|
+
inputSchema: z.object({
|
|
49
|
+
supplierId: z.string().min(1).optional(),
|
|
50
|
+
keyword: z.string().min(1).optional(),
|
|
51
|
+
activeOnly: z.boolean().optional(),
|
|
52
|
+
limit: z.number().int().positive().optional(),
|
|
53
|
+
}),
|
|
54
|
+
outputSchema: z.array(
|
|
55
|
+
z.object({
|
|
56
|
+
itemId: z.string(),
|
|
57
|
+
sku: z.string(),
|
|
58
|
+
itemName: z.string(),
|
|
59
|
+
unitCost: z.number().nullable(),
|
|
60
|
+
}),
|
|
61
|
+
),
|
|
62
|
+
},
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
procurementSkill
|
|
66
|
+
.addFunction(
|
|
67
|
+
(input) => procurementStore.getState().createPurchaseOrderDraft(input),
|
|
68
|
+
"createPurchaseOrderDraft",
|
|
69
|
+
{
|
|
70
|
+
description: "Prepare a purchase order draft and navigate to the ERP purchase order screen.",
|
|
71
|
+
inputSchema: z.object({
|
|
72
|
+
supplierId: z.string().min(1),
|
|
73
|
+
requesterId: z.string().min(1),
|
|
74
|
+
currency: z.string().min(1),
|
|
75
|
+
lines: z.array(
|
|
76
|
+
z.object({
|
|
77
|
+
itemId: z.string(),
|
|
78
|
+
quantity: z.number().positive(),
|
|
79
|
+
expectedUnitCost: z.number().nonnegative().optional(),
|
|
80
|
+
}),
|
|
81
|
+
),
|
|
82
|
+
}),
|
|
83
|
+
outputSchema: z.object({
|
|
84
|
+
draftId: z.string(),
|
|
85
|
+
route: z.string(),
|
|
86
|
+
}),
|
|
87
|
+
},
|
|
88
|
+
)
|
|
89
|
+
.addFunction(
|
|
90
|
+
(input) => procurementStore.getState().submitPurchaseRequisition(input),
|
|
91
|
+
"submitPurchaseRequisition",
|
|
92
|
+
{
|
|
93
|
+
description: "Create and submit a purchase requisition from the current selection.",
|
|
94
|
+
inputSchema: z.object({
|
|
95
|
+
departmentCode: z.string().min(1),
|
|
96
|
+
neededByDate: z.string().min(1),
|
|
97
|
+
lines: z.array(
|
|
98
|
+
z.object({
|
|
99
|
+
itemId: z.string(),
|
|
100
|
+
quantity: z.number().int().positive().optional(),
|
|
101
|
+
}),
|
|
102
|
+
),
|
|
103
|
+
}),
|
|
104
|
+
},
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
webSkills.install();
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
That exposes browser-callable functions like:
|
|
111
|
+
|
|
112
|
+
```js
|
|
113
|
+
window._web_skills.erpProcurement.findSupplierItem(input)
|
|
114
|
+
window._web_skills.erpProcurement.createPurchaseOrderDraft(input)
|
|
115
|
+
window._web_skills.erpProcurement.submitPurchaseRequisition(input)
|
|
116
|
+
window._web_skills.erpProcurement._meta
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
## Vite integration
|
|
120
|
+
|
|
121
|
+
Use the Vite plugin with the same generator instance:
|
|
122
|
+
|
|
123
|
+
```ts
|
|
124
|
+
import { defineConfig } from "vite";
|
|
125
|
+
import react from "@vitejs/plugin-react";
|
|
126
|
+
import { webSkillVitePlugin } from "web-skill";
|
|
127
|
+
import { webSkills } from "./src/web-skills.ts";
|
|
128
|
+
|
|
129
|
+
export default defineConfig({
|
|
130
|
+
plugins: [
|
|
131
|
+
react(),
|
|
132
|
+
webSkillVitePlugin({
|
|
133
|
+
generator: webSkills,
|
|
134
|
+
}),
|
|
135
|
+
],
|
|
136
|
+
});
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
At dev/build time the plugin will:
|
|
140
|
+
|
|
141
|
+
- generate `/skills/<skill-slug>/SKILL.md` for every configured skill
|
|
142
|
+
- serve those markdown files in Vite dev
|
|
143
|
+
- emit those markdown files as build assets
|
|
144
|
+
- inject one `<link rel="web-skill" ...>` tag per skill into the HTML `<head>`
|
|
145
|
+
|
|
146
|
+
## What gets generated
|
|
147
|
+
|
|
148
|
+
Each generated `SKILL.md` contains:
|
|
149
|
+
|
|
150
|
+
- the skill title
|
|
151
|
+
- the console entrypoint under `window._web_skills.<skillKey>`
|
|
152
|
+
- one section per function
|
|
153
|
+
- summarized input and output schemas
|
|
154
|
+
|
|
155
|
+
The repository also includes [`skills/use-web-skill/SKILL.md`](./skills/use-web-skill/SKILL.md), a discovery workflow for agents that inspect `<head>` before automating a page.
|
|
156
|
+
|
|
157
|
+
## Design guidance
|
|
158
|
+
|
|
159
|
+
`web-skill` works best when you expose task-level actions instead of raw UI primitives.
|
|
160
|
+
|
|
161
|
+
Good examples:
|
|
162
|
+
|
|
163
|
+
- `findCustomer`
|
|
164
|
+
- `openInvoiceDraft`
|
|
165
|
+
- `fillPurchaseOrderForm`
|
|
166
|
+
- `submitCurrentApproval`
|
|
167
|
+
|
|
168
|
+
Avoid low-level wrappers like:
|
|
169
|
+
|
|
170
|
+
- `clickSaveButton`
|
|
171
|
+
- `setInputValue`
|
|
172
|
+
- `selectTableRow`
|
|
173
|
+
|
|
174
|
+
The goal is to publish a stable, browser-callable API even when the underlying page is old, DOM-heavy, or inconsistent.
|
|
175
|
+
|
|
176
|
+
## Safety
|
|
177
|
+
|
|
178
|
+
Treat destructive actions as special cases:
|
|
179
|
+
|
|
180
|
+
- require explicit confirmation fields for submit/delete/finalize flows
|
|
181
|
+
- validate inputs with Zod whenever possible
|
|
182
|
+
- return structured results instead of relying on toast text
|
|
183
|
+
- avoid exposing unstable internal helpers just because they are easy to call
|
|
184
|
+
|
|
185
|
+
## Local development
|
|
186
|
+
|
|
187
|
+
```bash
|
|
188
|
+
npm install
|
|
189
|
+
npm run check
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
## License
|
|
193
|
+
|
|
194
|
+
[MIT](./LICENSE)
|
package/package.json
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "web-skill",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Generate browser-advertised skills, runtime registries, and Vite head tags for agent-friendly web apps.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "./src/index.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": "./src/index.ts",
|
|
10
|
+
"./vite": "./src/vite-plugin.ts"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"src",
|
|
14
|
+
"skills",
|
|
15
|
+
"README.md",
|
|
16
|
+
"LICENSE"
|
|
17
|
+
],
|
|
18
|
+
"sideEffects": false,
|
|
19
|
+
"engines": {
|
|
20
|
+
"node": ">=20"
|
|
21
|
+
},
|
|
22
|
+
"scripts": {
|
|
23
|
+
"check": "npm run typecheck",
|
|
24
|
+
"typecheck": "tsc --noEmit -p tsconfig.json"
|
|
25
|
+
},
|
|
26
|
+
"repository": {
|
|
27
|
+
"type": "git",
|
|
28
|
+
"url": "git+https://github.com/dimbreak/webskill.git"
|
|
29
|
+
},
|
|
30
|
+
"bugs": {
|
|
31
|
+
"url": "https://github.com/dimbreak/webskill/issues"
|
|
32
|
+
},
|
|
33
|
+
"homepage": "https://github.com/dimbreak/webskill#readme",
|
|
34
|
+
"keywords": [
|
|
35
|
+
"agent",
|
|
36
|
+
"automation",
|
|
37
|
+
"browser",
|
|
38
|
+
"skills",
|
|
39
|
+
"vite",
|
|
40
|
+
"zod"
|
|
41
|
+
],
|
|
42
|
+
"peerDependencies": {
|
|
43
|
+
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0"
|
|
44
|
+
},
|
|
45
|
+
"dependencies": {
|
|
46
|
+
"zod": "^3.24.4"
|
|
47
|
+
},
|
|
48
|
+
"devDependencies": {
|
|
49
|
+
"@types/node": "^24.3.3",
|
|
50
|
+
"typescript": "^5.7.3",
|
|
51
|
+
"vite": "^7.0.0"
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: use-web-skill
|
|
3
|
+
description: Discover and use browser-advertised skills exposed through HTML head tags like `<link rel="web-skill" href=".../SKILL.md" title="...">`. Use when Codex is browsing or automating a website with Browser MCP, Playwright, or other browser tooling and should check whether the page publishes a task-relevant skill before continuing with normal interaction.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# use-web-skill
|
|
7
|
+
|
|
8
|
+
When working on a live webpage with browser tooling, check the document `<head>` before deeper interaction.
|
|
9
|
+
|
|
10
|
+
## Workflow
|
|
11
|
+
|
|
12
|
+
1. Read the page head and look for:
|
|
13
|
+
- `<link rel="web-skill" ...>`
|
|
14
|
+
- especially `href` and `title`
|
|
15
|
+
2. Compare each link `title` against the current task.
|
|
16
|
+
3. If a title looks relevant, fetch the linked `SKILL.md`.
|
|
17
|
+
4. Read the downloaded skill and apply its instructions for the rest of the browser task.
|
|
18
|
+
5. If no relevant `web-skill` link exists, continue with the normal browser workflow.
|
|
19
|
+
|
|
20
|
+
## Selection rules
|
|
21
|
+
|
|
22
|
+
- Prefer the most task-specific title.
|
|
23
|
+
- Prefer exact domain or product matches over generic titles.
|
|
24
|
+
- If multiple links look useful, start with the closest match rather than loading everything.
|
|
25
|
+
- Ignore links with vague titles unless the task is also vague.
|
|
26
|
+
|
|
27
|
+
## Fetch rules
|
|
28
|
+
|
|
29
|
+
- Resolve relative `href` values against the current page URL.
|
|
30
|
+
- Treat the linked file as the source of truth for browser-specific workflow.
|
|
31
|
+
- If the file cannot be fetched, note that and fall back to standard browsing.
|
|
32
|
+
|
|
33
|
+
## Browser MCP guidance
|
|
34
|
+
|
|
35
|
+
- Use Browser MCP or the active browser tool to inspect `<head>` content first.
|
|
36
|
+
- Only switch into the linked skill workflow after confirming the title is relevant.
|
|
37
|
+
- Keep the fallback simple: no relevant link means no skill handoff.
|
|
38
|
+
|
|
39
|
+
## Output expectation
|
|
40
|
+
|
|
41
|
+
After discovering a relevant `web-skill`, continue the task using that downloaded `SKILL.md` as the active operating guide.
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export { generateSkillMarkdown, renderZodSchema } from "./markdown.ts";
|
|
2
|
+
export { createWebSkillGenerator } from "./runtime.ts";
|
|
3
|
+
export { webSkillVitePlugin } from "./vite-plugin.ts";
|
|
4
|
+
export type {
|
|
5
|
+
AddWebSkillFunctionOptions,
|
|
6
|
+
NewWebSkillOptions,
|
|
7
|
+
ResolvedWebSkillDefinition,
|
|
8
|
+
ResolvedWebSkillFunctionDefinition,
|
|
9
|
+
WebSkillBuilder,
|
|
10
|
+
WebSkillFunctionDefinition,
|
|
11
|
+
WebSkillFunctionHandler,
|
|
12
|
+
WebSkillGenerator,
|
|
13
|
+
WebSkillLinkTag,
|
|
14
|
+
WebSkillMetadata,
|
|
15
|
+
WebSkillsWindowShape,
|
|
16
|
+
WebSkillVitePluginOptions,
|
|
17
|
+
WebSkillWindowEntry,
|
|
18
|
+
WebSkillWindowFunction,
|
|
19
|
+
} from "./types.ts";
|
|
20
|
+
export { buildWebSkillLinkTags } from "./utils.ts";
|
package/src/markdown.ts
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import type { ZodTypeAny } from "zod";
|
|
2
|
+
|
|
3
|
+
import type { ResolvedWebSkillDefinition, ResolvedWebSkillFunctionDefinition } from "./types.ts";
|
|
4
|
+
|
|
5
|
+
const INDENT = " ";
|
|
6
|
+
|
|
7
|
+
interface LooseZodDefinition {
|
|
8
|
+
typeName?: string;
|
|
9
|
+
[key: string]: unknown;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function generateSkillMarkdown(skill: ResolvedWebSkillDefinition): string {
|
|
13
|
+
const description =
|
|
14
|
+
skill.description
|
|
15
|
+
?? `Expose browser-callable functions under \`window._web_skills.${skill.key}\`.`;
|
|
16
|
+
|
|
17
|
+
const lines: string[] = [
|
|
18
|
+
"---",
|
|
19
|
+
`name: ${skill.slug}`,
|
|
20
|
+
`description: ${escapeFrontmatterValue(description)}`,
|
|
21
|
+
"---",
|
|
22
|
+
"",
|
|
23
|
+
`# ${skill.title}`,
|
|
24
|
+
"",
|
|
25
|
+
"Use the browser console entrypoint:",
|
|
26
|
+
"",
|
|
27
|
+
"```js",
|
|
28
|
+
`window._web_skills.${skill.key}`,
|
|
29
|
+
"```",
|
|
30
|
+
"",
|
|
31
|
+
"Available functions:",
|
|
32
|
+
"",
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
for (const definition of skill.functions) {
|
|
36
|
+
lines.push(...renderFunctionSection(skill, definition), "");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return `${lines.join("\n").trimEnd()}\n`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function renderFunctionSection(
|
|
43
|
+
skill: ResolvedWebSkillDefinition,
|
|
44
|
+
definition: ResolvedWebSkillFunctionDefinition,
|
|
45
|
+
): string[] {
|
|
46
|
+
const inputSchema = definition.inputSchema ? renderZodSchema(definition.inputSchema) : "unknown";
|
|
47
|
+
const outputSchema = definition.outputSchema ? renderZodSchema(definition.outputSchema) : "unknown";
|
|
48
|
+
|
|
49
|
+
return [
|
|
50
|
+
`## \`${definition.name}(input)\``,
|
|
51
|
+
"",
|
|
52
|
+
`Purpose: ${definition.description ?? `Invoke \`window._web_skills.${skill.key}.${definition.name}(input)\`.`}`,
|
|
53
|
+
"",
|
|
54
|
+
"Input:",
|
|
55
|
+
"",
|
|
56
|
+
"```ts",
|
|
57
|
+
inputSchema,
|
|
58
|
+
"```",
|
|
59
|
+
"",
|
|
60
|
+
"Output:",
|
|
61
|
+
"",
|
|
62
|
+
"```ts",
|
|
63
|
+
outputSchema,
|
|
64
|
+
"```",
|
|
65
|
+
];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function renderZodSchema(schema: ZodTypeAny): string {
|
|
69
|
+
return renderSchema(schema, 0);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function renderSchema(schema: ZodTypeAny, depth: number): string {
|
|
73
|
+
const definition = getDefinition(schema);
|
|
74
|
+
const typeName = definition?.typeName;
|
|
75
|
+
|
|
76
|
+
switch (typeName) {
|
|
77
|
+
case "ZodString":
|
|
78
|
+
return "string";
|
|
79
|
+
case "ZodNumber":
|
|
80
|
+
return "number";
|
|
81
|
+
case "ZodBoolean":
|
|
82
|
+
return "boolean";
|
|
83
|
+
case "ZodBigInt":
|
|
84
|
+
return "bigint";
|
|
85
|
+
case "ZodDate":
|
|
86
|
+
return "Date";
|
|
87
|
+
case "ZodUndefined":
|
|
88
|
+
return "undefined";
|
|
89
|
+
case "ZodNull":
|
|
90
|
+
return "null";
|
|
91
|
+
case "ZodVoid":
|
|
92
|
+
return "void";
|
|
93
|
+
case "ZodAny":
|
|
94
|
+
return "any";
|
|
95
|
+
case "ZodUnknown":
|
|
96
|
+
return "unknown";
|
|
97
|
+
case "ZodNever":
|
|
98
|
+
return "never";
|
|
99
|
+
case "ZodLiteral":
|
|
100
|
+
return JSON.stringify(definition?.value);
|
|
101
|
+
case "ZodEnum":
|
|
102
|
+
return Array.isArray(definition?.values)
|
|
103
|
+
? definition.values.map((value) => JSON.stringify(value)).join(" | ")
|
|
104
|
+
: "string";
|
|
105
|
+
case "ZodNativeEnum":
|
|
106
|
+
return renderNativeEnum(
|
|
107
|
+
definition?.values && typeof definition.values === "object"
|
|
108
|
+
? (definition.values as Record<string, string | number>)
|
|
109
|
+
: undefined,
|
|
110
|
+
);
|
|
111
|
+
case "ZodArray":
|
|
112
|
+
return `${wrapType(renderSchema(definition?.type as ZodTypeAny, depth))}[]`;
|
|
113
|
+
case "ZodOptional":
|
|
114
|
+
return `${renderSchema(definition?.innerType as ZodTypeAny, depth)} | undefined`;
|
|
115
|
+
case "ZodNullable":
|
|
116
|
+
return `${renderSchema(definition?.innerType as ZodTypeAny, depth)} | null`;
|
|
117
|
+
case "ZodDefault":
|
|
118
|
+
case "ZodCatch":
|
|
119
|
+
return renderSchema(definition?.innerType as ZodTypeAny, depth);
|
|
120
|
+
case "ZodEffects":
|
|
121
|
+
return renderSchema(definition?.schema as ZodTypeAny, depth);
|
|
122
|
+
case "ZodBranded":
|
|
123
|
+
return renderSchema(definition?.type as ZodTypeAny, depth);
|
|
124
|
+
case "ZodUnion":
|
|
125
|
+
return Array.isArray(definition?.options)
|
|
126
|
+
? definition.options.map((option) => wrapType(renderSchema(option as ZodTypeAny, depth))).join(" | ")
|
|
127
|
+
: "unknown";
|
|
128
|
+
case "ZodDiscriminatedUnion":
|
|
129
|
+
return Array.from(
|
|
130
|
+
definition?.options instanceof Map ? definition.options.values() : [],
|
|
131
|
+
)
|
|
132
|
+
.map((option) => wrapType(renderSchema(option as ZodTypeAny, depth)))
|
|
133
|
+
.join(" | ");
|
|
134
|
+
case "ZodIntersection":
|
|
135
|
+
return `${wrapType(renderSchema(definition?.left as ZodTypeAny, depth))} & ${wrapType(renderSchema(definition?.right as ZodTypeAny, depth))}`;
|
|
136
|
+
case "ZodTuple":
|
|
137
|
+
return `[${Array.isArray(definition?.items)
|
|
138
|
+
? definition.items.map((item) => renderSchema(item as ZodTypeAny, depth)).join(", ")
|
|
139
|
+
: ""}]`;
|
|
140
|
+
case "ZodRecord":
|
|
141
|
+
return `Record<${renderRecordKey(definition?.keyType as ZodTypeAny | undefined)}, ${renderSchema(definition?.valueType as ZodTypeAny, depth)}>`;
|
|
142
|
+
case "ZodObject":
|
|
143
|
+
return renderObjectSchema(schema, depth);
|
|
144
|
+
case "ZodLazy":
|
|
145
|
+
return typeof definition?.getter === "function"
|
|
146
|
+
? renderSchema(definition.getter() as ZodTypeAny, depth)
|
|
147
|
+
: "unknown";
|
|
148
|
+
default:
|
|
149
|
+
return "unknown";
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function renderObjectSchema(schema: ZodTypeAny, depth: number): string {
|
|
154
|
+
const definition = getDefinition(schema);
|
|
155
|
+
const shapeSource = definition?.shape;
|
|
156
|
+
const shape =
|
|
157
|
+
typeof shapeSource === "function"
|
|
158
|
+
? (shapeSource() as Record<string, ZodTypeAny>)
|
|
159
|
+
: ((shapeSource ?? {}) as Record<string, ZodTypeAny>);
|
|
160
|
+
const entries = Object.entries(shape);
|
|
161
|
+
|
|
162
|
+
if (entries.length === 0) {
|
|
163
|
+
return "{}";
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const propertyLines = entries.map(([key, propertySchema]) => {
|
|
167
|
+
const optional = isOptionalSchema(propertySchema);
|
|
168
|
+
const renderedType = renderSchema(optional ? unwrapOptionalSchema(propertySchema) : propertySchema, depth + 1);
|
|
169
|
+
return `${indent(depth + 1)}${quotePropertyKey(key)}${optional ? "?" : ""}: ${renderedType};`;
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
return ["{", ...propertyLines, `${indent(depth)}}`].join("\n");
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function renderNativeEnum(values: Record<string, string | number> | undefined): string {
|
|
176
|
+
if (!values) {
|
|
177
|
+
return "string | number";
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const rendered = Array.from(
|
|
181
|
+
new Set(
|
|
182
|
+
Object.values(values).filter(
|
|
183
|
+
(value): value is string | number => typeof value === "string" || typeof value === "number",
|
|
184
|
+
),
|
|
185
|
+
),
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
return rendered.map((value) => JSON.stringify(value)).join(" | ") || "string | number";
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function renderRecordKey(keyType: ZodTypeAny | undefined): string {
|
|
192
|
+
if (!keyType) {
|
|
193
|
+
return "string";
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return renderSchema(keyType, 0);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function isOptionalSchema(schema: ZodTypeAny): boolean {
|
|
200
|
+
return typeof schema.isOptional === "function" ? schema.isOptional() : getDefinition(schema)?.typeName === "ZodOptional";
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function unwrapOptionalSchema(schema: ZodTypeAny): ZodTypeAny {
|
|
204
|
+
const definition = getDefinition(schema);
|
|
205
|
+
if (definition?.typeName === "ZodOptional") {
|
|
206
|
+
return definition.innerType as ZodTypeAny;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return schema;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function wrapType(value: string): string {
|
|
213
|
+
return /[|&\n]/u.test(value) ? `(${value})` : value;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function quotePropertyKey(value: string): string {
|
|
217
|
+
return /^[A-Za-z_$][A-Za-z0-9_$]*$/u.test(value) ? value : JSON.stringify(value);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function indent(depth: number): string {
|
|
221
|
+
return INDENT.repeat(depth);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function getDefinition(schema: ZodTypeAny): LooseZodDefinition | undefined {
|
|
225
|
+
return (schema as unknown as { _def?: LooseZodDefinition })._def;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function escapeFrontmatterValue(value: string): string {
|
|
229
|
+
return JSON.stringify(value);
|
|
230
|
+
}
|
package/src/runtime.ts
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import type { ZodTypeAny } from "zod";
|
|
2
|
+
|
|
3
|
+
import type {
|
|
4
|
+
AddWebSkillFunctionOptions,
|
|
5
|
+
NewWebSkillOptions,
|
|
6
|
+
ResolvedWebSkillDefinition,
|
|
7
|
+
ResolvedWebSkillFunctionDefinition,
|
|
8
|
+
WebSkillBuilder,
|
|
9
|
+
WebSkillFunctionDefinition,
|
|
10
|
+
WebSkillFunctionHandler,
|
|
11
|
+
WebSkillGenerator,
|
|
12
|
+
WebSkillMetadata,
|
|
13
|
+
WebSkillsWindowShape,
|
|
14
|
+
WebSkillWindowEntry,
|
|
15
|
+
WebSkillWindowFunction,
|
|
16
|
+
} from "./types.ts";
|
|
17
|
+
import { ensureUniqueString, slugifySkillSegment, titleFromName, toSkillKey } from "./utils.ts";
|
|
18
|
+
|
|
19
|
+
interface MutableWebSkillDefinition {
|
|
20
|
+
description: string | null;
|
|
21
|
+
functions: WebSkillFunctionDefinition<any, any>[];
|
|
22
|
+
key: string;
|
|
23
|
+
name: string;
|
|
24
|
+
slug: string;
|
|
25
|
+
title: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
class WebSkillBuilderImpl implements WebSkillBuilder {
|
|
29
|
+
constructor(private readonly skill: MutableWebSkillDefinition) {}
|
|
30
|
+
|
|
31
|
+
addFunction<TInput, TOutput>(
|
|
32
|
+
func: WebSkillFunctionHandler<TInput, TOutput>,
|
|
33
|
+
name: string,
|
|
34
|
+
options: AddWebSkillFunctionOptions<TInput, TOutput> = {},
|
|
35
|
+
): WebSkillBuilder {
|
|
36
|
+
if (typeof func !== "function") {
|
|
37
|
+
throw new TypeError(`web-skill function "${name}" must be a function.`);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const normalizedName = name.trim();
|
|
41
|
+
if (!normalizedName) {
|
|
42
|
+
throw new TypeError("web-skill function name must not be empty.");
|
|
43
|
+
}
|
|
44
|
+
if (normalizedName === "_meta") {
|
|
45
|
+
throw new TypeError('web-skill function name "_meta" is reserved.');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const existing = this.skill.functions.find((definition) => definition.name === normalizedName);
|
|
49
|
+
if (existing) {
|
|
50
|
+
throw new Error(
|
|
51
|
+
`web-skill "${this.skill.key}" already contains a function named "${normalizedName}".`,
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
this.skill.functions.push({
|
|
56
|
+
func,
|
|
57
|
+
name: normalizedName,
|
|
58
|
+
description: options.description?.trim() || undefined,
|
|
59
|
+
inputSchema: options.inputSchema,
|
|
60
|
+
outputSchema: options.outputSchema,
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
return this;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
class WebSkillGeneratorImpl implements WebSkillGenerator {
|
|
68
|
+
private readonly skills: MutableWebSkillDefinition[] = [];
|
|
69
|
+
private readonly usedKeys = new Set<string>();
|
|
70
|
+
private readonly usedNames = new Set<string>();
|
|
71
|
+
private readonly usedSlugs = new Set<string>();
|
|
72
|
+
|
|
73
|
+
newSkill(options: NewWebSkillOptions = {}): WebSkillBuilder {
|
|
74
|
+
const skillIndex = this.skills.length + 1;
|
|
75
|
+
const fallbackName = `web-skill-${skillIndex}`;
|
|
76
|
+
const requestedName = options.name?.trim();
|
|
77
|
+
const requestedTitle = options.title?.trim();
|
|
78
|
+
const requestedIdentifier = requestedName || requestedTitle || fallbackName;
|
|
79
|
+
const requestedKeyBase = requestedName || requestedTitle || `webSkill${skillIndex}`;
|
|
80
|
+
const requestedTitleBase = requestedTitle || requestedName || fallbackName;
|
|
81
|
+
|
|
82
|
+
const slug = ensureUniqueString(slugifySkillSegment(requestedIdentifier), this.usedSlugs);
|
|
83
|
+
const keyBase = toSkillKey(requestedKeyBase);
|
|
84
|
+
const key = ensureUniqueString(keyBase, this.usedKeys, (index) => `${keyBase}${index + 1}`);
|
|
85
|
+
const nameBase = requestedName || slug;
|
|
86
|
+
const name = ensureUniqueString(nameBase, this.usedNames);
|
|
87
|
+
const title = requestedTitle || titleFromName(requestedTitleBase);
|
|
88
|
+
|
|
89
|
+
const skill: MutableWebSkillDefinition = {
|
|
90
|
+
description: options.description?.trim() || null,
|
|
91
|
+
functions: [],
|
|
92
|
+
key,
|
|
93
|
+
name,
|
|
94
|
+
slug,
|
|
95
|
+
title,
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
this.skills.push(skill);
|
|
99
|
+
return new WebSkillBuilderImpl(skill);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
getSkills(): ResolvedWebSkillDefinition[] {
|
|
103
|
+
return this.skills.map((skill) => ({
|
|
104
|
+
description: skill.description,
|
|
105
|
+
key: skill.key,
|
|
106
|
+
name: skill.name,
|
|
107
|
+
slug: skill.slug,
|
|
108
|
+
title: skill.title,
|
|
109
|
+
functions: skill.functions.map<ResolvedWebSkillFunctionDefinition>((definition) => ({
|
|
110
|
+
name: definition.name,
|
|
111
|
+
description: definition.description ?? undefined,
|
|
112
|
+
inputSchema: definition.inputSchema,
|
|
113
|
+
outputSchema: definition.outputSchema,
|
|
114
|
+
})),
|
|
115
|
+
}));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
install(target?: Window): WebSkillsWindowShape {
|
|
119
|
+
const windowTarget = target ?? resolveWindowTarget();
|
|
120
|
+
const registry = (windowTarget._web_skills ??= {});
|
|
121
|
+
|
|
122
|
+
for (const skill of this.skills) {
|
|
123
|
+
const entry: Partial<WebSkillWindowEntry> = {};
|
|
124
|
+
const metadata: WebSkillMetadata = {
|
|
125
|
+
description: skill.description,
|
|
126
|
+
functions: skill.functions.map((definition) => ({
|
|
127
|
+
description: definition.description ?? null,
|
|
128
|
+
hasInputSchema: Boolean(definition.inputSchema),
|
|
129
|
+
hasOutputSchema: Boolean(definition.outputSchema),
|
|
130
|
+
name: definition.name,
|
|
131
|
+
})),
|
|
132
|
+
key: skill.key,
|
|
133
|
+
name: skill.name,
|
|
134
|
+
title: skill.title,
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
entry._meta = metadata;
|
|
138
|
+
|
|
139
|
+
for (const definition of skill.functions) {
|
|
140
|
+
entry[definition.name] = wrapFunction(skill.key, definition);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
registry[skill.key] = entry as WebSkillWindowEntry;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return registry;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function resolveWindowTarget(): Window {
|
|
151
|
+
if (typeof window === "undefined") {
|
|
152
|
+
throw new Error("web-skill install() requires a browser window target.");
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return window;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function wrapFunction(
|
|
159
|
+
skillKey: string,
|
|
160
|
+
definition: WebSkillFunctionDefinition<any, any>,
|
|
161
|
+
): WebSkillWindowFunction {
|
|
162
|
+
return async (input: unknown) => {
|
|
163
|
+
const parsedInput = parseWithSchema(definition.inputSchema, input);
|
|
164
|
+
const result = await definition.func(parsedInput);
|
|
165
|
+
return parseWithSchema(definition.outputSchema, result);
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function parseWithSchema(schema: ZodTypeAny | undefined, value: unknown): unknown {
|
|
170
|
+
if (!schema) {
|
|
171
|
+
return value;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return schema.parse(value);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export function createWebSkillGenerator(): WebSkillGenerator {
|
|
178
|
+
return new WebSkillGeneratorImpl();
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
declare global {
|
|
182
|
+
interface Window {
|
|
183
|
+
_web_skills?: WebSkillsWindowShape;
|
|
184
|
+
}
|
|
185
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import type { ZodType, ZodTypeAny } from "zod";
|
|
2
|
+
|
|
3
|
+
export type MaybePromise<T> = T | Promise<T>;
|
|
4
|
+
|
|
5
|
+
export type WebSkillFunctionHandler<TInput = unknown, TOutput = unknown> = (
|
|
6
|
+
input: TInput,
|
|
7
|
+
) => MaybePromise<TOutput>;
|
|
8
|
+
|
|
9
|
+
export interface AddWebSkillFunctionOptions<TInput = unknown, TOutput = unknown> {
|
|
10
|
+
description?: string;
|
|
11
|
+
inputSchema?: ZodType<TInput>;
|
|
12
|
+
outputSchema?: ZodType<TOutput>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface WebSkillFunctionDefinition<TInput = unknown, TOutput = unknown>
|
|
16
|
+
extends AddWebSkillFunctionOptions<TInput, TOutput> {
|
|
17
|
+
func: WebSkillFunctionHandler<TInput, TOutput>;
|
|
18
|
+
name: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface NewWebSkillOptions {
|
|
22
|
+
description?: string;
|
|
23
|
+
name?: string;
|
|
24
|
+
title?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface ResolvedWebSkillFunctionDefinition<TInput = unknown, TOutput = unknown>
|
|
28
|
+
extends AddWebSkillFunctionOptions<TInput, TOutput> {
|
|
29
|
+
name: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface ResolvedWebSkillDefinition {
|
|
33
|
+
description: string | null;
|
|
34
|
+
functions: ResolvedWebSkillFunctionDefinition<any, any>[];
|
|
35
|
+
key: string;
|
|
36
|
+
name: string;
|
|
37
|
+
slug: string;
|
|
38
|
+
title: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface WebSkillFunctionMetadata {
|
|
42
|
+
description: string | null;
|
|
43
|
+
hasInputSchema: boolean;
|
|
44
|
+
hasOutputSchema: boolean;
|
|
45
|
+
name: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface WebSkillMetadata {
|
|
49
|
+
description: string | null;
|
|
50
|
+
functions: WebSkillFunctionMetadata[];
|
|
51
|
+
key: string;
|
|
52
|
+
name: string;
|
|
53
|
+
title: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export type WebSkillWindowFunction = (input: unknown) => Promise<unknown>;
|
|
57
|
+
|
|
58
|
+
export type WebSkillWindowEntry = Record<string, WebSkillWindowFunction | WebSkillMetadata> & {
|
|
59
|
+
_meta: WebSkillMetadata;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
export interface WebSkillsWindowShape {
|
|
63
|
+
[skillKey: string]: WebSkillWindowEntry;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface WebSkillLinkTag {
|
|
67
|
+
href: string;
|
|
68
|
+
title: string;
|
|
69
|
+
type: "text/markdown";
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface WebSkillVitePluginOptions {
|
|
73
|
+
generator: WebSkillGenerator;
|
|
74
|
+
publicBasePath?: string;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface WebSkillGenerator {
|
|
78
|
+
getSkills(): ResolvedWebSkillDefinition[];
|
|
79
|
+
install(target?: Window): WebSkillsWindowShape;
|
|
80
|
+
newSkill(options?: NewWebSkillOptions): WebSkillBuilder;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface WebSkillBuilder {
|
|
84
|
+
addFunction<TInput, TOutput>(
|
|
85
|
+
func: WebSkillFunctionHandler<TInput, TOutput>,
|
|
86
|
+
name: string,
|
|
87
|
+
options?: AddWebSkillFunctionOptions<TInput, TOutput>,
|
|
88
|
+
): WebSkillBuilder;
|
|
89
|
+
}
|
package/src/utils.ts
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import type { ResolvedWebSkillDefinition, WebSkillLinkTag } from "./types.ts";
|
|
2
|
+
|
|
3
|
+
const NON_ALPHANUMERIC_PATTERN = /[^a-zA-Z0-9]+/gu;
|
|
4
|
+
const TRIM_DASHES_PATTERN = /^-+|-+$/gu;
|
|
5
|
+
const CAMEL_BOUNDARY_PATTERN = /[-_\s]+([a-zA-Z0-9])/gu;
|
|
6
|
+
const NON_IDENTIFIER_PATTERN = /[^a-zA-Z0-9_$]/gu;
|
|
7
|
+
const LEADING_INVALID_IDENTIFIER_PATTERN = /^[^a-zA-Z_$]+/u;
|
|
8
|
+
|
|
9
|
+
export function slugifySkillSegment(value: string): string {
|
|
10
|
+
const normalized = value
|
|
11
|
+
.trim()
|
|
12
|
+
.replace(NON_ALPHANUMERIC_PATTERN, "-")
|
|
13
|
+
.replace(TRIM_DASHES_PATTERN, "")
|
|
14
|
+
.toLowerCase();
|
|
15
|
+
|
|
16
|
+
return normalized || "web-skill";
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function toSkillKey(value: string): string {
|
|
20
|
+
const identifier = value
|
|
21
|
+
.trim()
|
|
22
|
+
.replace(CAMEL_BOUNDARY_PATTERN, (_match, character: string) => character.toUpperCase())
|
|
23
|
+
.replace(NON_IDENTIFIER_PATTERN, "")
|
|
24
|
+
.replace(LEADING_INVALID_IDENTIFIER_PATTERN, "");
|
|
25
|
+
|
|
26
|
+
if (!identifier) {
|
|
27
|
+
return "webSkill";
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return identifier[0]!.toLowerCase() + identifier.slice(1);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function titleFromName(value: string): string {
|
|
34
|
+
const normalized = value
|
|
35
|
+
.replace(/([a-z0-9])([A-Z])/gu, "$1 $2")
|
|
36
|
+
.replace(/[-_]+/gu, " ")
|
|
37
|
+
.trim();
|
|
38
|
+
|
|
39
|
+
if (!normalized) {
|
|
40
|
+
return "Web Skill";
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return normalized
|
|
44
|
+
.split(/\s+/u)
|
|
45
|
+
.map((part) => part[0]!.toUpperCase() + part.slice(1))
|
|
46
|
+
.join(" ");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function ensureUniqueString(
|
|
50
|
+
value: string,
|
|
51
|
+
used: Set<string>,
|
|
52
|
+
formatter: (index: number) => string = (index) => `${value}-${index + 1}`,
|
|
53
|
+
): string {
|
|
54
|
+
if (!used.has(value)) {
|
|
55
|
+
used.add(value);
|
|
56
|
+
return value;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
let index = 1;
|
|
60
|
+
while (true) {
|
|
61
|
+
const nextValue = formatter(index);
|
|
62
|
+
if (!used.has(nextValue)) {
|
|
63
|
+
used.add(nextValue);
|
|
64
|
+
return nextValue;
|
|
65
|
+
}
|
|
66
|
+
index += 1;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function joinBasePath(basePath: string | undefined, relativePath: string): string {
|
|
71
|
+
const normalizedBase = (basePath ?? "/").trim();
|
|
72
|
+
const base = normalizedBase === "/" ? "" : `/${normalizedBase.replace(/^\/+|\/+$/gu, "")}`;
|
|
73
|
+
const path = relativePath.startsWith("/") ? relativePath : `/${relativePath}`;
|
|
74
|
+
return `${base}${path}`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function toSkillMarkdownAssetPath(skill: ResolvedWebSkillDefinition): string {
|
|
78
|
+
return `/skills/${skill.slug}/SKILL.md`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function buildWebSkillLinkTags(
|
|
82
|
+
skills: ResolvedWebSkillDefinition[],
|
|
83
|
+
basePath?: string,
|
|
84
|
+
): WebSkillLinkTag[] {
|
|
85
|
+
return skills.map((skill) => ({
|
|
86
|
+
href: joinBasePath(basePath, toSkillMarkdownAssetPath(skill)),
|
|
87
|
+
title: skill.title,
|
|
88
|
+
type: "text/markdown",
|
|
89
|
+
}));
|
|
90
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
|
+
|
|
3
|
+
import type { Plugin, ResolvedConfig } from "vite";
|
|
4
|
+
|
|
5
|
+
import { generateSkillMarkdown } from "./markdown.ts";
|
|
6
|
+
import type { ResolvedWebSkillDefinition, WebSkillVitePluginOptions } from "./types.ts";
|
|
7
|
+
import { buildWebSkillLinkTags } from "./utils.ts";
|
|
8
|
+
|
|
9
|
+
export function webSkillVitePlugin(options: WebSkillVitePluginOptions): Plugin {
|
|
10
|
+
let resolvedConfig: ResolvedConfig | null = null;
|
|
11
|
+
|
|
12
|
+
return {
|
|
13
|
+
name: "web-skill",
|
|
14
|
+
|
|
15
|
+
configResolved(config): void {
|
|
16
|
+
resolvedConfig = config;
|
|
17
|
+
},
|
|
18
|
+
|
|
19
|
+
configureServer(server) {
|
|
20
|
+
server.middlewares.use((request, response, next) => {
|
|
21
|
+
if (!resolvedConfig) {
|
|
22
|
+
next();
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (
|
|
27
|
+
!tryServeGeneratedSkillMarkdown(
|
|
28
|
+
request,
|
|
29
|
+
response,
|
|
30
|
+
options.generator.getSkills(),
|
|
31
|
+
options.publicBasePath ?? resolvedConfig.base,
|
|
32
|
+
)
|
|
33
|
+
) {
|
|
34
|
+
next();
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
},
|
|
38
|
+
|
|
39
|
+
generateBundle() {
|
|
40
|
+
for (const skill of options.generator.getSkills()) {
|
|
41
|
+
this.emitFile({
|
|
42
|
+
fileName: `skills/${skill.slug}/SKILL.md`,
|
|
43
|
+
source: generateSkillMarkdown(skill),
|
|
44
|
+
type: "asset",
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
|
|
49
|
+
transformIndexHtml() {
|
|
50
|
+
const tags = buildWebSkillLinkTags(
|
|
51
|
+
options.generator.getSkills(),
|
|
52
|
+
options.publicBasePath ?? resolvedConfig?.base ?? "/",
|
|
53
|
+
);
|
|
54
|
+
return tags.map((tag) => ({
|
|
55
|
+
tag: "link",
|
|
56
|
+
attrs: {
|
|
57
|
+
rel: "web-skill",
|
|
58
|
+
href: tag.href,
|
|
59
|
+
title: tag.title,
|
|
60
|
+
type: tag.type,
|
|
61
|
+
},
|
|
62
|
+
injectTo: "head" as const,
|
|
63
|
+
}));
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function tryServeGeneratedSkillMarkdown(
|
|
69
|
+
request: IncomingMessage,
|
|
70
|
+
response: ServerResponse,
|
|
71
|
+
skills: ResolvedWebSkillDefinition[],
|
|
72
|
+
basePath: string,
|
|
73
|
+
): boolean {
|
|
74
|
+
const requestUrl = request.url;
|
|
75
|
+
if (!requestUrl) {
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const pathname = new URL(requestUrl, "http://localhost").pathname;
|
|
80
|
+
const tags = buildWebSkillLinkTags(skills, basePath);
|
|
81
|
+
const matchedIndex = tags.findIndex((tag) => tag.href === pathname);
|
|
82
|
+
|
|
83
|
+
if (matchedIndex === -1) {
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
response.statusCode = 200;
|
|
88
|
+
response.setHeader("Content-Type", "text/markdown; charset=utf-8");
|
|
89
|
+
response.end(generateSkillMarkdown(skills[matchedIndex]!));
|
|
90
|
+
return true;
|
|
91
|
+
}
|