diff --git a/.gitignore b/.gitignore index 3150f45..ade87d4 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,7 @@ node_modules/ # Documentation site build artifacts docs-site/.astro/ docs-site/dist/ +docs-site/public/demo/runtime/ docs-site/src/content/docs/ # WebApp build artifacts (regenerated by `npm run build:webapp` / Docker build) @@ -34,6 +35,11 @@ bot/app/web/templates/subscription_webapp_admin.min.*.js.br bot/app/web/templates/subscription_webapp_admin.min.*.js.gz bot/app/web/templates/subscription_webapp_admin.*.css.br bot/app/web/templates/subscription_webapp_admin.*.css.gz +bot/app/web/templates/subscription_webapp_docs_demo.css +bot/app/web/templates/subscription_webapp_docs_demo.js +bot/app/web/templates/subscription_webapp_docs_demo.*.css +bot/app/web/templates/subscription_webapp_docs_demo.*.css.br +bot/app/web/templates/subscription_webapp_docs_demo.*.css.gz backend/bot/app/web/templates/subscription_webapp.css backend/bot/app/web/templates/subscription_webapp.js backend/bot/app/web/templates/subscription_webapp.min.*.js @@ -50,6 +56,11 @@ backend/bot/app/web/templates/subscription_webapp_admin.min.*.js.br backend/bot/app/web/templates/subscription_webapp_admin.min.*.js.gz backend/bot/app/web/templates/subscription_webapp_admin.*.css.br backend/bot/app/web/templates/subscription_webapp_admin.*.css.gz +backend/bot/app/web/templates/subscription_webapp_docs_demo.css +backend/bot/app/web/templates/subscription_webapp_docs_demo.js +backend/bot/app/web/templates/subscription_webapp_docs_demo.*.css +backend/bot/app/web/templates/subscription_webapp_docs_demo.*.css.br +backend/bot/app/web/templates/subscription_webapp_docs_demo.*.css.gz tmp .claude diff --git a/docs-site/package.json b/docs-site/package.json index 72fc667..f1be9ed 100644 --- a/docs-site/package.json +++ b/docs-site/package.json @@ -3,8 +3,9 @@ "type": "module", "scripts": { "sync:docs": "node ./scripts/sync-docs.mjs", - "dev": "npm run sync:docs && astro dev", - "build": "npm run sync:docs && astro build", + "build:demo": "node ./scripts/build-demo-runtime.mjs", + "dev": "npm run sync:docs && npm run build:demo && astro dev", + "build": "npm run sync:docs && npm run build:demo && astro build", "preview": "astro preview" }, "dependencies": { diff --git a/docs-site/scripts/build-demo-runtime.mjs b/docs-site/scripts/build-demo-runtime.mjs new file mode 100644 index 0000000..e8fac26 --- /dev/null +++ b/docs-site/scripts/build-demo-runtime.mjs @@ -0,0 +1,149 @@ +import { spawn } from 'node:child_process'; +import { access, copyFile, mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const siteRoot = path.resolve(fileURLToPath(new URL('..', import.meta.url))); +const repoRoot = path.resolve(siteRoot, '..'); +const frontendRoot = path.join(repoRoot, 'frontend'); +const runtimeDir = path.join(siteRoot, 'public', 'demo', 'runtime'); +const templatesDir = path.join(repoRoot, 'backend', 'bot', 'app', 'web', 'templates'); +const themesDir = path.join(repoRoot, 'backend', 'bot', 'app', 'web', 'themes'); +const localesDir = path.join(repoRoot, 'locales'); +const runtimeBase = '/demo/runtime'; +const isWindows = process.platform === 'win32'; +const npmExecPath = process.env.npm_execpath || ''; + +function run(command, args, options = {}) { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { + cwd: repoRoot, + stdio: 'inherit', + shell: false, + ...options, + }); + child.on('error', reject); + child.on('exit', (code) => { + if (code === 0) { + resolve(); + return; + } + reject(new Error(`${command} ${args.join(' ')} exited with code ${code}`)); + }); + }); +} + +function runNpm(args) { + if (npmExecPath) { + return run(process.execPath, [npmExecPath, ...args]); + } + return run(isWindows ? 'npm.cmd' : 'npm', args, { shell: isWindows }); +} + +async function pathExists(targetPath) { + try { + await access(targetPath); + return true; + } catch (_error) { + return false; + } +} + +async function ensureFrontendDependencies() { + const viteBin = isWindows + ? path.join(frontendRoot, 'node_modules', '.bin', 'vite.cmd') + : path.join(frontendRoot, 'node_modules', '.bin', 'vite'); + if (await pathExists(viteBin)) return; + await runNpm(['--prefix', frontendRoot, 'ci']); +} + +async function copyDirectory(sourceDir, targetDir, transform = null) { + await mkdir(targetDir, { recursive: true }); + const entries = await readdir(sourceDir, { withFileTypes: true }); + for (const entry of entries) { + const sourcePath = path.join(sourceDir, entry.name); + const targetPath = path.join(targetDir, entry.name); + if (entry.isDirectory()) { + await copyDirectory(sourcePath, targetPath, transform); + continue; + } + if (!entry.isFile()) continue; + if (transform) { + const handled = await transform(sourcePath, targetPath); + if (handled) continue; + } + await mkdir(path.dirname(targetPath), { recursive: true }); + await copyFile(sourcePath, targetPath); + } +} + +async function copyThemeFile(sourcePath, targetPath) { + if (path.extname(sourcePath).toLowerCase() !== '.css') return false; + const css = await readFile(sourcePath, 'utf8'); + const rewritten = css.replace(/\/webapp-theme-assets\//g, `${runtimeBase}/themes/`); + await mkdir(path.dirname(targetPath), { recursive: true }); + await writeFile(targetPath, rewritten, 'utf8'); + return true; +} + +async function copyRuntimeAsset(name) { + await copyFile(path.join(templatesDir, name), path.join(runtimeDir, name)); +} + +function jsonScriptPayload(value) { + return JSON.stringify(value).replace(/ + + + + + + + Remnawave Minishop Demo + + + +
+
+
+ + + + +`; +} + +await ensureFrontendDependencies(); +await runNpm(['--prefix', frontendRoot, 'run', 'build:docs-demo']); + +await rm(runtimeDir, { recursive: true, force: true }); +await mkdir(runtimeDir, { recursive: true }); + +const html = await appHtml(); + +await Promise.all([ + copyRuntimeAsset('subscription_webapp_docs_demo.js'), + copyRuntimeAsset('subscription_webapp_docs_demo.css'), + copyRuntimeAsset('subscription_webapp_admin.js'), + copyRuntimeAsset('subscription_webapp_admin.css'), + copyDirectory(path.join(templatesDir, 'default-brand'), path.join(runtimeDir, 'default-brand')), + copyDirectory(themesDir, path.join(runtimeDir, 'themes'), copyThemeFile), + writeFile(path.join(runtimeDir, 'app.html'), html, 'utf8'), +]); + +console.log(`Built static docs demo runtime at ${path.relative(repoRoot, runtimeDir)}`); diff --git a/frontend/package.json b/frontend/package.json index d411fc8..3a8ef78 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -3,7 +3,9 @@ "scripts": { "build:webapp:svelte:main": "vite build --config ./vite.config.mjs", "build:webapp:svelte:admin": "vite build --config ./vite.config.mjs --mode admin", + "build:webapp:svelte:docs-demo": "vite build --config ./vite.config.mjs --mode docs-demo", "build:webapp:svelte": "npm run build:webapp:svelte:main && npm run build:webapp:svelte:admin", + "build:docs-demo": "npm run build:webapp:svelte:docs-demo && npm run build:webapp:svelte:admin", "build:webapp:css": "npm run build:webapp:svelte", "build:webapp:js": "node ./scripts/build_subscription_webapp_js.mjs", "build:webapp": "npm run build:webapp:svelte && npm run build:webapp:js", diff --git a/frontend/src/docsDemoEntry.js b/frontend/src/docsDemoEntry.js new file mode 100644 index 0000000..8bbb683 --- /dev/null +++ b/frontend/src/docsDemoEntry.js @@ -0,0 +1,58 @@ +import { mount } from "svelte"; + +import App from "./App.svelte"; +import PreviewBoard from "./PreviewBoard.svelte"; +import { mockApi } from "./lib/webapp/mockApi.js"; +import { DEV_MOCK, applyPreviewMock } from "./lib/webapp/previewMock.js"; +import "./styles.css"; + +const RUNTIME_BASE = "/demo/runtime"; +const DEFAULT_FAVICON_DIGEST = "19b2a242e5b7bc2d"; + +function runtimePath(path) { + return `${RUNTIME_BASE}/${String(path || "").replace(/^\/+/, "")}`; +} + +function copyThemeAssets(catalog) { + const themes = catalog?.themes || []; + for (const theme of themes) { + const cssFile = String(theme?.css_file || "").trim(); + if (!theme?.key || !cssFile || cssFile.startsWith("/") || /^(?:https?:)?\/\//i.test(cssFile)) { + continue; + } + theme.css_file = runtimePath(`themes/${theme.key}/${cssFile}`); + } +} + +function prepareMockConfig() { + const logoUrl = runtimePath("default-brand/default-logo.webp"); + const faviconUrl = runtimePath(`default-brand/favicons/${DEFAULT_FAVICON_DIGEST}/icon-180.png`); + DEV_MOCK.config.logoUrl = logoUrl; + DEV_MOCK.config.faviconUrl = faviconUrl; + DEV_MOCK.config.adminJsAsset = runtimePath("subscription_webapp_admin.js"); + DEV_MOCK.config.adminCssAsset = runtimePath("subscription_webapp_admin.css"); + DEV_MOCK.config.apiBase = "/api"; + copyThemeAssets(DEV_MOCK.config.themesCatalog); + copyThemeAssets(DEV_MOCK.data.themes_catalog); +} + +const params = new URLSearchParams(window.location.search); +applyPreviewMock(params.get("mock")); +prepareMockConfig(); + +const target = document.getElementById("app"); +if (target) { + target.replaceChildren(); + mount(App, { + target, + props: { + mockRuntime: { + source: DEV_MOCK, + applyPreviewMock: () => {}, + mockApi, + PreviewBoard, + docsDemo: true, + }, + }, + }); +} diff --git a/frontend/vite.config.mjs b/frontend/vite.config.mjs index c7324b3..fc73114 100644 --- a/frontend/vite.config.mjs +++ b/frontend/vite.config.mjs @@ -10,7 +10,17 @@ const templateDir = path.resolve(__dirname, "../backend/bot/app/web/templates"); export default defineConfig(({ mode }) => { const isAdminBuild = mode === "admin"; - const outputBase = isAdminBuild ? "subscription_webapp_admin" : "subscription_webapp"; + const isDocsDemoBuild = mode === "docs-demo"; + const outputBase = isAdminBuild + ? "subscription_webapp_admin" + : isDocsDemoBuild + ? "subscription_webapp_docs_demo" + : "subscription_webapp"; + const entry = isAdminBuild + ? "src/adminEntry.js" + : isDocsDemoBuild + ? "src/docsDemoEntry.js" + : "src/main.js"; return { resolve: { @@ -32,7 +42,7 @@ export default defineConfig(({ mode }) => { sourcemap: false, cssCodeSplit: false, lib: { - entry: path.resolve(__dirname, isAdminBuild ? "src/adminEntry.js" : "src/main.js"), + entry: path.resolve(__dirname, entry), name: isAdminBuild ? "SubscriptionWebAppAdmin" : "SubscriptionWebApp", formats: ["iife"], fileName: () => `${outputBase}.js`,