diff --git a/Dockerfile b/Dockerfile index f1203f4c..2bc14a03 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,17 +7,17 @@ COPY /client/.npmrc /client/.npmrc WORKDIR /client RUN npm ci COPY /client /client -COPY /docs /docs RUN npm run build FROM node:24-bookworm AS build_v3 -RUN mkdir -p /server/static/ui-new +RUN mkdir -p /server/static COPY /client-v3/package.json /client-v3/package.json COPY /client-v3/package-lock.json /client-v3/package-lock.json WORKDIR /client-v3 RUN npm ci COPY /client-v3 /client-v3 +COPY /docs /docs RUN npm run build FROM python:3.13-bookworm @@ -30,7 +30,7 @@ RUN apt install -y nano COPY /server /server COPY --from=build_v2 /server/static /server/static -COPY --from=build_v3 /server/static/ui-new /server/static/ui-new +COPY --from=build_v3 /server/static /server/static WORKDIR /server RUN mkdir conf EXPOSE 8080 diff --git a/client-v3/e2e/helpers.ts b/client-v3/e2e/helpers.ts index 3a694ab9..a9c0331b 100644 --- a/client-v3/e2e/helpers.ts +++ b/client-v3/e2e/helpers.ts @@ -1,7 +1,7 @@ import type { Page } from '@playwright/test'; export const SERVER_PORT = 8888; -export const UI_BASE = `http://localhost:${SERVER_PORT}/ui-new`; +export const UI_BASE = `http://localhost:${SERVER_PORT}`; export const ADMIN_USERNAME = 'admin'; export const ADMIN_PASSWORD = 'testpassword'; diff --git a/client-v3/package.json b/client-v3/package.json index 3c763bfd..7386cf92 100644 --- a/client-v3/package.json +++ b/client-v3/package.json @@ -6,6 +6,7 @@ "private": true, "type": "module", "scripts": { + "prebuild": "scripts/copy-docs.sh && node --strip-types scripts/generate-doc-manifest.ts", "build": "vite build", "build:analyze": "vite build --mode analyze", "lint": "npm run format && npm run lint:eslint", diff --git a/client-v3/scripts/copy-docs.sh b/client-v3/scripts/copy-docs.sh new file mode 100755 index 00000000..962b1b3b --- /dev/null +++ b/client-v3/scripts/copy-docs.sh @@ -0,0 +1,16 @@ +#!/bin/bash +set -e + +DOCS_SOURCE="../docs" +DOCS_DEST="./public/docs" + +echo "Copying documentation assets..." + +# Remove existing docs and ensure parent dir exists +rm -rf "$DOCS_DEST" +mkdir -p "$(dirname "$DOCS_DEST")" + +# Copy docs directory +cp -r "$DOCS_SOURCE" "$DOCS_DEST" + +echo "Documentation assets copied successfully" diff --git a/client-v3/scripts/generate-doc-manifest.ts b/client-v3/scripts/generate-doc-manifest.ts new file mode 100644 index 00000000..fb2b41a5 --- /dev/null +++ b/client-v3/scripts/generate-doc-manifest.ts @@ -0,0 +1,135 @@ +#!/usr/bin/env node +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +const DOCS_DIR = path.join(__dirname, '../../docs'); +const OUTPUT_DIR = path.join(__dirname, '../public/docs'); +const MANIFEST_PATH = path.join(OUTPUT_DIR, 'manifest.json'); + +interface ManifestEntry { + title: string; + slug: string; + path: string; + category: string; + order: number; +} + +function generateSlug(filepath: string): string { + // pages/getting_started.md → getting-started + // pages/show_config/acts_and_scenes.md → show-config/acts-and-scenes + const withoutExt = filepath.replace(/\.md$/, ''); + const withoutPages = withoutExt.replace(/^pages\//, ''); + return withoutPages.replace(/_/g, '-'); +} + +function extractTitle(content: string, filepath: string): string { + const parts = filepath.split('/'); + + if (parts.length > 2) { + const h3Match = content.match(/^###\s+(.+)$/m); + if (h3Match) return h3Match[1]; + } + + const match = content.match(/^##?\s+(.+)$/m); + if (match) return match[1]; + + const filename = parts[parts.length - 1].replace(/\.md$/, ''); + return filename.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()); +} + +function extractCategory(relativePath: string): string { + const parts = relativePath.split('/'); + return parts.length > 2 ? parts[1] : 'root'; +} + +function extractMarkdownLinks(content: string): string[] { + // Extract all markdown links: [text](./path.md) or [text](path.md) + return [...content.matchAll(/\[([^\]]+)\]\(([^)]+\.md)\)/g)].map((m) => m[2]); +} + +function normalizeLink(currentFile: string, link: string): string { + const currentDir = path.dirname(currentFile); + return path.normalize(path.join(currentDir, link)).replace(/\\/g, '/'); +} + +function buildOrderFromLinks(docsDir: string): string[] { + const visited = new Set(); + const order: string[] = []; + + function traverse(relativePath: string): void { + if (visited.has(relativePath)) return; + + const fullPath = path.join(docsDir, relativePath); + if (!fs.existsSync(fullPath) || !fs.statSync(fullPath).isFile()) return; + + visited.add(relativePath); + order.push(relativePath); + + const content = fs.readFileSync(fullPath, 'utf8'); + for (const link of extractMarkdownLinks(content)) { + traverse(normalizeLink(relativePath, link)); + } + } + + traverse('index.md'); + return order; +} + +function walkDocs(dir: string, basePath = ''): Omit[] { + const manifest: Omit[] = []; + + if (!fs.existsSync(dir)) { + console.warn(`Warning: Directory ${dir} does not exist`); + return manifest; + } + + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const fullPath = path.join(dir, entry.name); + const relativePath = path.join(basePath, entry.name); + + if (entry.isDirectory() && entry.name !== 'images') { + manifest.push(...walkDocs(fullPath, relativePath)); + } else if (entry.isFile() && entry.name.endsWith('.md')) { + const content = fs.readFileSync(fullPath, 'utf8'); + manifest.push({ + title: extractTitle(content, relativePath), + slug: generateSlug(relativePath), + path: relativePath.replace(/\\/g, '/'), + category: extractCategory(relativePath), + }); + } + } + + return manifest; +} + +if (!fs.existsSync(OUTPUT_DIR)) { + fs.mkdirSync(OUTPUT_DIR, { recursive: true }); +} + +console.log('Generating documentation manifest...'); + +const linkOrder = buildOrderFromLinks(DOCS_DIR); +console.log(`📊 Discovered ${linkOrder.length} documents via link traversal`); + +const orderMap = new Map(linkOrder.map((filePath, i) => [filePath, i])); + +const manifest: ManifestEntry[] = walkDocs(DOCS_DIR).map((entry) => ({ + ...entry, + order: orderMap.get(entry.path) ?? 999, +})); + +const filteredManifest = manifest + .filter((entry) => entry.path !== 'index.md') + .sort((a, b) => { + if (a.order !== b.order) return a.order - b.order; + if (a.category !== b.category) return a.category.localeCompare(b.category); + return a.title.localeCompare(b.title); + }); + +fs.writeFileSync(MANIFEST_PATH, JSON.stringify(filteredManifest, null, 2)); +console.log(`✅ Generated manifest with ${filteredManifest.length} documents (excluded index.md)`); +console.log(`📄 Manifest saved to: ${MANIFEST_PATH}`); diff --git a/client-v3/src/App.vue b/client-v3/src/App.vue index 78d0877d..e2aef2e7 100644 --- a/client-v3/src/App.vue +++ b/client-v3/src/App.vue @@ -94,7 +94,9 @@ - Switch to Classic UI + + Switch to Classic UI + Help About @@ -273,7 +275,7 @@ async function loadInitialUserData(): Promise { await Promise.all([userStore.getCurrentRbac(), userStore.getUserSettings()]); const switching = new URLSearchParams(window.location.search).has('_switch'); if (!switching && (userStore.userSettings as UserSettings).preferred_ui === 'old') { - window.location.href = '/?_switch=1'; + window.location.href = '/ui-old/?_switch=1'; return false; } if (switching) { diff --git a/client-v3/src/router/index.ts b/client-v3/src/router/index.ts index 7f5930ed..a26e4727 100644 --- a/client-v3/src/router/index.ts +++ b/client-v3/src/router/index.ts @@ -11,7 +11,7 @@ import NotFoundView from '@/views/NotFoundView.vue'; const isFileProtocol = typeof window !== 'undefined' && window.location.protocol === 'file:'; const router = createRouter({ - history: isFileProtocol ? createWebHashHistory() : createWebHistory('/ui-new/'), + history: isFileProtocol ? createWebHashHistory() : createWebHistory('/'), routes: [ { path: '/electron/server-selector', diff --git a/client-v3/vite.config.ts b/client-v3/vite.config.ts index e1baa54b..33dc6913 100644 --- a/client-v3/vite.config.ts +++ b/client-v3/vite.config.ts @@ -1,11 +1,29 @@ import path from 'node:path'; -import { defineConfig } from 'vite'; +import fs from 'node:fs'; +import { defineConfig, type Plugin } from 'vite'; import vue from '@vitejs/plugin-vue'; import Components from 'unplugin-vue-components/vite'; import { BootstrapVueNextResolver } from 'bootstrap-vue-next'; import Icons from 'unplugin-icons/vite'; import IconsResolve from 'unplugin-icons/resolver'; +function cleanV3StaticPlugin(): Plugin { + return { + name: 'clean-v3-static', + buildStart() { + if (process.env.BUILD_TARGET === 'electron') return; + const outDir = path.resolve(__dirname, '../server/static'); + if (fs.existsSync(outDir)) { + for (const entry of fs.readdirSync(outDir)) { + if (entry !== 'ui-old') { + fs.rmSync(path.join(outDir, entry), { recursive: true, force: true }); + } + } + } + }, + }; +} + export default defineConfig({ plugins: [ vue(), @@ -17,13 +35,14 @@ export default defineConfig({ compiler: 'vue3', autoInstall: false, }), + cleanV3StaticPlugin(), ], - base: process.env.BUILD_TARGET === 'electron' ? './' : '/ui-new/', + base: process.env.BUILD_TARGET === 'electron' ? './' : '/', build: { outDir: - process.env.BUILD_TARGET === 'electron' ? './dist-electron' : '../server/static/ui-new/', + process.env.BUILD_TARGET === 'electron' ? './dist-electron' : '../server/static/', assetsDir: './assets', - emptyOutDir: true, + emptyOutDir: false, rollupOptions: { output: { manualChunks(id) { diff --git a/client/package.json b/client/package.json index 3cf9a111..458e1b75 100644 --- a/client/package.json +++ b/client/package.json @@ -6,7 +6,6 @@ "private": true, "type": "module", "scripts": { - "prebuild": "scripts/copy-docs.sh && node --strip-types scripts/generate-doc-manifest.ts", "build": "vite build", "build:analyze": "vite build --mode analyze", "lint": "npm run format && npm run lint:eslint", diff --git a/client/src/App.vue b/client/src/App.vue index b8b1d303..1f2afc93 100644 --- a/client/src/App.vue +++ b/client/src/App.vue @@ -91,7 +91,7 @@ - Switch to New UI + Switch to New UI Help About @@ -329,7 +329,7 @@ export default defineComponent({ const userPref = (this as any).USER_SETTINGS?.preferred_ui as string | null | undefined; const systemDefault = (this as any).SETTINGS?.default_ui as string | undefined; if (userPref === 'new' || (userPref == null && systemDefault === 'new')) { - window.location.href = '/ui-new/'; + window.location.href = '/?_switch=1'; return; } } diff --git a/client/vite.config.ts b/client/vite.config.ts index 87157fa4..0cf9bb40 100644 --- a/client/vite.config.ts +++ b/client/vite.config.ts @@ -1,40 +1,21 @@ import path from 'node:path'; -import fs from 'node:fs'; -import { defineConfig, type Plugin } from 'vite'; +import { defineConfig } from 'vite'; import vue from '@vitejs/plugin-vue2'; -function cleanV2StaticPlugin(): Plugin { - return { - name: 'clean-v2-static', - buildStart() { - if (process.env.BUILD_TARGET === 'electron') return; - const outDir = path.resolve(__dirname, '../server/static'); - if (fs.existsSync(outDir)) { - for (const entry of fs.readdirSync(outDir)) { - if (entry !== 'ui-new') { - fs.rmSync(path.join(outDir, entry), { recursive: true, force: true }); - } - } - } - }, - }; -} - export default defineConfig({ plugins: [ vue(), - cleanV2StaticPlugin(), ], define: { 'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'development'), }, // Use relative paths ONLY for Electron builds (file:// protocol) // Use absolute paths for web server (prevents issues with nested routes) - base: process.env.BUILD_TARGET === 'electron' ? './' : '/', + base: process.env.BUILD_TARGET === 'electron' ? './' : '/ui-old/', build: { - outDir: process.env.BUILD_TARGET === 'electron' ? './dist-electron' : '../server/static/', + outDir: process.env.BUILD_TARGET === 'electron' ? './dist-electron' : '../server/static/ui-old/', assetsDir: './assets', - emptyOutDir: false, + emptyOutDir: true, minify: 'esbuild', cssMinify: 'esbuild', rollupOptions: { diff --git a/server/controllers/controllers.py b/server/controllers/controllers.py index f9d8f988..d5b67150 100644 --- a/server/controllers/controllers.py +++ b/server/controllers/controllers.py @@ -26,11 +26,26 @@ def import_all_controllers(): class RootController(BaseController): + def get(self, _path): + if is_frozen(): + full_path = get_resource_path(os.path.join("static", "ui-old", INDEX_HTML)) + else: + file_path = os.path.join( + os.path.abspath(os.path.dirname(__file__)), "..", "static", "ui-old" + ) + full_path = os.path.join(file_path, INDEX_HTML) + if not os.path.isfile(full_path): + raise HTTPError(404) + with open(full_path, "r", encoding="utf-8") as file: + self.write(file.read()) + + +class RootControllerV3(BaseController): async def get(self, path): if not path and not self.get_argument("_switch", None): default_ui = await self.application.digi_settings.get("default_ui") - if default_ui == "new": - self.redirect("/ui-new/") + if default_ui == "old": + self.redirect("/ui-old/") return if is_frozen(): # In PyInstaller mode, use resource path @@ -56,21 +71,6 @@ async def get(self, path): raise HTTPError(500) from e -class RootControllerV3(BaseController): - def get(self, _path): - if is_frozen(): - full_path = get_resource_path(os.path.join("static", "ui-new", INDEX_HTML)) - else: - file_path = os.path.join( - os.path.abspath(os.path.dirname(__file__)), "..", "static", "ui-new" - ) - full_path = os.path.join(file_path, INDEX_HTML) - if not os.path.isfile(full_path): - raise HTTPError(404) - with open(full_path, "r", encoding="utf-8") as file: - self.write(file.read()) - - class StaticController(BaseController): def get(self): self.set_header("Content-Type", "") diff --git a/server/digi_server/app_server.py b/server/digi_server/app_server.py index f118fe2a..5b9cf6af 100644 --- a/server/digi_server/app_server.py +++ b/server/digi_server/app_server.py @@ -242,8 +242,8 @@ class AlembicVersion(self._db.Model): if is_frozen(): static_files_path = get_resource_path(os.path.join("static", "assets")) docs_files_path = get_resource_path(os.path.join("static", "docs")) - ui_new_static_files_path = get_resource_path( - os.path.join("static", "ui-new", "assets") + ui_old_static_files_path = get_resource_path( + os.path.join("static", "ui-old", "assets") ) get_logger().info(f"Using packaged static files path: {static_files_path}") get_logger().info(f"Using packaged docs files path: {docs_files_path}") @@ -254,11 +254,11 @@ class AlembicVersion(self._db.Model): docs_files_path = os.path.join( os.path.abspath(os.path.dirname(__file__)), "..", "static", "docs" ) - ui_new_static_files_path = os.path.join( + ui_old_static_files_path = os.path.join( os.path.abspath(os.path.dirname(__file__)), "..", "static", - "ui-new", + "ui-old", "assets", ) get_logger().info(f"Using relative static files path: {static_files_path}") @@ -271,15 +271,15 @@ class AlembicVersion(self._db.Model): ) handlers.append( ( - r"/ui-new/assets/(.*)", + r"/ui-old/assets/(.*)", StaticFileHandler, - {"path": ui_new_static_files_path}, + {"path": ui_old_static_files_path}, ) ) handlers.append((r"/docs/(.*)", StaticFileHandler, {"path": docs_files_path})) handlers.append((r"/api/.*", controllers.ApiFallback)) - handlers.append((r"/ui-new(/?.*)", controllers.RootControllerV3)) - handlers.append((r"/(.*)", controllers.RootController)) + handlers.append((r"/ui-old(/?.*)", controllers.RootController)) + handlers.append((r"/(.*)", controllers.RootControllerV3)) super().__init__( handlers=handlers, debug=debug, diff --git a/server/digi_server/settings.py b/server/digi_server/settings.py index 0fb13092..6d32643f 100644 --- a/server/digi_server/settings.py +++ b/server/digi_server/settings.py @@ -209,7 +209,7 @@ def init_settings(self): self.define( "default_ui", str, - "old", + "new", True, display_name="Default UI Version", help_text="Which UI version users are directed to by default. User preferences override this.",