mirror of
https://github.com/zhw2590582/ArtPlayer.git
synced 2026-10-30 03:51:25 -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
@@ -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"]
|
||||
}
|
||||
Reference in new issue
Block a user