@@ -0,0 +1,27 @@
|
||||
name: publish-content
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- run: npm run build:content
|
||||
env:
|
||||
CONTENT_SHA: ${{ github.sha }}
|
||||
- uses: peaceiris/actions-gh-pages@v4
|
||||
with:
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
publish_dir: ./out
|
||||
publish_branch: published
|
||||
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
dist/
|
||||
out/
|
||||
*.log
|
||||
@@ -0,0 +1,145 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import process from 'node:process';
|
||||
import matter from 'gray-matter';
|
||||
import { parseDoc, validateDoc, serializeDoc } from './md.js';
|
||||
import { optimizeImage, hashFile } from './images.js';
|
||||
|
||||
const ROOT = process.cwd();
|
||||
const CONTENT = path.join(ROOT, 'content');
|
||||
const OUT = path.join(ROOT, 'out');
|
||||
|
||||
const cfg = JSON.parse(await fs.readFile(path.join(ROOT, 'config.json'), 'utf8'));
|
||||
if (cfg.repo === 'USER/REPO') {
|
||||
console.error('Заполните config.json: поле "repo" (например "ivanov/panel-content").');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// CONTENT_SHA задаёт CI; локально используем ветку (TTL кэша jsDelivr у ветки больше).
|
||||
const sha = process.env.CONTENT_SHA ?? cfg.branch;
|
||||
const CDN = `https://cdn.jsdelivr.net/gh/${cfg.repo}@${sha}`;
|
||||
|
||||
const errors = [];
|
||||
const imageCache = new Map();
|
||||
|
||||
function report(file, pos, hint) {
|
||||
errors.push({ file, line: pos?.line ?? 0, column: pos?.column ?? 0, hint });
|
||||
}
|
||||
|
||||
// Относительные пути картинок резолвятся от .md-файла, оптимизируются sharp-ом
|
||||
// и переписываются на абсолютный URL (jsDelivr @sha).
|
||||
async function resolveImage(src, { absPath, id, pos }) {
|
||||
if (!src) return null;
|
||||
if (/^https?:\/\//i.test(src)) {
|
||||
if (!/^https:\/\//i.test(src)) {
|
||||
report(absPath, pos, `внешняя картинка должна быть https: ${src}`);
|
||||
return null;
|
||||
}
|
||||
return src;
|
||||
}
|
||||
const abs = path.resolve(path.dirname(absPath), src);
|
||||
if (!abs.startsWith(path.join(CONTENT, path.sep))) {
|
||||
report(absPath, pos, `путь картинки вне content/: ${src}`);
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
await fs.access(abs);
|
||||
} catch {
|
||||
report(absPath, pos, `файл картинки не найден: ${src}`);
|
||||
return null;
|
||||
}
|
||||
const hash = await hashFile(abs);
|
||||
const name = `${id}-${hash}.webp`;
|
||||
if (!imageCache.has(name)) {
|
||||
await optimizeImage(abs, path.join(OUT, 'img', name), { width: 640 });
|
||||
imageCache.set(name, true);
|
||||
}
|
||||
return `${CDN}/img/${name}`;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
await fs.rm(OUT, { recursive: true, force: true });
|
||||
await fs.mkdir(path.join(OUT, 'docs'), { recursive: true });
|
||||
|
||||
const docsDir = path.join(CONTENT, 'docs');
|
||||
const files = (await fs.readdir(docsDir)).filter((f) => f.endsWith('.md'));
|
||||
if (!files.length) {
|
||||
console.error('В content/docs/ нет .md файлов.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const index = [];
|
||||
for (const fileName of files) {
|
||||
const absPath = path.join(docsDir, fileName);
|
||||
const id = fileName.replace(/\.md$/, '');
|
||||
const { data, content } = matter(await fs.readFile(absPath, 'utf8'));
|
||||
|
||||
if (typeof data.title !== 'string' || !data.title.trim()) {
|
||||
report(absPath, null, 'front-matter: обязательно поле title (строка)');
|
||||
continue;
|
||||
}
|
||||
|
||||
const ast = parseDoc(content);
|
||||
errors.push(
|
||||
...validateDoc(ast, fileName).map((e) => ({
|
||||
file: e.file,
|
||||
line: e.line,
|
||||
column: e.column,
|
||||
hint: `${e.type}: ${e.hint}`,
|
||||
})),
|
||||
);
|
||||
if (errors.some((e) => e.file === fileName)) continue;
|
||||
|
||||
const blocks = await serializeDoc(ast, {
|
||||
resolveImage: (src, pos) => resolveImage(src, { absPath, id, pos }),
|
||||
});
|
||||
|
||||
// Заголовочная картинка: относительный путь от content/assets, кроп 636×340 (318×170 @2x).
|
||||
let header = null;
|
||||
if (data.header) {
|
||||
const headerPath = path.resolve(CONTENT, 'assets', data.header);
|
||||
try {
|
||||
await fs.access(headerPath);
|
||||
} catch {
|
||||
report(absPath, null, `front-matter header: файл не найден content/assets/${data.header}`);
|
||||
continue;
|
||||
}
|
||||
const name = `${id}-header.webp`;
|
||||
await optimizeImage(headerPath, path.join(OUT, 'img', name), {
|
||||
width: 636,
|
||||
height: 340,
|
||||
});
|
||||
header = `${CDN}/img/${name}`;
|
||||
}
|
||||
|
||||
await fs.writeFile(
|
||||
path.join(OUT, 'docs', `${id}.json`),
|
||||
JSON.stringify({ id, title: data.title, header, blocks }),
|
||||
);
|
||||
index.push({
|
||||
id,
|
||||
title: data.title,
|
||||
order: Number.isFinite(data.order) ? data.order : 1000,
|
||||
hidden: Boolean(data.hidden),
|
||||
header,
|
||||
url: `${CDN}/docs/${id}.json`,
|
||||
});
|
||||
}
|
||||
|
||||
if (errors.length) {
|
||||
console.error('\nОшибки сборки контента:');
|
||||
for (const e of errors) {
|
||||
console.error(` ${e.file}${e.line ? `:${e.line}${e.column ? `:${e.column}` : ''}` : ''} — ${e.hint}`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
index.sort((a, b) => a.order - b.order || a.id.localeCompare(b.id));
|
||||
await fs.writeFile(path.join(OUT, 'index.json'), JSON.stringify(index, null, 2));
|
||||
|
||||
const docsCount = index.length;
|
||||
const size = (await fs.stat(path.join(OUT, 'index.json'))).size;
|
||||
console.log(`Готово: ${docsCount} документ(ов), index.json ${size} байт → out/`);
|
||||
}
|
||||
|
||||
await main();
|
||||
@@ -0,0 +1,18 @@
|
||||
import sharp from 'sharp';
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
export async function hashFile(p) {
|
||||
const buf = await fs.readFile(p);
|
||||
return crypto.createHash('sha256').update(buf).digest('hex').slice(0, 8);
|
||||
}
|
||||
|
||||
export async function optimizeImage(src, dest, { width = 640, height = null } = {}) {
|
||||
await fs.mkdir(path.dirname(dest), { recursive: true });
|
||||
let img = sharp(src);
|
||||
img = height
|
||||
? img.resize(width, height, { fit: 'cover' })
|
||||
: img.resize({ width, withoutEnlargement: true });
|
||||
await img.webp({ quality: 80 }).toFile(dest);
|
||||
}
|
||||
+178
@@ -0,0 +1,178 @@
|
||||
import { unified } from 'unified';
|
||||
import remarkParse from 'remark-parse';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
|
||||
const parser = unified().use(remarkParse).use(remarkGfm);
|
||||
|
||||
export function parseDoc(md) {
|
||||
return parser.parse(md);
|
||||
}
|
||||
|
||||
// Белый список узлов mdast — всё прочее = ошибка сборки.
|
||||
const ALLOWED = new Set([
|
||||
'root',
|
||||
'heading',
|
||||
'paragraph',
|
||||
'text',
|
||||
'emphasis',
|
||||
'strong',
|
||||
'delete',
|
||||
'inlineCode',
|
||||
'break',
|
||||
'link',
|
||||
'image',
|
||||
'list',
|
||||
'listItem',
|
||||
'blockquote',
|
||||
'thematicBreak',
|
||||
]);
|
||||
|
||||
const HINTS = {
|
||||
html: 'сырой HTML в markdown запрещён',
|
||||
code: 'блоки кода запрещены (подсветка не поддерживается)',
|
||||
table: 'таблицы не поддерживаются',
|
||||
footnoteDefinition: 'сноски не поддерживаются',
|
||||
linkReference: 'используйте обычные ссылки [текст](https://…)',
|
||||
imageReference: 'используйте обычные картинки ',
|
||||
definition: 'ссылки-определения не поддерживаются',
|
||||
yaml: 'yaml-блок внутри документа запрещён',
|
||||
};
|
||||
|
||||
const SCHEME_RE = /^[a-z][a-z0-9+.-]*:/i;
|
||||
|
||||
export function validateDoc(root, fileName) {
|
||||
const errors = [];
|
||||
const walk = (node) => {
|
||||
for (const child of node.children ?? []) {
|
||||
const pos = child.position?.start ?? {};
|
||||
if (!ALLOWED.has(child.type)) {
|
||||
errors.push({
|
||||
file: fileName,
|
||||
line: pos.line ?? 0,
|
||||
column: pos.column ?? 0,
|
||||
type: child.type,
|
||||
hint: HINTS[child.type] ?? 'не поддерживается белым списком',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (child.type === 'link' && !/^https?:\/\//i.test(child.url)) {
|
||||
errors.push({
|
||||
file: fileName,
|
||||
line: pos.line ?? 0,
|
||||
column: pos.column ?? 0,
|
||||
type: 'link',
|
||||
hint: `ссылка должна быть http(s): ${child.url}`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
child.type === 'image' &&
|
||||
SCHEME_RE.test(child.url) &&
|
||||
!/^https?:\/\//i.test(child.url)
|
||||
) {
|
||||
errors.push({
|
||||
file: fileName,
|
||||
line: pos.line ?? 0,
|
||||
column: pos.column ?? 0,
|
||||
type: 'image',
|
||||
hint: `картинка должна быть http(s) или относительным путём: ${child.url}`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
walk(child);
|
||||
}
|
||||
};
|
||||
walk(root);
|
||||
return errors;
|
||||
}
|
||||
|
||||
// --- Сериализация в мини-AST для DocRenderer.svelte ---
|
||||
// Блоки: ["p", ...inline] | ["h1".."h6", ...inline] | ["ul"|"ol", [items]]
|
||||
// | ["blockquote", ...blocks] | ["hr"]
|
||||
// Инлайн: строка | ["em"|"strong"|"del", ...inline] | ["code", text]
|
||||
// | ["br"] | ["a", href, ...inline] | ["img", url, alt]
|
||||
|
||||
const norm = (s) => s.replace(/\s+/g, ' ');
|
||||
|
||||
export async function serializeDoc(root, ctx) {
|
||||
const blocks = [];
|
||||
for (const child of root.children) {
|
||||
blocks.push(...(await serBlock(child, ctx)));
|
||||
}
|
||||
return blocks;
|
||||
}
|
||||
|
||||
async function serBlock(node, ctx) {
|
||||
switch (node.type) {
|
||||
case 'heading':
|
||||
return [['h' + Math.min(node.depth, 6), ...(await inline(node.children, ctx))]];
|
||||
case 'paragraph': {
|
||||
if (node.children.length === 1 && node.children[0].type === 'image') {
|
||||
const img = await serImage(node.children[0], ctx);
|
||||
return img ? [img] : [];
|
||||
}
|
||||
return [['p', ...(await inline(node.children, ctx))]];
|
||||
}
|
||||
case 'list': {
|
||||
const items = [];
|
||||
for (const item of node.children) items.push(await serItem(item, ctx));
|
||||
return [[node.ordered ? 'ol' : 'ul', items]];
|
||||
}
|
||||
case 'blockquote': {
|
||||
const blocks = [];
|
||||
for (const child of node.children) blocks.push(...(await serBlock(child, ctx)));
|
||||
return [['blockquote', ...blocks]];
|
||||
}
|
||||
case 'thematicBreak':
|
||||
return [['hr']];
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function serItem(item, ctx) {
|
||||
const prefix = item.checked === true ? '☑ ' : item.checked === false ? '☐ ' : '';
|
||||
const single =
|
||||
item.children.length === 1 && item.children[0].type === 'paragraph';
|
||||
const inner = single
|
||||
? await inline(item.children[0].children, ctx)
|
||||
: (await Promise.all(item.children.map((c) => serBlock(c, ctx)))).flat();
|
||||
return prefix ? ['li', prefix, ...inner] : ['li', ...inner];
|
||||
}
|
||||
|
||||
async function inline(nodes, ctx) {
|
||||
const out = [];
|
||||
for (const node of nodes) {
|
||||
const serialized = await serInline(node, ctx);
|
||||
if (serialized !== null && serialized !== '') out.push(serialized);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function serInline(node, ctx) {
|
||||
switch (node.type) {
|
||||
case 'text':
|
||||
return norm(node.value);
|
||||
case 'emphasis':
|
||||
return ['em', ...(await inline(node.children, ctx))];
|
||||
case 'strong':
|
||||
return ['strong', ...(await inline(node.children, ctx))];
|
||||
case 'delete':
|
||||
return ['del', ...(await inline(node.children, ctx))];
|
||||
case 'inlineCode':
|
||||
return ['code', node.value];
|
||||
case 'break':
|
||||
return ['br'];
|
||||
case 'link':
|
||||
return ['a', node.url, ...(await inline(node.children, ctx))];
|
||||
case 'image':
|
||||
return serImage(node, ctx);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function serImage(node, ctx) {
|
||||
const url = await ctx.resolveImage(node.url, node.position?.start);
|
||||
return url ? ['img', url, node.alt ?? ''] : null;
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Panel Config</title>
|
||||
<!-- Extension Helper обязателен для всех html расширения -->
|
||||
<script src="https://extension-files.twitch.tv/helper/v1/twitch-ext.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/config/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"repo": "USER/REPO",
|
||||
"branch": "published"
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
---
|
||||
title: О канале
|
||||
order: 1
|
||||
---
|
||||
|
||||
Привет! Я стримлю **игры и музыку** по вечерам.
|
||||
|
||||
## Расписание
|
||||
|
||||
- Вт, Чт, Сб — с 19:00 МСК
|
||||
- Иногда *утренние* стримы в выходные
|
||||
|
||||
> Расписание может меняться — следи за постами в Discord.
|
||||
|
||||
## Где меня найти
|
||||
|
||||
1. [Telegram](https://t.me/example) — анонсы
|
||||
2. [Discord](https://discord.gg/example) — общение
|
||||
@@ -0,0 +1,16 @@
|
||||
---
|
||||
title: Правила чата
|
||||
order: 2
|
||||
---
|
||||
|
||||
## Общие правила
|
||||
|
||||
1. Уважай друг друга — *без* оскорблений и политики.
|
||||
2. Без спама и капса.
|
||||
3. Вопросы задавай в чат — модераторы помогут.
|
||||
|
||||
## Наказания
|
||||
|
||||
- Первый раз — предупреждение
|
||||
- Второй раз — **таймаут 10 минут**
|
||||
- Третий раз — бан
|
||||
Generated
+3272
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "twitch-panel-bio",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"version": "0.1.0",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build:viewer": "vite build -c vite.build.viewer.js",
|
||||
"build:config": "vite build -c vite.build.config.js",
|
||||
"build": "npm run build:viewer && npm run build:config && node scripts/pack.js",
|
||||
"build:content": "node builder/build.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"gray-matter": "^4.0.3",
|
||||
"remark-gfm": "^4.0.0",
|
||||
"remark-parse": "^11.0.0",
|
||||
"sharp": "^0.33.5",
|
||||
"svelte": "^5.0.0",
|
||||
"unified": "^11.0.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
||||
"@vitejs/plugin-basic-ssl": "^1.2.0",
|
||||
"vite": "^6.0.0",
|
||||
"vite-plugin-singlefile": "^2.0.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import fs from 'node:fs/promises';
|
||||
|
||||
await fs.mkdir('dist/pkg', { recursive: true });
|
||||
await fs.copyFile('dist/viewer/viewer.html', 'dist/pkg/viewer.html');
|
||||
await fs.copyFile('dist/config/config.html', 'dist/pkg/config.html');
|
||||
console.log('dist/pkg готов: viewer.html + config.html — загрузите их содержимое как zip-билд в консоли Twitch.');
|
||||
@@ -0,0 +1,149 @@
|
||||
<script>
|
||||
import { isTwitch, getBroadcasterConfig, saveBroadcasterConfig } from '../shared/twitch.js';
|
||||
import { INDEX_URL } from '../shared/content.js';
|
||||
|
||||
let list = $state([]); // рабочая копия: { id, title, hidden }
|
||||
let status = $state('loading'); // loading | ready | error
|
||||
let error = $state('');
|
||||
let saved = $state(false);
|
||||
|
||||
const savedCfg = getBroadcasterConfig();
|
||||
|
||||
async function load() {
|
||||
status = 'loading';
|
||||
error = '';
|
||||
try {
|
||||
const res = await fetch(INDEX_URL);
|
||||
if (!res.ok) throw new Error(`index.json: HTTP ${res.status}`);
|
||||
const index = await res.json();
|
||||
const hidden = new Set(savedCfg?.hidden ?? []);
|
||||
const order = new Map((savedCfg?.order ?? []).map((id, i) => [id, i]));
|
||||
list = index
|
||||
.filter((d) => !d.hidden)
|
||||
.map((d) => ({ id: d.id, title: d.title, hidden: hidden.has(d.id) }))
|
||||
.sort((a, b) => (order.get(a.id) ?? Infinity) - (order.get(b.id) ?? Infinity));
|
||||
status = 'ready';
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
error = String(e.message ?? e);
|
||||
status = 'error';
|
||||
}
|
||||
}
|
||||
|
||||
load();
|
||||
|
||||
function move(i, dir) {
|
||||
const j = i + dir;
|
||||
if (j < 0 || j >= list.length) return;
|
||||
[list[i], list[j]] = [list[j], list[i]];
|
||||
}
|
||||
|
||||
function save() {
|
||||
saved = saveBroadcasterConfig({
|
||||
v: 1,
|
||||
hidden: list.filter((d) => d.hidden).map((d) => d.id),
|
||||
order: list.map((d) => d.id),
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="config">
|
||||
<h1>Документы панели</h1>
|
||||
<p class="muted">Порядок и видимость для этого канала. Содержимое документов правится в git-репозитории.</p>
|
||||
|
||||
{#if status === 'loading'}
|
||||
<p class="muted">Загрузка…</p>
|
||||
{:else if status === 'error'}
|
||||
<p>Не удалось загрузить index.json.</p>
|
||||
<p class="muted">{error}</p>
|
||||
<button onclick={load}>Повторить</button>
|
||||
{:else}
|
||||
{#each list as doc, i (doc.id)}
|
||||
<div class="row">
|
||||
<span class="title">{doc.title}</span>
|
||||
<span class="controls">
|
||||
<button title="Выше" onclick={() => move(i, -1)} disabled={i === 0}>↑</button>
|
||||
<button title="Ниже" onclick={() => move(i, 1)} disabled={i === list.length - 1}>↓</button>
|
||||
<label>
|
||||
<input type="checkbox" bind:checked={doc.hidden} />
|
||||
скрыть
|
||||
</label>
|
||||
</span>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="muted">Список пуст: соберите контент билдером и сделайте push.</p>
|
||||
{/each}
|
||||
|
||||
<div class="save">
|
||||
<button onclick={save}>Сохранить</button>
|
||||
{#if saved}
|
||||
<span class="ok">Сохранено</span>
|
||||
{:else if !isTwitch}
|
||||
<span class="muted">Локальный режим: сохранение доступно только в Creator Dashboard.</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.config {
|
||||
font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
|
||||
color: #efeff1;
|
||||
background: #0e0e10;
|
||||
min-height: 100vh;
|
||||
box-sizing: border-box;
|
||||
padding: 16px;
|
||||
max-width: 640px;
|
||||
}
|
||||
h1 {
|
||||
font-size: 1.2em;
|
||||
}
|
||||
.muted {
|
||||
color: #adadb8;
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid #3a3a3d;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
label {
|
||||
font-size: 0.85em;
|
||||
color: #adadb8;
|
||||
}
|
||||
button {
|
||||
background: #3a3a3d;
|
||||
color: #efeff1;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
height: 28px;
|
||||
min-width: 28px;
|
||||
cursor: pointer;
|
||||
}
|
||||
button:hover {
|
||||
filter: brightness(1.2);
|
||||
}
|
||||
.save {
|
||||
margin-top: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.save button {
|
||||
min-width: 100px;
|
||||
height: 32px;
|
||||
}
|
||||
.ok {
|
||||
color: #00f593;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,4 @@
|
||||
import { mount } from 'svelte';
|
||||
import App from './App.svelte';
|
||||
|
||||
export default mount(App, { target: document.getElementById('app') });
|
||||
@@ -0,0 +1,139 @@
|
||||
<script>
|
||||
// Рекурсивный рендерер мини-AST из билдера. Никакого {@html}: строка = текст
|
||||
// (эскейпит Svelte), узел = [tag, ...rest]. Неизвестное — пропускаем с warning.
|
||||
let { nodes = [] } = $props();
|
||||
|
||||
// Картинки разрешены только с контентных CDN-хостов (даже если JSON подменили).
|
||||
const IMG_HOSTS = /^https:\/\/(cdn\.jsdelivr\.net|raw\.githubusercontent\.com)\//;
|
||||
const LINK_RE = /^https?:\/\//i;
|
||||
|
||||
function safeImg(src) {
|
||||
return typeof src === 'string' && IMG_HOSTS.test(src) ? src : null;
|
||||
}
|
||||
|
||||
function safeLink(href) {
|
||||
return typeof href === 'string' && LINK_RE.test(href) ? href : null;
|
||||
}
|
||||
</script>
|
||||
|
||||
{#snippet children(ns)}
|
||||
{#each ns as c}{@render node(c)}{/each}
|
||||
{/snippet}
|
||||
|
||||
{#snippet node(n)}
|
||||
{#if typeof n === 'string'}
|
||||
{n}
|
||||
{:else if Array.isArray(n)}
|
||||
{@const [tag, ...rest] = n}
|
||||
{#if tag === 'p'}
|
||||
<p>{@render children(rest)}</p>
|
||||
{:else if tag === 'br'}
|
||||
<br />
|
||||
{:else if tag === 'hr'}
|
||||
<hr />
|
||||
{:else if tag === 'em'}
|
||||
<em>{@render children(rest)}</em>
|
||||
{:else if tag === 'strong'}
|
||||
<strong>{@render children(rest)}</strong>
|
||||
{:else if tag === 'del'}
|
||||
<del>{@render children(rest)}</del>
|
||||
{:else if tag === 'code'}
|
||||
<code>{rest[0]}</code>
|
||||
{:else if tag === 'a'}
|
||||
{@const href = safeLink(rest[0])}
|
||||
{#if href}
|
||||
<a href={href} target="_blank" rel="noopener noreferrer">{@render children(rest.slice(1))}</a>
|
||||
{:else}
|
||||
{@render children(rest.slice(1))}
|
||||
{/if}
|
||||
{:else if tag === 'img'}
|
||||
{@const src = safeImg(rest[0])}
|
||||
{#if src}
|
||||
<img src={src} alt={rest[1] ?? ''} loading="lazy" />
|
||||
{/if}
|
||||
{:else if /^h[1-6]$/.test(tag)}
|
||||
<svelte:element this={tag}>{@render children(rest)}</svelte:element>
|
||||
{:else if tag === 'ul' || tag === 'ol'}
|
||||
<svelte:element this={tag}>
|
||||
{#each rest[0] ?? [] as item}{@render node(item)}{/each}
|
||||
</svelte:element>
|
||||
{:else if tag === 'li'}
|
||||
<li>{@render children(rest)}</li>
|
||||
{:else if tag === 'blockquote'}
|
||||
<blockquote>{#each rest as b}{@render node(b)}{/each}</blockquote>
|
||||
{:else}
|
||||
{@const _warn = console.warn('DocRenderer: неизвестный узел', tag)}
|
||||
{/if}
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
{#each nodes as n}{@render node(n)}{/each}
|
||||
|
||||
<style>
|
||||
p {
|
||||
margin: 0.35em 0;
|
||||
}
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
margin: 0.8em 0 0.3em;
|
||||
line-height: 1.25;
|
||||
}
|
||||
h1 {
|
||||
font-size: 1.15em;
|
||||
}
|
||||
h2 {
|
||||
font-size: 1.08em;
|
||||
}
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
font-size: 1em;
|
||||
}
|
||||
ul,
|
||||
ol {
|
||||
margin: 0.35em 0;
|
||||
padding-left: 1.4em;
|
||||
}
|
||||
li {
|
||||
margin: 0.15em 0;
|
||||
}
|
||||
blockquote {
|
||||
margin: 0.5em 0;
|
||||
padding: 0.1em 0.8em;
|
||||
border-left: 3px solid var(--muted);
|
||||
color: var(--muted);
|
||||
}
|
||||
hr {
|
||||
border: 0;
|
||||
border-top: 1px solid var(--border);
|
||||
margin: 0.8em 0;
|
||||
}
|
||||
a {
|
||||
color: var(--link);
|
||||
text-decoration: none;
|
||||
}
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
img {
|
||||
max-width: 100%;
|
||||
border-radius: 6px;
|
||||
display: block;
|
||||
margin: 0.5em 0;
|
||||
}
|
||||
code {
|
||||
font-family: ui-monospace, Consolas, monospace;
|
||||
font-size: 0.9em;
|
||||
background: var(--border);
|
||||
border-radius: 4px;
|
||||
padding: 0.1em 0.35em;
|
||||
}
|
||||
del {
|
||||
opacity: 0.6;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,6 @@
|
||||
import cfg from '../../config.json';
|
||||
|
||||
// Индекс контента публикуется CI в ветку `published`; raw.githubusercontent
|
||||
// отдаёт его с кэшем 5 минут и CORS *. Документы и картинки внутри индекса —
|
||||
// абсолютные URL на jsDelivr, запиннованные на SHA коммита (иммутабельные).
|
||||
export const INDEX_URL = `https://raw.githubusercontent.com/${cfg.repo}/${cfg.branch}/index.json`;
|
||||
@@ -0,0 +1,46 @@
|
||||
// Обёртка над Twitch Extension Helper с заглушкой для локальной разработки:
|
||||
// вне Twitch (npm run dev) всё работает, сохранение конфига просто логируется.
|
||||
|
||||
const ext = globalThis.Twitch?.ext ?? null;
|
||||
|
||||
export const isTwitch = Boolean(ext);
|
||||
|
||||
const contextListeners = [];
|
||||
const authListeners = [];
|
||||
|
||||
if (ext) {
|
||||
ext.onContext((ctx) => contextListeners.forEach((cb) => cb(ctx)));
|
||||
ext.onAuthorized((auth) => authListeners.forEach((cb) => cb(auth)));
|
||||
}
|
||||
|
||||
export function onContext(cb) {
|
||||
contextListeners.push(cb);
|
||||
if (!ext) cb({ theme: 'dark' });
|
||||
}
|
||||
|
||||
export function onAuthorized(cb) {
|
||||
authListeners.push(cb);
|
||||
}
|
||||
|
||||
// Сегмент broadcaster — публичные настройки канала (JSON или null).
|
||||
export function getBroadcasterConfig() {
|
||||
if (!ext) return null;
|
||||
const raw = ext.configuration?.broadcaster?.content;
|
||||
if (!raw) return null;
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
console.warn('twitch.js: broadcaster config не парсится как JSON');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Разрешено вызывать только из config-вью от имени стримера.
|
||||
export function saveBroadcasterConfig(value) {
|
||||
if (!ext) {
|
||||
console.warn('[dev] saveBroadcasterConfig:', value);
|
||||
return false;
|
||||
}
|
||||
ext.configuration.set('broadcaster', '1', JSON.stringify(value));
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
<script>
|
||||
import { onContext, getBroadcasterConfig } from '../shared/twitch.js';
|
||||
import { INDEX_URL } from '../shared/content.js';
|
||||
import DocRenderer from '../shared/DocRenderer.svelte';
|
||||
|
||||
let theme = $state('dark');
|
||||
onContext((ctx) => {
|
||||
if (ctx.theme) theme = ctx.theme;
|
||||
});
|
||||
|
||||
let docs = $state([]);
|
||||
let current = $state(0);
|
||||
let status = $state('loading'); // loading | ready | empty | error
|
||||
let loadError = $state('');
|
||||
let doc = $state(null);
|
||||
let docError = $state('');
|
||||
|
||||
// Per-channel исключения из конфиг-сегмента: скрыть/перепорядочить.
|
||||
const overrides = getBroadcasterConfig();
|
||||
|
||||
async function loadIndex() {
|
||||
status = 'loading';
|
||||
loadError = '';
|
||||
try {
|
||||
const res = await fetch(INDEX_URL);
|
||||
if (!res.ok) throw new Error(`index.json: HTTP ${res.status}`);
|
||||
let list = await res.json();
|
||||
if (overrides) {
|
||||
const hidden = new Set(overrides.hidden ?? []);
|
||||
const order = new Map((overrides.order ?? []).map((id, i) => [id, i]));
|
||||
list = list.filter((d) => !hidden.has(d.id));
|
||||
list.sort((a, b) => (order.get(a.id) ?? Infinity) - (order.get(b.id) ?? Infinity));
|
||||
}
|
||||
docs = list;
|
||||
if (!list.length) {
|
||||
status = 'empty';
|
||||
return;
|
||||
}
|
||||
status = 'ready';
|
||||
await loadDoc(0);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
loadError = String(e.message ?? e);
|
||||
status = 'error';
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDoc(i) {
|
||||
current = i;
|
||||
doc = null;
|
||||
docError = '';
|
||||
try {
|
||||
const res = await fetch(docs[i].url);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
doc = await res.json();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
docError = String(e.message ?? e);
|
||||
}
|
||||
}
|
||||
|
||||
const prev = () => current > 0 && loadDoc(current - 1);
|
||||
const next = () => current < docs.length - 1 && loadDoc(current + 1);
|
||||
|
||||
loadIndex();
|
||||
</script>
|
||||
|
||||
<div class="panel" data-theme={theme}>
|
||||
{#if status === 'loading'}
|
||||
<p class="muted center">Загрузка…</p>
|
||||
{:else if status === 'error'}
|
||||
<p class="center">Не удалось загрузить документы.</p>
|
||||
<p class="muted center">{loadError}</p>
|
||||
<p class="center"><button onclick={loadIndex}>Повторить</button></p>
|
||||
{:else if status === 'empty'}
|
||||
<p class="muted center">Документов пока нет.</p>
|
||||
{:else}
|
||||
{#if docs[current]?.header}
|
||||
<img class="header" src={docs[current].header} alt="" />
|
||||
{/if}
|
||||
<main>
|
||||
<h1 class="title">{doc?.title ?? docs[current].title}</h1>
|
||||
{#if docError}
|
||||
<p class="muted">Не удалось загрузить документ ({docError}).</p>
|
||||
{:else if doc}
|
||||
<DocRenderer nodes={doc.blocks} />
|
||||
{:else}
|
||||
<p class="muted">Загрузка…</p>
|
||||
{/if}
|
||||
</main>
|
||||
{#if docs.length > 1}
|
||||
<nav>
|
||||
<button onclick={prev} disabled={current === 0} aria-label="Предыдущий документ">‹</button>
|
||||
<span class="counter">{current + 1} / {docs.length}</span>
|
||||
<button onclick={next} disabled={current === docs.length - 1} aria-label="Следующий документ">›</button>
|
||||
</nav>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.panel {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
padding: 10px;
|
||||
gap: 8px;
|
||||
/* Тема приходит из Twitch (onContext) */
|
||||
--text: #efeff1;
|
||||
--muted: #adadb8;
|
||||
--border: #3a3a3d;
|
||||
--link: #bf94ff;
|
||||
color: var(--text);
|
||||
}
|
||||
.panel[data-theme='light'] {
|
||||
--text: #0e0e10;
|
||||
--muted: #53535f;
|
||||
--border: #dcdde1;
|
||||
--link: #6441a5;
|
||||
}
|
||||
.header {
|
||||
width: 100%;
|
||||
height: 150px;
|
||||
object-fit: cover;
|
||||
border-radius: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
main {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
min-height: 0;
|
||||
padding-right: 2px;
|
||||
}
|
||||
.title {
|
||||
font-size: 1.1em;
|
||||
margin: 0 0 0.4em;
|
||||
}
|
||||
.muted {
|
||||
color: var(--muted);
|
||||
}
|
||||
.center {
|
||||
text-align: center;
|
||||
}
|
||||
nav {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
border-top: 1px solid var(--border);
|
||||
padding-top: 6px;
|
||||
}
|
||||
.counter {
|
||||
color: var(--muted);
|
||||
font-size: 0.85em;
|
||||
min-width: 48px;
|
||||
text-align: center;
|
||||
}
|
||||
button {
|
||||
background: var(--border);
|
||||
color: var(--text);
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
min-width: 32px;
|
||||
height: 28px;
|
||||
font-size: 1.1em;
|
||||
cursor: pointer;
|
||||
}
|
||||
button:hover:not(:disabled) {
|
||||
filter: brightness(1.2);
|
||||
}
|
||||
button:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: default;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,5 @@
|
||||
import { mount } from 'svelte';
|
||||
import App from './App.svelte';
|
||||
import './viewer.css';
|
||||
|
||||
export default mount(App, { target: document.getElementById('app') });
|
||||
@@ -0,0 +1,16 @@
|
||||
/* Панель живёт в iframe 318×496; фон прозрачный — подложку даёт Twitch. */
|
||||
:root {
|
||||
color-scheme: dark light;
|
||||
}
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
body {
|
||||
background: transparent;
|
||||
font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
|
||||
}
|
||||
#app {
|
||||
height: 100%;
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Panel</title>
|
||||
<!-- Extension Helper обязателен для всех html расширения -->
|
||||
<script src="https://extension-files.twitch.tv/helper/v1/twitch-ext.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/viewer/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,13 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import { svelte } from '@sveltejs/vite-plugin-svelte';
|
||||
import { viteSingleFile } from 'vite-plugin-singlefile';
|
||||
|
||||
// Конфиг-вью (панель управления в Creator Dashboard): отдельный самодостаточный HTML.
|
||||
export default defineConfig({
|
||||
plugins: [svelte(), viteSingleFile()],
|
||||
build: {
|
||||
outDir: 'dist/config',
|
||||
emptyOutDir: true,
|
||||
rollupOptions: { input: 'config.html' },
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import { svelte } from '@sveltejs/vite-plugin-svelte';
|
||||
import { viteSingleFile } from 'vite-plugin-singlefile';
|
||||
|
||||
// Вьювер-панель: один самодостаточный HTML без внешних js/css.
|
||||
export default defineConfig({
|
||||
plugins: [svelte(), viteSingleFile()],
|
||||
build: {
|
||||
outDir: 'dist/viewer',
|
||||
emptyOutDir: true,
|
||||
rollupOptions: { input: 'viewer.html' },
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import { svelte } from '@sveltejs/vite-plugin-svelte';
|
||||
import basicSsl from '@vitejs/plugin-basic-ssl';
|
||||
|
||||
// Dev-сервер: обслуживает и /viewer.html, и /config.html на одном origin
|
||||
// (Twitch требует один Base URI). HTTPS — обязательное требование Twitch.
|
||||
export default defineConfig({
|
||||
plugins: [svelte(), basicSsl()],
|
||||
server: { port: 8080 },
|
||||
});
|
||||
Reference in New Issue
Block a user