starlight-onetag 0.1.0 → 0.2.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/README.md +26 -0
- package/package.json +44 -6
- package/plugins/explain/client.js +96 -0
- package/plugins/explain/index.js +26 -0
- package/plugins/plugin.js +47 -0
- package/plugins/search/client.js +149 -0
- package/plugins/search/index.js +28 -0
- package/plugins/selection/client.js +137 -0
- package/plugins/selection/index.js +27 -0
- package/plugins/shared.js +175 -0
- package/plugins/summary/client.js +66 -0
- package/plugins/summary/index.js +28 -0
- package/plugins/translate/client.js +176 -0
- package/plugins/translate/index.js +25 -0
package/README.md
CHANGED
|
@@ -69,6 +69,32 @@ This key is designed to be public, like an analytics ID: it ends up in the built
|
|
|
69
69
|
|
|
70
70
|
Nothing is stored on a server. OneTag AI keeps token counts and timestamps, not questions or page text.
|
|
71
71
|
|
|
72
|
+
## More AI features in this package
|
|
73
|
+
|
|
74
|
+
Five more plugins ship in the same package. Add the ones you want next to `starlightOnetag()`; they share the key and add no component overrides.
|
|
75
|
+
|
|
76
|
+
```js
|
|
77
|
+
import starlightOnetag from "starlight-onetag";
|
|
78
|
+
import starlightOnetagSearch from "starlight-onetag/search";
|
|
79
|
+
import starlightOnetagTranslate from "starlight-onetag/translate";
|
|
80
|
+
import starlightOnetagExplain from "starlight-onetag/explain";
|
|
81
|
+
import starlightOnetagSummary from "starlight-onetag/summary";
|
|
82
|
+
import starlightOnetagSelection from "starlight-onetag/selection";
|
|
83
|
+
|
|
84
|
+
starlight({
|
|
85
|
+
plugins: [
|
|
86
|
+
starlightOnetag(),
|
|
87
|
+
starlightOnetagSearch(), // AI answer inside the search dialog, above the Pagefind results
|
|
88
|
+
starlightOnetagTranslate(), // "Read in <your language>": AI translation of the page for the reader
|
|
89
|
+
starlightOnetagExplain(), // "Explain this code" under every code block
|
|
90
|
+
starlightOnetagSummary(), // "Summarize this page" on long pages
|
|
91
|
+
starlightOnetagSelection(), // select text, click Ask AI, get it explained in context
|
|
92
|
+
],
|
|
93
|
+
});
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Each one has its own page with screenshots and options: [search](https://github.com/fengyiqicoder/starlight-onetag-search), [translate](https://github.com/fengyiqicoder/starlight-onetag-translate), [explain](https://github.com/fengyiqicoder/starlight-onetag-explain), [summary](https://github.com/fengyiqicoder/starlight-onetag-summary), [selection](https://github.com/fengyiqicoder/starlight-onetag-selection). The options are the same as listed there; the key also comes from `PUBLIC_ONETAG_KEY`.
|
|
97
|
+
|
|
72
98
|
## See also
|
|
73
99
|
|
|
74
100
|
- [Astro AI Docs](https://github.com/fengyiqicoder/astro-ai-docs): a docs template that ships with this dialog built in.
|
package/package.json
CHANGED
|
@@ -1,17 +1,55 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "starlight-onetag",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "AI for Starlight docs, no backend: Ask AI dialog, AI answers in search, one-click page translation for readers, Explain this code, page summaries and ask-about-selection. Answers via OneTag AI with a public key.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
|
-
"keywords": [
|
|
8
|
-
|
|
7
|
+
"keywords": [
|
|
8
|
+
"ai-search",
|
|
9
|
+
"ask-ai",
|
|
10
|
+
"astro",
|
|
11
|
+
"chatbot",
|
|
12
|
+
"code",
|
|
13
|
+
"docs",
|
|
14
|
+
"explain",
|
|
15
|
+
"i18n",
|
|
16
|
+
"no-backend",
|
|
17
|
+
"onetag",
|
|
18
|
+
"pagefind",
|
|
19
|
+
"search",
|
|
20
|
+
"starlight",
|
|
21
|
+
"starlight-plugin",
|
|
22
|
+
"summary",
|
|
23
|
+
"translate",
|
|
24
|
+
"translation",
|
|
25
|
+
"withastro"
|
|
26
|
+
],
|
|
27
|
+
"repository": {
|
|
28
|
+
"type": "git",
|
|
29
|
+
"url": "git+https://github.com/fengyiqicoder/starlight-onetag.git"
|
|
30
|
+
},
|
|
9
31
|
"homepage": "https://github.com/fengyiqicoder/starlight-onetag",
|
|
10
32
|
"exports": {
|
|
11
33
|
".": "./index.js",
|
|
12
|
-
"./Search.astro": "./components/Search.astro"
|
|
34
|
+
"./Search.astro": "./components/Search.astro",
|
|
35
|
+
"./translate": "./plugins/translate/index.js",
|
|
36
|
+
"./translate/client": "./plugins/translate/client.js",
|
|
37
|
+
"./explain": "./plugins/explain/index.js",
|
|
38
|
+
"./explain/client": "./plugins/explain/client.js",
|
|
39
|
+
"./summary": "./plugins/summary/index.js",
|
|
40
|
+
"./summary/client": "./plugins/summary/client.js",
|
|
41
|
+
"./search": "./plugins/search/index.js",
|
|
42
|
+
"./search/client": "./plugins/search/client.js",
|
|
43
|
+
"./selection": "./plugins/selection/index.js",
|
|
44
|
+
"./selection/client": "./plugins/selection/client.js"
|
|
13
45
|
},
|
|
14
|
-
"files": [
|
|
46
|
+
"files": [
|
|
47
|
+
"index.js",
|
|
48
|
+
"components",
|
|
49
|
+
"plugins",
|
|
50
|
+
"README.md",
|
|
51
|
+
"LICENSE"
|
|
52
|
+
],
|
|
15
53
|
"dependencies": {
|
|
16
54
|
"onetagai": "^1.2.0"
|
|
17
55
|
},
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// Browser side of starlight-onetag-explain.
|
|
2
|
+
import { addStyle, answerLanguage, BASE_CSS, contentRoot, demoNote, el, errorText, pageTitle, stream } from '../shared.js';
|
|
3
|
+
|
|
4
|
+
// The text around a code block: the heading it sits under and the paragraph before it.
|
|
5
|
+
function context(block) {
|
|
6
|
+
let heading = '';
|
|
7
|
+
let before = '';
|
|
8
|
+
let n = block;
|
|
9
|
+
while (n && n.parentElement !== contentRoot()) n = n.parentElement;
|
|
10
|
+
for (let p = n?.previousElementSibling; p; p = p.previousElementSibling) {
|
|
11
|
+
if (!before && !p.matches('.expressive-code, pre')) before = p.textContent.trim().slice(0, 1200);
|
|
12
|
+
if (/^H[2-4]$/.test(p.tagName) || p.querySelector?.(':scope > h2, :scope > h3, :scope > h4')) {
|
|
13
|
+
heading = p.textContent.replace(/Section titled.*$/, '').trim();
|
|
14
|
+
break;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
return { heading, before };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export default function init(cfg) {
|
|
21
|
+
const root = contentRoot();
|
|
22
|
+
if (!root) return;
|
|
23
|
+
const blocks = [...root.querySelectorAll('.expressive-code')];
|
|
24
|
+
root.querySelectorAll('pre').forEach((pre) => {
|
|
25
|
+
if (!pre.closest('.expressive-code')) blocks.push(pre);
|
|
26
|
+
});
|
|
27
|
+
if (!blocks.length) return;
|
|
28
|
+
addStyle('starlight-onetag-explain', BASE_CSS + CSS);
|
|
29
|
+
|
|
30
|
+
blocks.forEach((block) => {
|
|
31
|
+
const pre = block.matches('pre') ? block : block.querySelector('pre');
|
|
32
|
+
const code = (pre?.innerText || '').trim();
|
|
33
|
+
if (!code || code.split('\n').length < cfg.minLines) return;
|
|
34
|
+
const lang = pre.getAttribute('data-language') || '';
|
|
35
|
+
const title = block.querySelector('figcaption .title')?.textContent?.trim() || '';
|
|
36
|
+
|
|
37
|
+
let controller = null;
|
|
38
|
+
const answer = el('div', { class: 'onetag-answer' });
|
|
39
|
+
const status = el('p', { class: 'onetag-status', 'aria-live': 'polite' });
|
|
40
|
+
const close = el('button', { type: 'button', class: 'onetag-link', onclick: () => {
|
|
41
|
+
controller?.abort();
|
|
42
|
+
panel.hidden = true;
|
|
43
|
+
button.hidden = false;
|
|
44
|
+
button.focus();
|
|
45
|
+
} }, 'Close');
|
|
46
|
+
const panel = el('div', { class: 'onetag-panel', hidden: true, 'data-onetag-ui': '' }, status, answer, demoNote(cfg), el('p', { class: 'onetag-foot' }, 'Explained by AI. ', close));
|
|
47
|
+
const button = el('button', { type: 'button', class: 'onetag-btn onetag-explain-btn', 'data-onetag-ui': '', onclick: run }, spark(), cfg.label);
|
|
48
|
+
block.after(button, panel);
|
|
49
|
+
|
|
50
|
+
async function run() {
|
|
51
|
+
controller?.abort();
|
|
52
|
+
controller = new AbortController();
|
|
53
|
+
const { signal } = controller;
|
|
54
|
+
button.hidden = true;
|
|
55
|
+
panel.hidden = false;
|
|
56
|
+
answer.replaceChildren();
|
|
57
|
+
status.classList.remove('error');
|
|
58
|
+
status.textContent = 'Reading the code…';
|
|
59
|
+
const { heading, before } = context(block);
|
|
60
|
+
const input = [
|
|
61
|
+
`Page: ${pageTitle()}`,
|
|
62
|
+
heading && `Section: ${heading}`,
|
|
63
|
+
before && `Text before the code:\n${before}`,
|
|
64
|
+
`Code${lang ? ' (' + lang + ')' : ''}${title ? ', file ' + title : ''}:\n${code.slice(0, 12000)}`,
|
|
65
|
+
].filter(Boolean);
|
|
66
|
+
try {
|
|
67
|
+
await stream(cfg, `Explain this code to a reader of the docs. Answer in ${answerLanguage(cfg)}.`, input, answer, {
|
|
68
|
+
system: cfg.rules,
|
|
69
|
+
signal,
|
|
70
|
+
onDelta: () => (status.textContent = ''),
|
|
71
|
+
});
|
|
72
|
+
status.textContent = '';
|
|
73
|
+
} catch (e) {
|
|
74
|
+
if (signal.aborted) return;
|
|
75
|
+
status.textContent = errorText(e);
|
|
76
|
+
status.classList.add('error');
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function spark() {
|
|
83
|
+
const s = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
|
84
|
+
s.setAttribute('width', '14');
|
|
85
|
+
s.setAttribute('height', '14');
|
|
86
|
+
s.setAttribute('viewBox', '0 0 24 24');
|
|
87
|
+
s.setAttribute('aria-hidden', 'true');
|
|
88
|
+
s.innerHTML = '<path fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round" d="M12 3l1.8 5.2L19 10l-5.2 1.8L12 17l-1.8-5.2L5 10l5.2-1.8z"/>';
|
|
89
|
+
return s;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const CSS = `
|
|
93
|
+
.sl-markdown-content .onetag-explain-btn { margin-top: .5rem; border-color: transparent; background: transparent; color: var(--sl-color-gray-2); padding-inline: .25rem; }
|
|
94
|
+
.sl-markdown-content .onetag-explain-btn:hover { color: var(--sl-color-text-accent); border-color: transparent; }
|
|
95
|
+
.sl-markdown-content .onetag-panel { margin-top: .5rem; }
|
|
96
|
+
`;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* starlight-onetag-explain: an "Explain" button under every code block in Starlight docs.
|
|
3
|
+
*
|
|
4
|
+
* import starlightOnetagExplain from "starlight-onetag/explain";
|
|
5
|
+
* starlight({ plugins: [starlightOnetagExplain()] })
|
|
6
|
+
*/
|
|
7
|
+
import { makePlugin } from '../plugin.js';
|
|
8
|
+
|
|
9
|
+
export default function starlightOnetagExplain(options = {}) {
|
|
10
|
+
return makePlugin(
|
|
11
|
+
'starlight-onetag-explain',
|
|
12
|
+
{
|
|
13
|
+
key: options.key ?? '',
|
|
14
|
+
site: options.site ?? 'https://onetagai.com',
|
|
15
|
+
label: options.label ?? 'Explain this code',
|
|
16
|
+
minLines: options.minLines ?? 1,
|
|
17
|
+
lang: options.lang ?? '',
|
|
18
|
+
rules:
|
|
19
|
+
options.rules ??
|
|
20
|
+
'You explain code samples in documentation to readers who are learning. Walk through what the code does, step by step, in plain words. Mention what they need to change before using it (placeholders, keys, paths). Use short paragraphs or a short list, under 180 words. Wrap identifiers and commands in backticks. No headings.',
|
|
21
|
+
maxTokens: options.maxTokens ?? 600,
|
|
22
|
+
},
|
|
23
|
+
'code explanations',
|
|
24
|
+
'starlight-onetag/explain/client'
|
|
25
|
+
);
|
|
26
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// Plugin factory shared by the starlight-onetag-* packages: injects the browser script with the
|
|
2
|
+
// options, reads the key from the option or PUBLIC_ONETAG_KEY, and warns at build time without one.
|
|
3
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
|
|
7
|
+
function envHasKey(root) {
|
|
8
|
+
if (process.env.PUBLIC_ONETAG_KEY) return true;
|
|
9
|
+
const dir = root instanceof URL ? fileURLToPath(root) : String(root || '.');
|
|
10
|
+
for (const f of ['.env', '.env.production', '.env.local', '.env.production.local']) {
|
|
11
|
+
const p = join(dir, f);
|
|
12
|
+
if (existsSync(p) && /^\s*PUBLIC_ONETAG_KEY\s*=\s*\S+/m.test(readFileSync(p, 'utf8'))) return true;
|
|
13
|
+
}
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function makePlugin(name, opts, label, client = name + '/client') {
|
|
18
|
+
return {
|
|
19
|
+
name,
|
|
20
|
+
hooks: {
|
|
21
|
+
'config:setup'({ addIntegration }) {
|
|
22
|
+
addIntegration({
|
|
23
|
+
name,
|
|
24
|
+
hooks: {
|
|
25
|
+
'astro:config:setup'({ injectScript, command, config, logger }) {
|
|
26
|
+
// Page scripts from every integration share one module: keep the names unique.
|
|
27
|
+
const id = name.replace(/\W/g, '_');
|
|
28
|
+
const cfg = JSON.stringify({ ...opts, via: name, base: (config.base || '/').replace(/\/?$/, '/') });
|
|
29
|
+
injectScript(
|
|
30
|
+
'page',
|
|
31
|
+
`import ${id}_init from ${JSON.stringify(client)};\n` +
|
|
32
|
+
`const ${id}_cfg = ${cfg};\n${id}_cfg.key = ${id}_cfg.key || import.meta.env.PUBLIC_ONETAG_KEY || "";\n${id}_init(${id}_cfg);`
|
|
33
|
+
);
|
|
34
|
+
if (command === 'build' && !opts.key && !envHasKey(config.root)) {
|
|
35
|
+
logger.warn(
|
|
36
|
+
`No OneTag AI key: ${label} will not work on the deployed site. Get a free key (email only): ` +
|
|
37
|
+
`curl -X POST https://onetagai.com/api/signup -H "Content-Type: application/json" -d '{"email":"you@example.com"}' ` +
|
|
38
|
+
`then pass it as { key } or set PUBLIC_ONETAG_KEY.`
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
});
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// Browser side of starlight-onetag-search.
|
|
2
|
+
import { addStyle, BASE_CSS, demoNote, el, errorText, pageText, pageTitle, stream } from '../shared.js';
|
|
3
|
+
|
|
4
|
+
const STOP = new Set(('a an and are as at be by can could do does for from how i if in into is it its me my of on or ' +
|
|
5
|
+
'our should site that the their there these this to use using we what when where which who why will with you your').split(' '));
|
|
6
|
+
function keywords(q) {
|
|
7
|
+
const words = (q.toLowerCase().match(/[\p{L}\p{N}][\p{L}\p{N}'-]*/gu) || []).filter((w) => w.length > 1 && !STOP.has(w));
|
|
8
|
+
const unique = [...new Set(words)].sort((a, b) => b.length - a.length).slice(0, 6);
|
|
9
|
+
return unique.length ? unique : [q];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// Top pages for the question from the Pagefind index Starlight builds. Pagefind matches every
|
|
13
|
+
// word of a query, so keywords are searched one by one and pages ranked by how many they match.
|
|
14
|
+
async function retrieve(cfg, question) {
|
|
15
|
+
try {
|
|
16
|
+
const pf = await import(/* @vite-ignore */ cfg.base + 'pagefind/pagefind.js');
|
|
17
|
+
await pf.init();
|
|
18
|
+
const searches = await Promise.all(keywords(question).map((w) => pf.search(w)));
|
|
19
|
+
const scores = new Map();
|
|
20
|
+
searches.forEach((s) =>
|
|
21
|
+
s.results.slice(0, 10).forEach((r, rank) => {
|
|
22
|
+
const cur = scores.get(r.id) || { score: 0, result: r };
|
|
23
|
+
cur.score += 1 + Math.max(0, 10 - rank) / 20 + (r.score || 0) / 100;
|
|
24
|
+
scores.set(r.id, cur);
|
|
25
|
+
})
|
|
26
|
+
);
|
|
27
|
+
const top = [...scores.values()].sort((a, b) => b.score - a.score).slice(0, cfg.topPages);
|
|
28
|
+
const hits = await Promise.all(top.map((t) => t.result.data()));
|
|
29
|
+
const pages = hits.map((h) => ({ title: h.meta?.title || h.url, url: h.url, text: (h.content || h.excerpt || '').slice(0, cfg.perPage) }));
|
|
30
|
+
if (pages.length) return pages;
|
|
31
|
+
} catch {}
|
|
32
|
+
// No index (astro dev) or nothing matched: fall back to llms-full.txt, then the current page.
|
|
33
|
+
for (const name of ['llms-full.txt', 'llms-small.txt']) {
|
|
34
|
+
try {
|
|
35
|
+
const r = await fetch(cfg.base + name);
|
|
36
|
+
if (!r.ok) continue;
|
|
37
|
+
const text = await r.text();
|
|
38
|
+
if (text.length <= cfg.maxContext) return [{ title: 'All pages', url: cfg.base + name, text }];
|
|
39
|
+
} catch {}
|
|
40
|
+
}
|
|
41
|
+
return [{ title: pageTitle(), url: location.pathname, text: pageText(cfg.perPage) }];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export default function init(cfg) {
|
|
45
|
+
const host = document.getElementById('starlight__search');
|
|
46
|
+
if (!host) return;
|
|
47
|
+
addStyle('starlight-onetag-search', BASE_CSS + CSS);
|
|
48
|
+
|
|
49
|
+
let controller = null;
|
|
50
|
+
let asked = '';
|
|
51
|
+
const label = el('span', { class: 'onetag-search-q' });
|
|
52
|
+
const button = el('button', { type: 'button', class: 'onetag-search-ask', onclick: () => ask(current()) }, spark(), el('span', {}, cfg.label + ': '), label);
|
|
53
|
+
const status = el('p', { class: 'onetag-status', 'aria-live': 'polite' });
|
|
54
|
+
const answer = el('div', { class: 'onetag-answer' });
|
|
55
|
+
const sources = el('p', { class: 'onetag-foot onetag-sources' });
|
|
56
|
+
const result = el('div', { class: 'onetag-search-result', hidden: true }, status, answer, sources, demoNote(cfg));
|
|
57
|
+
const box = el('div', { class: 'onetag-search', hidden: true, 'data-onetag-ui': '' }, button, result);
|
|
58
|
+
|
|
59
|
+
const input = () => host.querySelector('.pagefind-ui__search-input');
|
|
60
|
+
const current = () => (input()?.value || '').trim();
|
|
61
|
+
|
|
62
|
+
function place() {
|
|
63
|
+
const i = input();
|
|
64
|
+
if (!i) return;
|
|
65
|
+
const after = host.querySelector('.pagefind-ui__search-clear') || i;
|
|
66
|
+
if (after.nextElementSibling !== box) after.after(box);
|
|
67
|
+
}
|
|
68
|
+
function update() {
|
|
69
|
+
place();
|
|
70
|
+
const q = current();
|
|
71
|
+
box.hidden = q.length < cfg.minChars;
|
|
72
|
+
label.textContent = '“' + q + '”';
|
|
73
|
+
button.hidden = q === asked && !result.hidden;
|
|
74
|
+
if (q !== asked) result.hidden = true;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
new MutationObserver(() => {
|
|
78
|
+
const i = input();
|
|
79
|
+
if (i && !i.dataset.onetag) {
|
|
80
|
+
i.dataset.onetag = '1';
|
|
81
|
+
i.addEventListener('input', update);
|
|
82
|
+
}
|
|
83
|
+
place();
|
|
84
|
+
}).observe(host, { childList: true, subtree: true });
|
|
85
|
+
|
|
86
|
+
async function ask(q) {
|
|
87
|
+
if (!q) return;
|
|
88
|
+
controller?.abort();
|
|
89
|
+
controller = new AbortController();
|
|
90
|
+
const { signal } = controller;
|
|
91
|
+
asked = q;
|
|
92
|
+
button.hidden = true;
|
|
93
|
+
result.hidden = false;
|
|
94
|
+
answer.replaceChildren();
|
|
95
|
+
sources.replaceChildren();
|
|
96
|
+
status.classList.remove('error');
|
|
97
|
+
status.textContent = 'Reading the docs…';
|
|
98
|
+
try {
|
|
99
|
+
const pages = await retrieve(cfg, q);
|
|
100
|
+
let budget = cfg.maxContext;
|
|
101
|
+
const parts = [];
|
|
102
|
+
for (const p of pages) {
|
|
103
|
+
const chunk = `Page: ${p.title} (${p.url})\n${p.text}`.slice(0, budget);
|
|
104
|
+
if (!chunk) break;
|
|
105
|
+
parts.push(chunk);
|
|
106
|
+
budget -= chunk.length;
|
|
107
|
+
}
|
|
108
|
+
await stream(cfg, q + '\n\n(Reply in the same language as the question above.)', parts, answer, {
|
|
109
|
+
system: cfg.rules,
|
|
110
|
+
signal,
|
|
111
|
+
onDelta: () => (status.textContent = ''),
|
|
112
|
+
});
|
|
113
|
+
if (signal.aborted) return;
|
|
114
|
+
status.textContent = '';
|
|
115
|
+
const real = pages.filter((p) => p.title !== 'All pages' && p.url !== location.pathname).slice(0, 3);
|
|
116
|
+
if (real.length) {
|
|
117
|
+
sources.append('From: ');
|
|
118
|
+
real.forEach((p, i) => {
|
|
119
|
+
if (i) sources.append(' · ');
|
|
120
|
+
sources.append(el('a', { href: p.url }, p.title));
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
} catch (e) {
|
|
124
|
+
if (signal.aborted) return;
|
|
125
|
+
status.textContent = errorText(e);
|
|
126
|
+
status.classList.add('error');
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function spark() {
|
|
132
|
+
const s = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
|
133
|
+
s.setAttribute('width', '16');
|
|
134
|
+
s.setAttribute('height', '16');
|
|
135
|
+
s.setAttribute('viewBox', '0 0 24 24');
|
|
136
|
+
s.setAttribute('aria-hidden', 'true');
|
|
137
|
+
s.innerHTML = '<path fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round" d="M12 3l1.8 5.2L19 10l-5.2 1.8L12 17l-1.8-5.2L5 10l5.2-1.8z"/>';
|
|
138
|
+
return s;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const CSS = `
|
|
142
|
+
.onetag-search { margin-top: 1rem; }
|
|
143
|
+
.onetag-search-ask { display: flex; align-items: center; gap: .5rem; width: 100%; text-align: start; font: inherit; font-size: var(--sl-text-base); padding: .75rem 1rem; border: 1px solid var(--sl-color-gray-5); border-radius: .4rem; background: var(--sl-color-black); color: var(--sl-color-white); cursor: pointer; }
|
|
144
|
+
.onetag-search-ask:hover, .onetag-search-ask:focus-visible { border-color: var(--sl-color-accent); outline: none; background: var(--sl-color-accent-low); }
|
|
145
|
+
.onetag-search-ask svg { color: var(--sl-color-text-accent); flex: none; }
|
|
146
|
+
.onetag-search-q { color: var(--sl-color-text-accent); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
147
|
+
.onetag-search-result { padding: .75rem 1rem; border: 1px solid var(--sl-color-gray-5); border-inline-start: 3px solid var(--sl-color-accent); border-radius: .4rem; background: var(--sl-color-black); color: var(--sl-color-text); font-size: var(--sl-text-sm); }
|
|
148
|
+
.onetag-search-result a { color: var(--sl-color-text-accent); }
|
|
149
|
+
`;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* starlight-onetag-search: an "Ask AI" answer inside Starlight's search dialog, above the Pagefind results.
|
|
3
|
+
*
|
|
4
|
+
* import starlightOnetagSearch from "starlight-onetag/search";
|
|
5
|
+
* starlight({ plugins: [starlightOnetagSearch()] })
|
|
6
|
+
*/
|
|
7
|
+
import { makePlugin } from '../plugin.js';
|
|
8
|
+
|
|
9
|
+
export default function starlightOnetagSearch(options = {}) {
|
|
10
|
+
return makePlugin(
|
|
11
|
+
'starlight-onetag-search',
|
|
12
|
+
{
|
|
13
|
+
key: options.key ?? '',
|
|
14
|
+
site: options.site ?? 'https://onetagai.com',
|
|
15
|
+
label: options.label ?? 'Ask AI',
|
|
16
|
+
minChars: options.minChars ?? 3,
|
|
17
|
+
rules:
|
|
18
|
+
options.rules ??
|
|
19
|
+
'You answer questions typed into the search box of a documentation site. Answer only from the pages provided. When they cover the question, answer it directly in under 120 words and stop. When they do not, say so in one sentence and name the closest page. Plain text, no headings. Answer in the language of the question.',
|
|
20
|
+
topPages: options.topPages ?? 5,
|
|
21
|
+
perPage: options.perPage ?? 5000,
|
|
22
|
+
maxContext: options.maxContext ?? 20000,
|
|
23
|
+
maxTokens: options.maxTokens ?? 400,
|
|
24
|
+
},
|
|
25
|
+
'AI search answers',
|
|
26
|
+
'starlight-onetag/search/client'
|
|
27
|
+
);
|
|
28
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
// Browser side of starlight-onetag-selection.
|
|
2
|
+
import { addStyle, answerLanguage, BASE_CSS, contentRoot, demoNote, el, errorText, pageText, pageTitle, stream } from '../shared.js';
|
|
3
|
+
|
|
4
|
+
export default function init(cfg) {
|
|
5
|
+
const root = contentRoot();
|
|
6
|
+
if (!root) return;
|
|
7
|
+
addStyle('starlight-onetag-selection', BASE_CSS + CSS);
|
|
8
|
+
|
|
9
|
+
let picked = null; // { text, paragraph, rect }
|
|
10
|
+
let controller = null;
|
|
11
|
+
let history = [];
|
|
12
|
+
|
|
13
|
+
const pill = el('button', { type: 'button', class: 'onetag-pill', hidden: true, 'data-onetag-ui': '', onmousedown: (e) => e.preventDefault(), onclick: () => open() }, spark(), cfg.label);
|
|
14
|
+
const quote = el('p', { class: 'onetag-quote' });
|
|
15
|
+
const status = el('p', { class: 'onetag-status', 'aria-live': 'polite' });
|
|
16
|
+
const answer = el('div', { class: 'onetag-answer' });
|
|
17
|
+
const field = el('input', { type: 'text', maxlength: '300', placeholder: 'Ask a follow-up', 'aria-label': 'Ask a follow-up about the selection' });
|
|
18
|
+
const form = el('form', { class: 'onetag-follow', onsubmit: (e) => {
|
|
19
|
+
e.preventDefault();
|
|
20
|
+
const q = field.value.trim();
|
|
21
|
+
if (!q) return;
|
|
22
|
+
field.value = '';
|
|
23
|
+
ask(q);
|
|
24
|
+
} }, field, el('button', { type: 'submit', class: 'onetag-btn' }, 'Ask'));
|
|
25
|
+
const close = el('button', { type: 'button', class: 'onetag-link', onclick: () => hideCard() }, 'Close');
|
|
26
|
+
const card = el('div', { class: 'onetag-card', hidden: true, role: 'dialog', 'aria-label': 'AI explanation of the selected text', 'data-onetag-ui': '' },
|
|
27
|
+
quote, status, answer, form, demoNote(cfg), el('p', { class: 'onetag-foot' }, 'Explained by AI from this page. ', close));
|
|
28
|
+
document.body.append(pill, card);
|
|
29
|
+
|
|
30
|
+
function selection() {
|
|
31
|
+
const sel = getSelection();
|
|
32
|
+
if (!sel || sel.isCollapsed || !sel.rangeCount) return null;
|
|
33
|
+
const text = sel.toString().trim();
|
|
34
|
+
if (text.length < cfg.minChars || text.length > cfg.maxChars) return null;
|
|
35
|
+
const range = sel.getRangeAt(0);
|
|
36
|
+
const node = range.commonAncestorContainer;
|
|
37
|
+
const elm = node.nodeType === 1 ? node : node.parentElement;
|
|
38
|
+
if (!root.contains(elm) || elm.closest('[data-onetag-ui], input, textarea')) return null;
|
|
39
|
+
const block = elm.closest('p, li, td, pre, blockquote, dd, h2, h3, h4, .expressive-code') || elm;
|
|
40
|
+
const rects = range.getClientRects();
|
|
41
|
+
const rect = rects[rects.length - 1] || range.getBoundingClientRect();
|
|
42
|
+
return { text, paragraph: block.textContent.trim().slice(0, 1500), rect };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function position(node, rect, below = 8) {
|
|
46
|
+
const width = node.offsetWidth;
|
|
47
|
+
const left = Math.min(Math.max(8, rect.left + scrollX), scrollX + document.documentElement.clientWidth - width - 8);
|
|
48
|
+
node.style.left = left + 'px';
|
|
49
|
+
node.style.top = rect.bottom + scrollY + below + 'px';
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
let timer = 0;
|
|
53
|
+
const check = () => {
|
|
54
|
+
clearTimeout(timer);
|
|
55
|
+
timer = setTimeout(() => {
|
|
56
|
+
if (!card.hidden) return;
|
|
57
|
+
const s = selection();
|
|
58
|
+
if (!s) return (pill.hidden = true);
|
|
59
|
+
picked = s;
|
|
60
|
+
pill.hidden = false;
|
|
61
|
+
position(pill, s.rect, 6);
|
|
62
|
+
}, 250);
|
|
63
|
+
};
|
|
64
|
+
document.addEventListener('selectionchange', check);
|
|
65
|
+
document.addEventListener('keydown', (e) => {
|
|
66
|
+
if (e.key === 'Escape') {
|
|
67
|
+
pill.hidden = true;
|
|
68
|
+
if (!card.hidden) hideCard();
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
document.addEventListener('mousedown', (e) => {
|
|
72
|
+
if (!card.hidden && !card.contains(e.target)) hideCard();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
function hideCard() {
|
|
76
|
+
controller?.abort();
|
|
77
|
+
card.hidden = true;
|
|
78
|
+
pill.hidden = true;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function open() {
|
|
82
|
+
if (!picked) return;
|
|
83
|
+
pill.hidden = true;
|
|
84
|
+
history = [];
|
|
85
|
+
quote.textContent = '“' + (picked.text.length > 160 ? picked.text.slice(0, 160) + '…' : picked.text) + '”';
|
|
86
|
+
card.hidden = false;
|
|
87
|
+
position(card, picked.rect, 10);
|
|
88
|
+
ask(null);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function ask(followUp) {
|
|
92
|
+
controller?.abort();
|
|
93
|
+
controller = new AbortController();
|
|
94
|
+
const { signal } = controller;
|
|
95
|
+
answer.replaceChildren();
|
|
96
|
+
status.classList.remove('error');
|
|
97
|
+
status.textContent = 'Reading the page…';
|
|
98
|
+
const prompt = followUp || `Explain the selected text: "${picked.text}". Answer in ${answerLanguage(cfg)}.`;
|
|
99
|
+
const input = [`Page: ${pageTitle()}`, `Selected text: ${picked.text}`, `Where it appears: ${picked.paragraph}`, `The page:\n${pageText(12000)}`];
|
|
100
|
+
try {
|
|
101
|
+
const text = await stream(cfg, prompt, input, answer, {
|
|
102
|
+
system: cfg.rules,
|
|
103
|
+
history,
|
|
104
|
+
signal,
|
|
105
|
+
onDelta: () => (status.textContent = ''),
|
|
106
|
+
});
|
|
107
|
+
if (signal.aborted) return;
|
|
108
|
+
status.textContent = '';
|
|
109
|
+
history.push({ role: 'user', content: prompt }, { role: 'assistant', content: text });
|
|
110
|
+
history = history.slice(-6);
|
|
111
|
+
} catch (e) {
|
|
112
|
+
if (signal.aborted) return;
|
|
113
|
+
status.textContent = errorText(e);
|
|
114
|
+
status.classList.add('error');
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function spark() {
|
|
120
|
+
const s = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
|
121
|
+
s.setAttribute('width', '14');
|
|
122
|
+
s.setAttribute('height', '14');
|
|
123
|
+
s.setAttribute('viewBox', '0 0 24 24');
|
|
124
|
+
s.setAttribute('aria-hidden', 'true');
|
|
125
|
+
s.innerHTML = '<path fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round" d="M12 3l1.8 5.2L19 10l-5.2 1.8L12 17l-1.8-5.2L5 10l5.2-1.8z"/>';
|
|
126
|
+
return s;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const CSS = `
|
|
130
|
+
.onetag-pill { position: absolute; z-index: 30; display: inline-flex; align-items: center; gap: .35rem; font: inherit; font-size: var(--sl-text-sm); padding: .3rem .65rem; border: 1px solid var(--sl-color-accent); border-radius: 999px; background: var(--sl-color-accent-low); color: var(--sl-color-white); box-shadow: var(--sl-shadow-md); cursor: pointer; }
|
|
131
|
+
.onetag-pill:hover { background: var(--sl-color-accent); color: var(--sl-color-black); }
|
|
132
|
+
.onetag-card { position: absolute; z-index: 31; width: min(28rem, calc(100vw - 1rem)); padding: .9rem 1rem; border: 1px solid var(--sl-color-gray-5); border-radius: .5rem; background: var(--sl-color-gray-6); color: var(--sl-color-text); box-shadow: var(--sl-shadow-lg); }
|
|
133
|
+
.onetag-quote { color: var(--sl-color-gray-2); font-style: italic; overflow-wrap: anywhere; }
|
|
134
|
+
.onetag-follow { display: flex; gap: .4rem; margin-top: .6rem; }
|
|
135
|
+
.onetag-follow input { flex: 1; min-width: 0; font: inherit; font-size: var(--sl-text-sm); padding: .3rem .5rem; border: 1px solid var(--sl-color-gray-5); border-radius: .4rem; background: var(--sl-color-black); color: var(--sl-color-white); }
|
|
136
|
+
.onetag-follow input:focus { outline: 2px solid var(--sl-color-accent); outline-offset: 0; }
|
|
137
|
+
`;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* starlight-onetag-selection: select text in Starlight docs, click "Ask AI", get it explained in context.
|
|
3
|
+
*
|
|
4
|
+
* import starlightOnetagSelection from "starlight-onetag/selection";
|
|
5
|
+
* starlight({ plugins: [starlightOnetagSelection()] })
|
|
6
|
+
*/
|
|
7
|
+
import { makePlugin } from '../plugin.js';
|
|
8
|
+
|
|
9
|
+
export default function starlightOnetagSelection(options = {}) {
|
|
10
|
+
return makePlugin(
|
|
11
|
+
'starlight-onetag-selection',
|
|
12
|
+
{
|
|
13
|
+
key: options.key ?? '',
|
|
14
|
+
site: options.site ?? 'https://onetagai.com',
|
|
15
|
+
label: options.label ?? 'Ask AI',
|
|
16
|
+
minChars: options.minChars ?? 2,
|
|
17
|
+
maxChars: options.maxChars ?? 2000,
|
|
18
|
+
lang: options.lang ?? '',
|
|
19
|
+
rules:
|
|
20
|
+
options.rules ??
|
|
21
|
+
'A reader selected part of a documentation page and wants it explained. Explain the selected text in the context of the page: what it means, why it matters here, and an example if that helps. Plain words, under 120 words, no headings. If they ask a follow-up, answer it from the page.',
|
|
22
|
+
maxTokens: options.maxTokens ?? 400,
|
|
23
|
+
},
|
|
24
|
+
'selection explanations',
|
|
25
|
+
'starlight-onetag/selection/client'
|
|
26
|
+
);
|
|
27
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
// Shared helpers for the starlight-onetag-* plugins. Copied into each package so every
|
|
2
|
+
// plugin installs on its own.
|
|
3
|
+
|
|
4
|
+
const ERRORS = {
|
|
5
|
+
no_key: 'AI is not set up on this site yet: the site owner needs to add a OneTag AI key.',
|
|
6
|
+
bad_key: 'AI is not set up correctly on this site: its OneTag AI key is not valid.',
|
|
7
|
+
domain_not_allowed: 'This site is not allowed to use its OneTag AI key.',
|
|
8
|
+
quota_exceeded: 'This site has used up its AI allowance for now.',
|
|
9
|
+
paused: 'The AI service is paused for today. Try again after 00:00 UTC.',
|
|
10
|
+
rate_limited: 'Too many requests in one minute. Give it a moment.',
|
|
11
|
+
too_large: 'That is too long to send in one request.',
|
|
12
|
+
network: 'Could not reach the AI service. Check your connection and try again.',
|
|
13
|
+
upstream: 'The AI provider had a problem. Try again in a few seconds.',
|
|
14
|
+
aborted: 'Stopped.',
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export function errorText(e) {
|
|
18
|
+
return ERRORS[e?.code] || (e?.code && e.message) || 'Could not load the AI service. Check your connection and try again.';
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function isLocal() {
|
|
22
|
+
return /^(|localhost|127\.0\.0\.1|\[::1\]|.+\.localhost)$/.test(location.hostname);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// The onetagai SDK is its own chunk, fetched the first time a reader uses the feature.
|
|
26
|
+
let sdkPromise = null;
|
|
27
|
+
export function loadSdk(site) {
|
|
28
|
+
if (!sdkPromise) {
|
|
29
|
+
sdkPromise = import('onetagai').then((sdk) => {
|
|
30
|
+
if (site && site !== 'https://onetagai.com') sdk.configure({ endpoint: site.replace(/\/$/, '') + '/v1/ask' });
|
|
31
|
+
return sdk;
|
|
32
|
+
});
|
|
33
|
+
sdkPromise.catch(() => (sdkPromise = null));
|
|
34
|
+
}
|
|
35
|
+
return sdkPromise;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function contentRoot() {
|
|
39
|
+
return document.querySelector('.sl-markdown-content');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function pageTitle() {
|
|
43
|
+
return document.querySelector('h1#_top')?.textContent?.trim() || document.title;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Text of the docs content without code-copy buttons or our own UI.
|
|
47
|
+
export function pageText(max = 24000) {
|
|
48
|
+
const root = contentRoot();
|
|
49
|
+
if (!root) return '';
|
|
50
|
+
const clone = root.cloneNode(true);
|
|
51
|
+
clone.querySelectorAll('[data-onetag-ui], .copy, script, style, svg').forEach((n) => n.remove());
|
|
52
|
+
return (clone.innerText || clone.textContent || '').replace(/\n{3,}/g, '\n\n').trim().slice(0, max);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function addStyle(id, css) {
|
|
56
|
+
if (document.getElementById(id)) return;
|
|
57
|
+
const s = document.createElement('style');
|
|
58
|
+
s.id = id;
|
|
59
|
+
s.textContent = css;
|
|
60
|
+
document.head.append(s);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function el(tag, attrs = {}, ...children) {
|
|
64
|
+
const n = document.createElement(tag);
|
|
65
|
+
for (const [k, v] of Object.entries(attrs)) {
|
|
66
|
+
if (v === false || v == null) continue;
|
|
67
|
+
if (k === 'class') n.className = v;
|
|
68
|
+
else if (k.startsWith('on')) n.addEventListener(k.slice(2), v);
|
|
69
|
+
else n.setAttribute(k, v === true ? '' : v);
|
|
70
|
+
}
|
|
71
|
+
n.append(...children.filter((c) => c != null));
|
|
72
|
+
return n;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// A small, safe renderer for model answers: paragraphs, "- " lists, `code` and **bold**.
|
|
76
|
+
// Everything goes in as text nodes; nothing the model writes is parsed as HTML.
|
|
77
|
+
function inline(text) {
|
|
78
|
+
const out = [];
|
|
79
|
+
text.split(/(`[^`]+`|\*\*[^*]+\*\*)/g).forEach((part) => {
|
|
80
|
+
if (!part) return;
|
|
81
|
+
if (part.startsWith('`') && part.endsWith('`') && part.length > 2) out.push(el('code', {}, part.slice(1, -1)));
|
|
82
|
+
else if (part.startsWith('**') && part.endsWith('**') && part.length > 4) out.push(el('strong', {}, part.slice(2, -2)));
|
|
83
|
+
else out.push(document.createTextNode(part));
|
|
84
|
+
});
|
|
85
|
+
return out;
|
|
86
|
+
}
|
|
87
|
+
export function render(target, text) {
|
|
88
|
+
const nodes = [];
|
|
89
|
+
let list = null;
|
|
90
|
+
text.replace(/\r/g, '').split('\n').forEach((line) => {
|
|
91
|
+
const item = line.match(/^\s*(?:[-*•]|\d+[.)])\s+(.*)$/);
|
|
92
|
+
if (item) {
|
|
93
|
+
if (!list) nodes.push((list = el('ul')));
|
|
94
|
+
list.append(el('li', {}, ...inline(item[1])));
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
list = null;
|
|
98
|
+
const t = line.replace(/^#+\s*/, '').trim();
|
|
99
|
+
if (t) nodes.push(el('p', {}, ...inline(t)));
|
|
100
|
+
});
|
|
101
|
+
target.replaceChildren(...nodes);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Ask the model and stream into a target element. Returns the full text.
|
|
105
|
+
export async function stream(cfg, prompt, input, target, opts = {}) {
|
|
106
|
+
const sdk = await loadSdk(cfg.site);
|
|
107
|
+
let shown = '';
|
|
108
|
+
const text = await sdk.ask(prompt, input, {
|
|
109
|
+
key: cfg.key || undefined,
|
|
110
|
+
via: cfg.via,
|
|
111
|
+
system: opts.system,
|
|
112
|
+
history: opts.history,
|
|
113
|
+
maxTokens: opts.maxTokens || cfg.maxTokens,
|
|
114
|
+
signal: opts.signal,
|
|
115
|
+
stream: target
|
|
116
|
+
? (d) => {
|
|
117
|
+
shown += d;
|
|
118
|
+
render(target, shown);
|
|
119
|
+
opts.onDelta?.();
|
|
120
|
+
}
|
|
121
|
+
: undefined,
|
|
122
|
+
});
|
|
123
|
+
if (target) render(target, text);
|
|
124
|
+
return text;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// The language readers see answers in: the option, else the page's own language.
|
|
128
|
+
export function answerLanguage(cfg) {
|
|
129
|
+
if (cfg.lang) return cfg.lang;
|
|
130
|
+
const code = document.documentElement.lang || 'en';
|
|
131
|
+
try {
|
|
132
|
+
return new Intl.DisplayNames(['en'], { type: 'language' }).of(code) || code;
|
|
133
|
+
} catch {
|
|
134
|
+
return code;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function store(key, value) {
|
|
139
|
+
try {
|
|
140
|
+
if (value === undefined) return JSON.parse(localStorage.getItem(key) || 'null');
|
|
141
|
+
localStorage.setItem(key, JSON.stringify(value));
|
|
142
|
+
} catch {}
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function hash(s) {
|
|
147
|
+
let h = 5381;
|
|
148
|
+
for (let i = 0; i < s.length; i++) h = ((h << 5) + h + s.charCodeAt(i)) | 0;
|
|
149
|
+
return (h >>> 0).toString(36);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Shown once per page to the site owner on localhost when no key is set.
|
|
153
|
+
export function demoNote(cfg) {
|
|
154
|
+
if (cfg.key || !isLocal()) return null;
|
|
155
|
+
return el('p', { class: 'onetag-note' }, 'No key set: on localhost the answers come from a shared demo key. Add your own free OneTag AI key before you deploy.');
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export const BASE_CSS = `
|
|
159
|
+
[data-onetag-ui] { font-size: var(--sl-text-sm); }
|
|
160
|
+
[data-onetag-ui] [hidden], [data-onetag-ui][hidden] { display: none !important; }
|
|
161
|
+
[data-onetag-ui] p { margin: 0 0 .5em; }
|
|
162
|
+
[data-onetag-ui] p:last-child { margin-bottom: 0; }
|
|
163
|
+
[data-onetag-ui] ul { margin: 0 0 .5em; padding-inline-start: 1.2em; }
|
|
164
|
+
[data-onetag-ui] code { font-size: .9em; }
|
|
165
|
+
.onetag-btn { display: inline-flex; align-items: center; gap: .35rem; font: inherit; font-size: var(--sl-text-sm); padding: .25rem .6rem; border: 1px solid var(--sl-color-gray-5); border-radius: .4rem; background: var(--sl-color-gray-6); color: var(--sl-color-text); cursor: pointer; line-height: 1.5; }
|
|
166
|
+
.onetag-btn:hover { border-color: var(--sl-color-accent); color: var(--sl-color-white); }
|
|
167
|
+
.onetag-btn:focus-visible { outline: 2px solid var(--sl-color-accent); outline-offset: 2px; }
|
|
168
|
+
.onetag-btn[disabled] { opacity: .6; cursor: wait; }
|
|
169
|
+
.onetag-link { border: 0; background: none; padding: 0; font: inherit; color: var(--sl-color-text-accent); text-decoration: underline; text-underline-offset: 3px; cursor: pointer; }
|
|
170
|
+
.onetag-status { color: var(--sl-color-gray-3); }
|
|
171
|
+
.onetag-status.error { color: var(--sl-color-red-high, #f87171); }
|
|
172
|
+
.onetag-note { color: var(--sl-color-gray-3); font-size: var(--sl-text-xs); }
|
|
173
|
+
.onetag-foot { color: var(--sl-color-gray-3); font-size: var(--sl-text-xs); margin-top: .5em; }
|
|
174
|
+
.onetag-panel { margin-top: .75rem; padding: .75rem 1rem; border: 1px solid var(--sl-color-gray-5); border-inline-start: 3px solid var(--sl-color-accent); border-radius: .4rem; background: var(--sl-color-gray-6); }
|
|
175
|
+
`;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// Browser side of starlight-onetag-summary.
|
|
2
|
+
import { addStyle, answerLanguage, BASE_CSS, contentRoot, demoNote, el, errorText, hash, pageText, pageTitle, render, store, stream } from '../shared.js';
|
|
3
|
+
|
|
4
|
+
export default function init(cfg) {
|
|
5
|
+
const h1 = document.querySelector('h1#_top');
|
|
6
|
+
if (!h1 || !contentRoot()) return;
|
|
7
|
+
const text = pageText(28000);
|
|
8
|
+
if (text.length < cfg.minChars) return;
|
|
9
|
+
addStyle('starlight-onetag-summary', BASE_CSS + CSS);
|
|
10
|
+
|
|
11
|
+
const cacheKey = 'starlight-onetag-summary:' + location.pathname + ':' + hash(text);
|
|
12
|
+
const answer = el('div', { class: 'onetag-answer' });
|
|
13
|
+
const status = el('p', { class: 'onetag-status', 'aria-live': 'polite' });
|
|
14
|
+
const hide = el('button', { type: 'button', class: 'onetag-link', onclick: () => {
|
|
15
|
+
panel.hidden = true;
|
|
16
|
+
button.hidden = false;
|
|
17
|
+
button.focus();
|
|
18
|
+
} }, 'Hide');
|
|
19
|
+
const panel = el('div', { class: 'onetag-panel onetag-summary', hidden: true, 'data-onetag-ui': '' }, el('p', { class: 'onetag-summary-title' }, 'Summary'), status, answer, demoNote(cfg), el('p', { class: 'onetag-foot' }, 'Summarized by AI from this page. ', hide));
|
|
20
|
+
const button = el('button', { type: 'button', class: 'onetag-btn', 'data-onetag-ui': '', onclick: run }, lines(), cfg.label);
|
|
21
|
+
const wrap = el('div', { class: 'onetag-summary-wrap', 'data-onetag-ui': '' }, button, panel);
|
|
22
|
+
(document.querySelector('.onetag-translate') || h1).after(wrap);
|
|
23
|
+
|
|
24
|
+
let done = false;
|
|
25
|
+
async function run() {
|
|
26
|
+
button.hidden = true;
|
|
27
|
+
panel.hidden = false;
|
|
28
|
+
if (done) return;
|
|
29
|
+
const cached = cfg.cache ? store(cacheKey) : null;
|
|
30
|
+
if (typeof cached === 'string' && cached) {
|
|
31
|
+
render(answer, cached);
|
|
32
|
+
done = true;
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
status.classList.remove('error');
|
|
36
|
+
status.textContent = 'Reading the page…';
|
|
37
|
+
try {
|
|
38
|
+
const out = await stream(cfg, `Summarize this page. Answer in ${answerLanguage(cfg)}.`, [`Page: ${pageTitle()}`, text], answer, {
|
|
39
|
+
system: cfg.rules,
|
|
40
|
+
onDelta: () => (status.textContent = ''),
|
|
41
|
+
});
|
|
42
|
+
status.textContent = '';
|
|
43
|
+
done = true;
|
|
44
|
+
if (cfg.cache) store(cacheKey, out);
|
|
45
|
+
} catch (e) {
|
|
46
|
+
status.textContent = errorText(e);
|
|
47
|
+
status.classList.add('error');
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
if (cfg.auto) run();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function lines() {
|
|
54
|
+
const s = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
|
55
|
+
s.setAttribute('width', '15');
|
|
56
|
+
s.setAttribute('height', '15');
|
|
57
|
+
s.setAttribute('viewBox', '0 0 24 24');
|
|
58
|
+
s.setAttribute('aria-hidden', 'true');
|
|
59
|
+
s.innerHTML = '<path fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" d="M4 6h16M4 12h10M4 18h13"/>';
|
|
60
|
+
return s;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const CSS = `
|
|
64
|
+
.onetag-summary-wrap { margin-top: .75rem; }
|
|
65
|
+
.onetag-summary-title { font-weight: 600; color: var(--sl-color-white); }
|
|
66
|
+
`;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* starlight-onetag-summary: a "Summarize this page" box at the top of Starlight docs pages.
|
|
3
|
+
*
|
|
4
|
+
* import starlightOnetagSummary from "starlight-onetag/summary";
|
|
5
|
+
* starlight({ plugins: [starlightOnetagSummary()] })
|
|
6
|
+
*/
|
|
7
|
+
import { makePlugin } from '../plugin.js';
|
|
8
|
+
|
|
9
|
+
export default function starlightOnetagSummary(options = {}) {
|
|
10
|
+
return makePlugin(
|
|
11
|
+
'starlight-onetag-summary',
|
|
12
|
+
{
|
|
13
|
+
key: options.key ?? '',
|
|
14
|
+
site: options.site ?? 'https://onetagai.com',
|
|
15
|
+
label: options.label ?? 'Summarize this page',
|
|
16
|
+
minChars: options.minChars ?? 1500,
|
|
17
|
+
auto: options.auto ?? false,
|
|
18
|
+
cache: options.cache ?? true,
|
|
19
|
+
lang: options.lang ?? '',
|
|
20
|
+
rules:
|
|
21
|
+
options.rules ??
|
|
22
|
+
'You summarize a documentation page for a reader deciding whether it answers their question. Give 3 to 5 bullets, each one line, with the concrete facts: what the page lets them do, the key steps, commands or options, and any warning. No intro, no heading, no closing line.',
|
|
23
|
+
maxTokens: options.maxTokens ?? 400,
|
|
24
|
+
},
|
|
25
|
+
'page summaries',
|
|
26
|
+
'starlight-onetag/summary/client'
|
|
27
|
+
);
|
|
28
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
// Browser side of starlight-onetag-translate.
|
|
2
|
+
import { addStyle, BASE_CSS, contentRoot, el, errorText, hash, loadSdk, store, demoNote } from '../shared.js';
|
|
3
|
+
|
|
4
|
+
const SKIP = 'pre, code, kbd, samp, script, style, svg, .expressive-code, [translate="no"], .notranslate, [data-onetag-ui], .sl-anchor-link';
|
|
5
|
+
const PREF = 'starlight-onetag-translate:lang';
|
|
6
|
+
|
|
7
|
+
function name(code, inLang) {
|
|
8
|
+
try {
|
|
9
|
+
return new Intl.DisplayNames([inLang || code], { type: 'language' }).of(code) || code;
|
|
10
|
+
} catch {
|
|
11
|
+
return code;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
const base = (c) => String(c || '').toLowerCase().split('-')[0];
|
|
15
|
+
const same = (a, b) => {
|
|
16
|
+
a = String(a || '').toLowerCase();
|
|
17
|
+
b = String(b || '').toLowerCase();
|
|
18
|
+
if (base(a) !== base(b)) return false;
|
|
19
|
+
// Simplified and Traditional Chinese count as different languages.
|
|
20
|
+
const trad = (c) => /zh-(tw|hk|mo|hant)/.test(c);
|
|
21
|
+
return base(a) !== 'zh' || trad(a) === trad(b);
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
// Text nodes worth translating, in reading order: the page title and the docs content.
|
|
25
|
+
function collect() {
|
|
26
|
+
const roots = [document.querySelector('h1#_top'), contentRoot()].filter(Boolean);
|
|
27
|
+
const nodes = [];
|
|
28
|
+
for (const root of roots) {
|
|
29
|
+
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
|
|
30
|
+
acceptNode: (n) => (!n.nodeValue.trim() || n.parentElement?.closest(SKIP) ? NodeFilter.FILTER_REJECT : NodeFilter.FILTER_ACCEPT),
|
|
31
|
+
});
|
|
32
|
+
while (walker.nextNode()) nodes.push(walker.currentNode);
|
|
33
|
+
}
|
|
34
|
+
return nodes;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function batches(texts, size) {
|
|
38
|
+
const out = [];
|
|
39
|
+
let cur = [];
|
|
40
|
+
let len = 0;
|
|
41
|
+
texts.forEach((t, i) => {
|
|
42
|
+
if (cur.length && len + t.length > size) {
|
|
43
|
+
out.push(cur);
|
|
44
|
+
cur = [];
|
|
45
|
+
len = 0;
|
|
46
|
+
}
|
|
47
|
+
cur.push(i);
|
|
48
|
+
len += t.length + 6;
|
|
49
|
+
});
|
|
50
|
+
if (cur.length) out.push(cur);
|
|
51
|
+
return out;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const SYSTEM =
|
|
55
|
+
'You translate documentation for readers. Translate each numbered segment into the target language. ' +
|
|
56
|
+
'Segments are fragments of one page in reading order and may break mid-sentence: translate each fragment so that, read in order, the page reads naturally. ' +
|
|
57
|
+
'Translate every segment, including short fragments such as a single word or "Push to". Keep code identifiers, commands, file names, URLs, product names and placeholders unchanged. ' +
|
|
58
|
+
'Output one line per segment, starting with its marker, for example «3» text. Same count, same order, nothing else.';
|
|
59
|
+
|
|
60
|
+
export default function init(cfg) {
|
|
61
|
+
const h1 = document.querySelector('h1#_top');
|
|
62
|
+
if (!h1 || !contentRoot()) return;
|
|
63
|
+
addStyle('starlight-onetag-translate', BASE_CSS + CSS);
|
|
64
|
+
|
|
65
|
+
const pageLang = document.documentElement.lang || 'en';
|
|
66
|
+
const reader = navigator.language && !same(navigator.language, pageLang) ? navigator.language : null;
|
|
67
|
+
const langs = [...new Set(cfg.languages.filter((l) => !same(l, pageLang)))];
|
|
68
|
+
if (reader && !langs.some((l) => same(l, reader))) langs.unshift(reader);
|
|
69
|
+
|
|
70
|
+
let nodes = null;
|
|
71
|
+
let originals = null;
|
|
72
|
+
let controller = null;
|
|
73
|
+
let current = null;
|
|
74
|
+
|
|
75
|
+
const status = el('span', { class: 'onetag-status', 'aria-live': 'polite' });
|
|
76
|
+
const select = el('select', { 'aria-label': 'Translate this page into' },
|
|
77
|
+
el('option', { value: '' }, 'Translate…'),
|
|
78
|
+
...langs.map((l) => el('option', { value: l }, name(l)))
|
|
79
|
+
);
|
|
80
|
+
const quick = reader ? el('button', { type: 'button', class: 'onetag-btn', onclick: () => translate(reader) }, globe(), 'Read in ' + name(reader)) : null;
|
|
81
|
+
const original = el('button', { type: 'button', class: 'onetag-link', hidden: true, onclick: () => restore(true) }, 'Show original');
|
|
82
|
+
const note = demoNote(cfg);
|
|
83
|
+
const bar = el('div', { class: 'onetag-translate', 'data-onetag-ui': '' }, quick, select, status, original);
|
|
84
|
+
h1.after(bar);
|
|
85
|
+
if (note) bar.after(note), note.setAttribute('data-onetag-ui', '');
|
|
86
|
+
|
|
87
|
+
select.addEventListener('change', () => select.value && translate(select.value));
|
|
88
|
+
|
|
89
|
+
function restore(forget) {
|
|
90
|
+
if (controller) controller.abort();
|
|
91
|
+
if (nodes && originals) nodes.forEach((n, i) => (n.nodeValue = originals[i]));
|
|
92
|
+
current = null;
|
|
93
|
+
original.hidden = true;
|
|
94
|
+
status.textContent = '';
|
|
95
|
+
status.classList.remove('error');
|
|
96
|
+
select.value = '';
|
|
97
|
+
if (quick) quick.hidden = false;
|
|
98
|
+
if (forget) store(PREF, '');
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function translate(lang) {
|
|
102
|
+
if (current === lang) return;
|
|
103
|
+
if (current) restore(false);
|
|
104
|
+
if (controller) controller.abort();
|
|
105
|
+
controller = new AbortController();
|
|
106
|
+
const { signal } = controller;
|
|
107
|
+
nodes = nodes || collect();
|
|
108
|
+
originals = originals || nodes.map((n) => n.nodeValue);
|
|
109
|
+
const texts = originals.map((t) => t.replace(/\s+/g, ' ').trim());
|
|
110
|
+
current = lang;
|
|
111
|
+
if (cfg.remember) store(PREF, lang);
|
|
112
|
+
select.value = lang;
|
|
113
|
+
if (quick) quick.hidden = true;
|
|
114
|
+
status.classList.remove('error');
|
|
115
|
+
const cacheKey = 'starlight-onetag-translate:' + location.pathname + ':' + lang + ':' + hash(texts.join('\n'));
|
|
116
|
+
const cached = cfg.cache ? store(cacheKey) : null;
|
|
117
|
+
const result = Array.isArray(cached) && cached.length === texts.length ? cached : new Array(texts.length).fill(null);
|
|
118
|
+
const apply = () =>
|
|
119
|
+
result.forEach((t, i) => {
|
|
120
|
+
if (t == null) return;
|
|
121
|
+
const lead = originals[i].match(/^\s*/)[0];
|
|
122
|
+
const trail = originals[i].match(/\s*$/)[0];
|
|
123
|
+
nodes[i].nodeValue = lead + t + trail;
|
|
124
|
+
});
|
|
125
|
+
apply();
|
|
126
|
+
const todo = batches(texts, cfg.batchChars).filter((b) => b.some((i) => result[i] == null));
|
|
127
|
+
try {
|
|
128
|
+
const sdk = todo.length ? await loadSdk(cfg.site) : null;
|
|
129
|
+
for (let k = 0; k < todo.length; k++) {
|
|
130
|
+
status.textContent = `Translating ${k + 1}/${todo.length}…`;
|
|
131
|
+
const ids = todo[k];
|
|
132
|
+
const input = ids.map((i, j) => `«${j + 1}» ${texts[i]}`).join('\n');
|
|
133
|
+
const text = await sdk.ask(`Target language: ${name(lang, 'en')} (${lang}).`, input, {
|
|
134
|
+
key: cfg.key || undefined,
|
|
135
|
+
via: cfg.via,
|
|
136
|
+
system: SYSTEM,
|
|
137
|
+
maxTokens: cfg.maxTokens,
|
|
138
|
+
signal,
|
|
139
|
+
});
|
|
140
|
+
if (signal.aborted) return;
|
|
141
|
+
for (const line of text.split('\n')) {
|
|
142
|
+
const m = line.match(/^\s*«(\d+)»\s?(.*)$/);
|
|
143
|
+
if (m && ids[m[1] - 1] != null && m[2].trim()) result[ids[m[1] - 1]] = m[2].trim();
|
|
144
|
+
}
|
|
145
|
+
apply();
|
|
146
|
+
}
|
|
147
|
+
status.textContent = 'Translated by AI.';
|
|
148
|
+
original.hidden = false;
|
|
149
|
+
if (cfg.cache) store(cacheKey, result.map((t, i) => t ?? texts[i]));
|
|
150
|
+
} catch (e) {
|
|
151
|
+
if (signal.aborted) return;
|
|
152
|
+
status.textContent = errorText(e);
|
|
153
|
+
status.classList.add('error');
|
|
154
|
+
original.hidden = false;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const saved = cfg.remember ? store(PREF) : null;
|
|
159
|
+
if (saved && !same(saved, pageLang)) translate(saved);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function globe() {
|
|
163
|
+
const s = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
|
164
|
+
s.setAttribute('width', '15');
|
|
165
|
+
s.setAttribute('height', '15');
|
|
166
|
+
s.setAttribute('viewBox', '0 0 24 24');
|
|
167
|
+
s.setAttribute('aria-hidden', 'true');
|
|
168
|
+
s.innerHTML = '<g fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="12" r="9"/><path d="M3 12h18M12 3c2.5 2.7 3.8 5.7 3.8 9s-1.3 6.3-3.8 9c-2.5-2.7-3.8-5.7-3.8-9S9.5 5.7 12 3z"/></g>';
|
|
169
|
+
return s;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const CSS = `
|
|
173
|
+
.onetag-translate { display: flex; flex-wrap: wrap; align-items: center; gap: .5rem .75rem; margin-top: .75rem; font-size: var(--sl-text-sm); }
|
|
174
|
+
.onetag-translate select { font: inherit; font-size: var(--sl-text-sm); padding: .25rem .4rem; border: 1px solid var(--sl-color-gray-5); border-radius: .4rem; background: var(--sl-color-gray-6); color: var(--sl-color-text); }
|
|
175
|
+
.onetag-translate select:focus-visible { outline: 2px solid var(--sl-color-accent); outline-offset: 2px; }
|
|
176
|
+
`;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* starlight-onetag-translate: a "Read in <your language>" button on every Starlight page.
|
|
3
|
+
* One click translates the page for the reader with AI; nothing to maintain on your side.
|
|
4
|
+
*
|
|
5
|
+
* import starlightOnetagTranslate from "starlight-onetag/translate";
|
|
6
|
+
* starlight({ plugins: [starlightOnetagTranslate()] })
|
|
7
|
+
*/
|
|
8
|
+
import { makePlugin } from '../plugin.js';
|
|
9
|
+
|
|
10
|
+
export default function starlightOnetagTranslate(options = {}) {
|
|
11
|
+
return makePlugin(
|
|
12
|
+
'starlight-onetag-translate',
|
|
13
|
+
{
|
|
14
|
+
key: options.key ?? '',
|
|
15
|
+
site: options.site ?? 'https://onetagai.com',
|
|
16
|
+
languages: options.languages ?? ['en', 'zh-CN', 'zh-TW', 'ja', 'ko', 'es', 'pt-BR', 'fr', 'de', 'ru', 'it', 'tr', 'vi', 'id', 'ar', 'hi'],
|
|
17
|
+
remember: options.remember ?? true,
|
|
18
|
+
cache: options.cache ?? true,
|
|
19
|
+
batchChars: options.batchChars ?? 3500,
|
|
20
|
+
maxTokens: options.maxTokens ?? 4000,
|
|
21
|
+
},
|
|
22
|
+
'page translation',
|
|
23
|
+
'starlight-onetag/translate/client'
|
|
24
|
+
);
|
|
25
|
+
}
|