mirror of
https://github.com/zhw2590582/ArtPlayer.git
synced 2026-10-26 00:31:20 -08:00
fix(docs): [SITE-LOAD-01] serialize example loading and preserve navigation
This commit is contained in:
1 parent
76499e7a3e
commit
647f3e7f12
29 files changed
+1977
-256
No files matched your search
@@ -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"]);
|
||||
})();
|
||||
Reference in new issue
Block a user