fix(docs): [SITE-LOAD-01] serialize example loading and preserve navigation

This commit is contained in:
Harvey Zhao committed 2026-09-14 07:52:32 +08:00
1 parent 76499e7a3e
commit 647f3e7f12
29 files changed
+1977 -256

No files matched your search

+12 -6
View File
@@ -22,6 +22,12 @@ and 13 English. Danmuku has no English counterpart yet. Other plugins currently
use their package README, architecture notes and linked editor examples;
navigation links alone are not complete API documentation.
Site-owned browser behavior now lives in `browser/`; its README maps the shared
loader, mobile entry and Run Code/language navigation. `docs/public/main.js` is a
generated input to VitePress. Run root `yarn build:site-assets` after changes, or
`yarn check:site-assets` for drift. The existing desktop common.js UI remains a
separate source pending SITE-03 migration; no player package APIs move here.
## Related tooling
Run commands from the repository root with its pinned Node version and Yarn
@@ -69,12 +75,12 @@ have separate dependencies. See the repository
[site inventory](../../refactor/site-inventory.md) for the full entrypoint map,
source fingerprints, asset provenance and assigned follow-up work.
`main.js` installs one document click listener under `window['run-code-init']`;
its lifetime is the page. Run Code opens the separate editor. The development
hostname check is currently exactly `localhost`; `127.0.0.1` follows the remote
link path. The language redirect writes `lang-init` and can navigate to the
English root on a first non-Chinese visit. SITE-03/SITE-05 must test these paths
before modifying routing or VitePress client lifecycle behavior.
Generated `main.js` installs one document click listener under
`window['run-code-init']`; its lifetime is the page. Run Code opens the separate
editor and recognizes localhost, 127.0.0.1 and IPv6 loopback. First-language
navigation preserves existing English counterparts and explicit later choices;
unavailable storage keeps the requested page. See `browser/README.md` for the
current rules and verified scope. Full VitePress lifecycle remains SITE-03/05.
## Delivery and remaining acceptance
@@ -0,0 +1,77 @@
# Documentation browser modules
These are site-owned TypeScript sources, not player package APIs. Use the pinned
Node 24.21.0 / Yarn 1.22.22 toolchain from the repository root.
| Module | Ownership |
| ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
| `loader.ts` | Raw URL parameters, serialized script/style loading, successful URL cache, AMD restoration and HTTP example-source checks |
| `editor-loader.ts` | One desktop loader exposed as the private site bridge `window.ArtplayerDocsLoader` |
| `mobile.ts` | Mobile DEBUG setup, dependency completion, example/code/default priority and classic-script evaluation |
| `navigation.ts` | Run Code DOM delegation, URL encoding, loopback targets and first-language navigation |
| `../../../scripts/build-site-assets.mjs` | Deterministic ES2020 bundles, available English routes and read-only drift check |
Generated outputs are `docs/assets/js/loader.js`, `docs/assets/js/mobile.js` and
this workspace's `docs/public/main.js`. Do not hand-edit them. `docs/index.html`
loads the desktop bridge before the old core/Monaco/common bootstrap. Mobile
bundles the same loader module locally; it does not depend on the desktop bridge.
`docs/assets/js/common.js` remains the desktop UI source and owns Monaco, controls,
file import, editor state and instance cleanup. Its remaining TS/UI migration is
tracked under SITE-03; this task does not claim that whole file was migrated.
The loader serializes all batches in one page. Each dependency completes before
the next is inserted; successful absolute URLs are cached even if a later resource
fails. Failed URLs are removed and may be retried. Query strings remain part of
cache identity; unknown file extensions are ignored as before. CSS follows the
same input order but never changes AMD state. There is no new network timeout.
A script temporarily masks the own `window.define` descriptor and restores the
exact descriptor, or its absence, on success, error, append failure or cancellation.
An immutable incompatible descriptor is rejected before insertion. Third-party
code that irreversibly changes that descriptor can prevent restoration; the error
is reported rather than treating the dependency as loaded. The loader does not
roll back arbitrary global side effects of code that already executed.
`dispose()` rejects pending loads, removes their listeners/elements and rejects
future calls; loaded script/style nodes remain page-owned. Removing a classic
script does not guarantee that the browser cancels already-started evaluation.
Non-persisted pagehide disposes the loader. BFCache
pagehide leaves the page's loader usable on restoration. Physical-device/BFCache
acceptance remains a separate environment check, not inferred from the branch.
Desktop request generations prevent a late example fetch from overwriting a newer
Run. Existing `artplayer:example:cleanup`, instance destruction and controls remain.
Query parsing preserves raw values (including literal `+`) for one explicit decode;
the last duplicate wins and fragments are not part of query values. Example still
takes priority over code; mobile/index remain their original defaults. Example HTTP
errors are reported before evaluating response bodies. Mobile evaluates in a fresh
classic function scope, preserving ordinary `var` and non-strict example behavior.
This editor intentionally runs user-selected code and is not a security sandbox.
Run Code encodes both libraries and code once, supports clicks on nested children,
and safely ignores missing code siblings. localhost, 127.0.0.1 and IPv6 loopback
target their local HTTP port 8082; production retains the canonical editor URL.
First non-Chinese visits keep matching English deep links, query and fragment.
Pages without an English counterpart fall back to the English index. Already-English
visits record the existing lang-init key without a redundant reload; later explicit
Chinese selections remain. Blocked storage keeps the requested page, avoiding loops.
The available counterparts are derived from current English Markdown during build.
```sh
yarn build:site-assets
yarn check:site-assets
yarn typecheck:site-assets
yarn typecheck:docs-tools
node --test test/site-loading.test.js
yarn test:browser test/browser/site-loading.spec.js --workers=1
```
Root build:docs regenerates site assets first. ci:check checks their types and drift;
root lint includes the TS modules and legacy common.js. Direct workspace VitePress
commands still require regenerating these assets after source edits. The full
VitePress build/deploy, i18n and remote translation/LLM tooling remain SITE-03/05.
Tests use real local pages, Monaco and controlled media, with unrelated remote
analytics/ads fulfilled inertly in test routes. They do not validate those services.
Keep browser verification serial with expensive compiler baselines when local
resource contention is observed; record infrastructure failures, never hide them
by increasing timeouts or silently retrying.
@@ -0,0 +1,8 @@
import { createLibraryLoader, exampleSource, parameters } from './loader.ts'
const loader = createLibraryLoader()
Object.assign(window, { ArtplayerDocsLoader: { ...loader, exampleSource, parameters } })
window.addEventListener('pagehide', (event) => {
if (!event.persisted)
loader.dispose()
})
@@ -0,0 +1,129 @@
export function parameters(url: string): Record<string, string> {
const result: Record<string, string> = {}
for (const part of new URL(url).search.slice(1).split('&')) {
const index = part.indexOf('=')
if (index >= 0)
result[part.slice(0, index)] = part.slice(index + 1)
}
return result
}
function suspendDefine(host: Window): () => void {
const descriptor = Object.getOwnPropertyDescriptor(host, 'define')
if (descriptor?.configurable === false) {
if (!('value' in descriptor) || !descriptor.writable)
throw new Error('Cannot temporarily suspend the AMD loader')
Object.defineProperty(host, 'define', { value: undefined })
}
else {
Object.defineProperty(host, 'define', { value: undefined, configurable: true, writable: true, enumerable: descriptor?.enumerable ?? true })
}
return () => {
if (descriptor)
Object.defineProperty(host, 'define', descriptor)
else Reflect.deleteProperty(host, 'define')
}
}
export function createLibraryLoader(host: Window = window) {
const loaded = new Set<string>()
let queue: Promise<unknown> = Promise.resolve()
let cancel: (() => void) | undefined
let disposed = false
const ensureActive = () => {
if (disposed)
throw new Error('Documentation loader was disposed')
}
function load(url: string, script: boolean): Promise<void> {
return new Promise((resolve, reject) => {
const element = host.document.createElement(script ? 'script' : 'link')
let restore: (() => void) | undefined
let settled = false
function finish(error?: Error) {
if (settled)
return
settled = true
element.removeEventListener('load', success)
element.removeEventListener('error', failure)
cancel = undefined
try {
restore?.()
}
catch (cause) {
error = new Error(`Failed to restore AMD loader: ${String(cause)}`)
}
if (error) {
element.remove()
reject(error)
}
else {
resolve()
}
}
function success() {
finish()
}
function failure() {
finish(new Error(`Loading ${script ? 'script' : 'style'} failed: ${url}`))
}
element.addEventListener('load', success)
element.addEventListener('error', failure)
cancel = () => finish(new Error(`Cancelled documentation dependency: ${url}`))
try {
ensureActive()
if (script) {
restore = suspendDefine(host)
const node = element as HTMLScriptElement
node.type = 'text/javascript'
node.src = url
node.async = false
}
else {
const node = element as HTMLLinkElement
node.rel = 'stylesheet'
node.href = url
}
host.document.head.appendChild(element)
}
catch (cause) {
finish(cause instanceof Error ? cause : new Error(String(cause)))
}
})
}
return {
loadLibraries(encoded: string = ''): Promise<string[]> {
const work = queue.then(async () => {
ensureActive()
const urls = decodeURIComponent(encoded).split(/\r?\n/).map(url => url.trim()).filter(Boolean)
const result: string[] = []
for (const url of urls) {
ensureActive()
const absolute = new URL(url, host.location.href)
const extension = absolute.pathname.toLowerCase().split('.').pop()
if (extension !== 'js' && extension !== 'css')
continue
if (!loaded.has(absolute.href)) {
await load(absolute.href, extension === 'js')
loaded.add(absolute.href)
}
result.push(url)
}
return result
})
queue = work.catch(() => {})
return work
},
dispose(): void {
disposed = true
cancel?.()
loaded.clear()
},
}
}
export async function exampleSource(name: string, request: typeof fetch = fetch): Promise<string> {
const response = await request(`./assets/example/${name}.js`)
if (!response.ok)
throw new Error(`Loading example failed: ${name} (${response.status})`)
return response.text()
}
@@ -0,0 +1,23 @@
import type Artplayer from '../../artplayer/public/artplayer.ts'
import { createLibraryLoader, exampleSource, parameters } from './loader.ts'
declare global {
interface Window { Artplayer: typeof Artplayer }
}
window.Artplayer.DEBUG = true
const loader = createLibraryLoader()
window.addEventListener('pagehide', (event) => {
if (!event.persisted)
loader.dispose()
})
async function start(): Promise<void> {
const { code, libs, example } = parameters(location.href)
await loader.loadLibraries(libs)
const source = example ? await exampleSource(example) : code ? decodeURIComponent(code).trim() : await exampleSource('mobile')
// eslint-disable-next-line no-new-func -- Run user-selected example code in the existing classic-script function scope.
new Function(source)()
}
start().catch(error => console.error('Failed to initialize mobile example:', error))
@@ -0,0 +1,53 @@
export function isDevelopment(hostname: string): boolean {
return ['localhost', '127.0.0.1', '[::1]', '::1'].includes(hostname.toLowerCase())
}
export function runCodeUrl(location: Pick<Location, 'hostname'>, libraries: string, code: string): string {
const hostname = location.hostname === '::1' ? '[::1]' : location.hostname
const origin = isDevelopment(hostname) ? `http://${hostname}:8082` : 'https://artplayer.org'
return `${origin}/?libs=${encodeURIComponent(libraries)}&code=${encodeURIComponent(code)}`
}
export function languageDestination(url: string, language: string, initialized: boolean, englishPaths: readonly string[]): { remember: boolean, target: string | null } {
const current = new URL(url)
if (initialized || isDevelopment(current.hostname) || language.toLowerCase().startsWith('zh') || !language || !current.pathname.startsWith('/document/'))
return { remember: false, target: null }
if (current.pathname.startsWith('/document/en/'))
return { remember: true, target: null }
const relative = current.pathname.slice('/document/'.length)
const counterpart = englishPaths.includes(relative)
const target = new URL(counterpart ? `/document/en/${relative}` : '/document/en/', current.origin)
if (counterpart) {
target.search = current.search
target.hash = current.hash
}
return { remember: true, target: target.href }
}
export function installNavigation(host: Window, englishPaths: readonly string[]): void {
const marker = 'run-code-init'
if (Reflect.get(host, marker))
return
Reflect.set(host, marker, true)
host.document.addEventListener('click', (event) => {
const element = event.target instanceof Element ? event.target : null
const button = element?.closest('.run-code, [classname="run-code"]')
const code = button?.nextElementSibling?.querySelector<HTMLElement>('pre code')
if (!(button instanceof HTMLElement) || !code)
return
event.preventDefault()
// eslint-disable-next-line unicorn/prefer-dom-node-text-content -- Preserve the existing rendered Run Code text, including visible line breaks.
const text = code.innerText ?? code.textContent ?? ''
host.open(runCodeUrl(host.location, button.dataset.libs || '', text))
})
try {
const result = languageDestination(host.location.href, host.navigator.language, !!host.localStorage.getItem('lang-init'), englishPaths)
if (result.remember)
host.localStorage.setItem('lang-init', 'true')
if (result.target)
host.location.href = result.target
}
catch {
// Storage may be unavailable. Keep the requested page instead of risking a redirect loop.
}
}
@@ -0,0 +1,5 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": { "allowImportingTsExtensions": true },
"include": ["*.ts"]
}
@@ -1,27 +1,54 @@
(function () {
if (window['run-code-init']) return;
window['run-code-init'] = true;
document.addEventListener('click', (event) => {
if (event.target.className === 'run-code' || event.target.getAttribute('classname') === 'run-code') {
event.preventDefault();
const codeElement = event.target.nextElementSibling.querySelector('pre code');
if (codeElement) {
const libs = event.target.dataset.libs || '';
const code = encodeURIComponent(codeElement.innerText);
const isDev = location.hostname === 'localhost';
const env = isDev ? 'http://localhost:8082' : 'https://artplayer.org';
const url = env + '/?libs=' + libs + '&code=' + code;
window.open(url);
}
}
// Generated by yarn build:site-assets. Do not edit.
(() => {
// packages/artplayer-vitepress/browser/navigation.ts
function isDevelopment(hostname) {
return ["localhost", "127.0.0.1", "[::1]", "::1"].includes(hostname.toLowerCase());
}
function runCodeUrl(location, libraries, code) {
const hostname = location.hostname === "::1" ? "[::1]" : location.hostname;
const origin = isDevelopment(hostname) ? `http://${hostname}:8082` : "https://artplayer.org";
return `${origin}/?libs=${encodeURIComponent(libraries)}&code=${encodeURIComponent(code)}`;
}
function languageDestination(url, language, initialized, englishPaths) {
const current = new URL(url);
if (initialized || isDevelopment(current.hostname) || language.toLowerCase().startsWith("zh") || !language || !current.pathname.startsWith("/document/"))
return { remember: false, target: null };
if (current.pathname.startsWith("/document/en/"))
return { remember: true, target: null };
const relative = current.pathname.slice("/document/".length);
const counterpart = englishPaths.includes(relative);
const target = new URL(counterpart ? `/document/en/${relative}` : "/document/en/", current.origin);
if (counterpart) {
target.search = current.search;
target.hash = current.hash;
}
return { remember: true, target: target.href };
}
function installNavigation(host, englishPaths) {
const marker = "run-code-init";
if (Reflect.get(host, marker))
return;
Reflect.set(host, marker, true);
host.document.addEventListener("click", (event) => {
const element = event.target instanceof Element ? event.target : null;
const button = element?.closest('.run-code, [classname="run-code"]');
const code = button?.nextElementSibling?.querySelector("pre code");
if (!(button instanceof HTMLElement) || !code)
return;
event.preventDefault();
const text = code.innerText ?? code.textContent ?? "";
host.open(runCodeUrl(host.location, button.dataset.libs || "", text));
});
})();
try {
const result = languageDestination(host.location.href, host.navigator.language, !!host.localStorage.getItem("lang-init"), englishPaths);
if (result.remember)
host.localStorage.setItem("lang-init", "true");
if (result.target)
host.location.href = result.target;
} catch {
}
}
(function () {
const language = window.navigator?.language || '';
const isDev = location.hostname === 'localhost';
if (!language.startsWith('zh') && !localStorage.getItem('lang-init') && !isDev) {
localStorage.setItem('lang-init', 'true');
window.location.href = 'https://artplayer.org/document/en/';
}
})();
// <stdin>
installNavigation(window, ["advanced/built-in.html", "advanced/built-in", "advanced/class.html", "advanced/class", "advanced/event.html", "advanced/event", "advanced/global.html", "advanced/global", "advanced/plugin.html", "advanced/plugin", "advanced/property.html", "advanced/property", "component/contextmenu.html", "component/contextmenu", "component/controls.html", "component/controls", "component/layers.html", "component/layers", "component/setting.html", "component/setting", "", "index.html", "start/i18n.html", "start/i18n", "start/option.html", "start/option"]);
})();