refactor(docs): [SITE-03] migrate desktop editor and integrate typed generation

This commit is contained in:
Harvey Zhao committed 2026-09-14 09:25:46 +08:00
1 parent a468cb2c39
commit 2f130c58f1
37 files changed
+1921 -305

No files matched your search

+5 -4
View File
@@ -23,10 +23,11 @@ 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
loader, desktop editor, 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.
`yarn check:site-assets` for drift. Desktop common.js and bootstrap.js are generated
from typed modules, with Monaco models/emit, Run ordering, file imports, preferences
and page cleanup separated by responsibility. No player package APIs move here.
## Related tooling
@@ -87,7 +88,7 @@ Generated `main.js` installs one document click listener under
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.
current rules and verified scope. Full site/search acceptance remains SITE-05.
## Delivery and remaining acceptance
+43 -8
View File
@@ -11,13 +11,24 @@ Node 24.21.0 / Yarn 1.22.22 toolchain from the repository root.
| `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
Generated outputs are `docs/assets/js/bootstrap.js`, `common.js`, `loader.js`,
`mobile.js` and this workspace's `docs/public/main.js`. Do not hand-edit them.
`docs/index.html` loads the console bridge and loader before the typed 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.
`common.js` is now generated from `editor.ts`. Desktop responsibilities are:
- `bootstrap.ts` and `editor-preferences.ts`: core/AMD/editor boot order, denied
storage defaults and the exact prod/ts/code/log preference keys.
- `editor.ts` and `editor-host.ts`: DOM bindings, page lifetime and the explicit
player/AMD/console boundary. Monaco globals come from the HTML bootstrap.
- `editor-monaco.ts`: model/declaration ownership, AMD language readiness and TS emit.
- `editor-session.ts`: latest-run ordering, source priority and classic-script execution.
- `editor-files.ts`: serialized JS/CSS imports, FileReader errors/cancellation and nodes.
- `editor-libraries.ts`: generated declaration URLs, authored through build:ts.
Root `monaco-editor@0.30.1` is a development type dependency matching the existing
browser vendor version. It is imported only as types, never bundled to replace
the browser assets. It does not complete the separate vendor provenance review.
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
@@ -41,6 +52,28 @@ 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.
Successful preference changes keep their old true/false strings and reload behavior;
code/log visibility and URL pushState/Ctrl/Cmd-S remain. Denied storage defaults to
false, while failed writes report an error and restore the checkbox. Run is unavailable
until initialization finishes. Actual JS/TS AMD modules are awaited before example
dependencies can mask define; the old arbitrary one-second delay is removed.
TypeScript mode now compiles an immutable temporary model before Run. Syntax failures
keep the current player; semantic diagnostics are visible but are not a runtime gate.
This is a classic-script editor, not a module bundler or npm import resolver. JavaScript
uses a fresh non-strict function scope: globals work and top-level var stays local to
that Run. Access to the old private common.js closure was not a supported interface.
Late compilation cannot replace a newer Run; temporary models are disposed on settlement
and page disposal. Monaco worker RPCs have no invented cancellation API.
Imports preserve sequential JS/CSS insertion and the last lowercased `[name]` label.
Unsupported extensions are ignored; local labels are not persistable dependency URLs.
File errors now reject, cancellation removes handlers, and batches are serialized.
On non-persisted pagehide the editor cleans listeners, models, declaration libraries,
FileReaders, imported nodes and players. Executed script globals cannot be undone by
removing script nodes. Persisted pagehide keeps state; synthetic lifecycle tests do
not certify physical-device BFCache behavior.
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
@@ -62,14 +95,16 @@ yarn build:site-assets
yarn check:site-assets
yarn typecheck:site-assets
yarn typecheck:docs-tools
yarn test:site-editor
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
root lint includes the TS sources; common.js is checked by generated asset comparison. 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.
VitePress build/i18n tools are in scripts/site-build and offline LLM/draft translation
tools are in scripts/documentation. Full site/search and release acceptance remain separate.
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
@@ -0,0 +1,34 @@
import { readPreference } from './editor-preferences.ts'
function loadScript(url: string): Promise<void> {
return new Promise((resolve, reject) => {
const script = document.createElement('script')
const clear = () => {
script.onload = null
script.onerror = null
}
script.onload = () => {
clear()
resolve()
}
script.onerror = () => {
clear()
script.remove()
reject(new Error(`Editor bootstrap failed: ${url}`))
}
script.src = url
document.body.appendChild(script)
})
}
async function bootstrap(): Promise<void> {
await loadScript(
readPreference('prod')
? './compiled/artplayer.js'
: './uncompiled/artplayer/index.js',
)
await loadScript('./assets/js/vs/loader.js')
await loadScript('./assets/js/common.js')
}
void bootstrap().catch(error => console.error(error))
@@ -0,0 +1,82 @@
export function readEditorFile(
file: File,
signal: AbortSignal,
createReader: () => FileReader = () => new FileReader(),
): Promise<string> {
return new Promise((resolve, reject) => {
const reader = createReader()
function clear() {
reader.onload = null
reader.onerror = null
reader.onabort = null
signal.removeEventListener('abort', cancel)
}
function fail(error: unknown) {
clear()
reject(error)
}
function cancel() {
reader.abort()
fail(new Error(`File import cancelled: ${file.name}`))
}
reader.onload = () => {
if (typeof reader.result !== 'string') {
fail(new Error(`File is not text: ${file.name}`))
return
}
const value = reader.result
clear()
resolve(value)
}
reader.onerror = () =>
fail(reader.error || new Error(`Reading file failed: ${file.name}`))
reader.onabort = () =>
fail(new Error(`File import cancelled: ${file.name}`))
signal.addEventListener('abort', cancel, { once: true })
try {
if (signal.aborted)
cancel()
else reader.readAsText(file)
}
catch (error) {
fail(error)
}
})
}
export function createFileImports(
document: Document,
label: (name: string) => void,
) {
const controller = new AbortController()
const elements: HTMLElement[] = []
let queue: Promise<unknown> = Promise.resolve()
return {
add(files: File[]): Promise<void> {
const work = queue.then(async () => {
for (const file of files) {
const name = file.name.toLowerCase().trim()
if (!name.endsWith('.js') && !name.endsWith('.css'))
continue
const text = await readEditorFile(file, controller.signal)
if (controller.signal.aborted)
return
const element = document.createElement(
name.endsWith('.css') ? 'style' : 'script',
)
element.textContent = text
document.body.appendChild(element)
elements.push(element)
label(name)
}
})
queue = work.catch(() => {})
return work
},
dispose(): void {
controller.abort()
for (const element of elements) element.remove()
elements.length = 0
},
}
}
@@ -0,0 +1,36 @@
import type * as Monaco from 'monaco-editor'
import type {
createLibraryLoader,
exampleSource,
parameters,
} from './loader.ts'
export interface EditorPlayer {
destroy: (removeHtml?: boolean) => unknown
}
export interface AmdLoader {
(
modules: string[],
ready: () => void,
failed: (error: unknown) => void,
): void
config: (options: { paths: { vs: string } }) => void
}
export type EditorHost = Window & {
console: Console
Artplayer: {
DEBUG: boolean
utils: { isMobile: boolean }
instances: EditorPlayer[]
}
monaco: typeof Monaco
require: AmdLoader
consoleLog: (element: HTMLElement) => void
ArtplayerDocsLoader: ReturnType<typeof createLibraryLoader> & {
exampleSource: typeof exampleSource
parameters: typeof parameters
}
art: EditorPlayer | undefined
}
export type Editor = Monaco.editor.IStandaloneCodeEditor
@@ -0,0 +1,25 @@
// Generated by yarn build:ts. Do not edit.
export const libUris = [
'./assets/ts/artplayer-plugin-ads.d.ts',
'./assets/ts/artplayer-plugin-ambilight.d.ts',
'./assets/ts/artplayer-plugin-asr.d.ts',
'./assets/ts/artplayer-plugin-audio-track.d.ts',
'./assets/ts/artplayer-plugin-auto-thumbnail.d.ts',
'./assets/ts/artplayer-plugin-chapter.d.ts',
'./assets/ts/artplayer-plugin-chromecast.d.ts',
'./assets/ts/artplayer-plugin-danmuku-mask.d.ts',
'./assets/ts/artplayer-plugin-danmuku.d.ts',
'./assets/ts/artplayer-plugin-dash-control.d.ts',
'./assets/ts/artplayer-plugin-document-pip.d.ts',
'./assets/ts/artplayer-plugin-hls-control.d.ts',
'./assets/ts/artplayer-plugin-jassub.d.ts',
'./assets/ts/artplayer-plugin-multiple-subtitles.d.ts',
'./assets/ts/artplayer-plugin-vast.d.ts',
'./assets/ts/artplayer-plugin-vtt-thumbnail.d.ts',
'./assets/ts/artplayer-proxy-canvas.d.ts',
'./assets/ts/artplayer-proxy-mediabunny.d.ts',
'./assets/ts/artplayer-tool-iframe.d.ts',
'./assets/ts/artplayer-tool-thumbnail.d.ts',
'./assets/ts/artplayer.d.ts',
'./assets/ts/artplayer-i18n.d.ts',
]
@@ -0,0 +1,130 @@
import type { Editor, EditorHost } from './editor-host.ts'
import { libUris } from './editor-libraries.ts'
export async function createMonaco(
host: EditorHost,
container: HTMLElement,
typescript: boolean,
signal: AbortSignal,
) {
host.require.config({ paths: { vs: './assets/js/vs' } })
await new Promise<void>((resolve, reject) =>
host.require(['vs/editor/editor.main'], resolve, reject),
)
await new Promise<void>((resolve, reject) =>
host.require(
[
'vs/basic-languages/typescript/typescript',
'vs/basic-languages/javascript/javascript',
'vs/language/typescript/tsMode',
],
resolve,
reject,
),
)
if (signal.aborted)
throw new Error('Editor initialization cancelled')
const api = host.monaco
const types = api.languages.typescript
const resources: { dispose: () => void }[] = []
const temporary = new Set<ReturnType<typeof api.editor.createModel>>()
let editor: Editor | undefined
let disposed = false
const dispose = () => {
if (disposed)
return
disposed = true
signal.removeEventListener('abort', dispose)
editor?.dispose()
editor = undefined
for (const model of temporary) model.dispose()
temporary.clear()
for (const resource of resources.splice(0).reverse()) resource.dispose()
}
signal.addEventListener('abort', dispose, { once: true })
try {
for (const defaults of [
types.javascriptDefaults,
types.typescriptDefaults,
]) {
defaults.setDiagnosticsOptions({
noSemanticValidation: defaults === types.javascriptDefaults,
noSyntaxValidation: false,
})
defaults.setCompilerOptions({
target: types.ScriptTarget.ES2015,
module: types.ModuleKind.None,
allowNonTsExtensions: true,
noEmit: false,
})
}
for (const uri of libUris) {
const response = await host.fetch(uri, { signal })
if (!response.ok) {
throw new Error(
`Loading editor declaration failed: ${uri} (${response.status})`,
)
}
const source = await response.text()
if (signal.aborted)
throw new Error('Editor initialization cancelled')
resources.push(
types.javascriptDefaults.addExtraLib(source, uri),
types.typescriptDefaults.addExtraLib(source, uri),
)
resources.push(
api.editor.createModel(source, 'typescript', api.Uri.parse(uri)),
)
}
const model = api.editor.createModel(
'var art = new Artplayer({\n\tcontainer: \'.artplayer-app\',\n\turl: \'/assets/sample/video.mp4\',\n});',
typescript ? 'typescript' : 'javascript',
)
resources.push(model)
editor = api.editor.create(container, {
theme: 'vs-dark',
folding: true,
automaticLayout: true,
quickSuggestions: { other: true, comments: true, strings: true },
model,
})
return {
editor,
async compile(source: string): Promise<string> {
if (disposed)
throw new Error('Editor was disposed')
if (!typescript)
return source
const model = api.editor.createModel(source, 'typescript')
temporary.add(model)
try {
const worker = await (await types.getTypeScriptWorker())(model.uri)
const file = model.uri.toString()
const syntax = await worker.getSyntacticDiagnostics(file)
if (syntax.length) {
throw new Error(
`TypeScript syntax error: ${syntax.map(item => (typeof item.messageText === 'string' ? item.messageText : item.messageText.messageText)).join('; ')}`,
)
}
const output = await worker.getEmitOutput(file)
const script = output.outputFiles.find(file =>
file.name.endsWith('.js'),
)
if (output.emitSkipped || !script)
throw new Error('TypeScript worker did not emit JavaScript')
return script.text
}
finally {
temporary.delete(model)
if (!model.isDisposed())
model.dispose()
}
},
dispose,
}
}
catch (error) {
dispose()
throw error
}
}
@@ -0,0 +1,27 @@
export type Preference = 'prod' | 'ts' | 'code' | 'log'
export function readPreference(
key: Preference,
storage: () => Storage = () => localStorage,
): boolean {
try {
return storage().getItem(key) === 'true'
}
catch {
return false
}
}
export function writePreference(
key: Preference,
value: boolean,
storage: () => Storage = () => localStorage,
): boolean {
try {
storage().setItem(key, value ? 'true' : 'false')
return true
}
catch {
return false
}
}
@@ -0,0 +1,51 @@
interface SessionOptions {
loadLibraries: (encoded: string) => Promise<unknown>
exampleSource: (name: string) => Promise<string>
writeCode: (source: string) => void
compile: (source: string) => Promise<string>
execute: (code: string) => void
}
export function createEditorSession(options: SessionOptions) {
let generation = 0
let disposed = false
return {
async run({
libs = '',
code,
example,
}: {
libs?: string
code?: string
example?: string
}): Promise<void> {
const current = ++generation
const active = () => !disposed && current === generation
if (!active())
return
await options.loadLibraries(libs)
if (!active())
return
const source = example
? await options.exampleSource(example)
: code
? decodeURIComponent(code).trim()
: await options.exampleSource('index')
if (!active())
return
options.writeCode(source)
const executable = await options.compile(source)
if (active())
options.execute(executable)
},
dispose(): void {
disposed = true
generation++
},
}
}
export function executeClassic(code: string): void {
// eslint-disable-next-line no-new-func -- The existing editor explicitly executes user-entered classic script on Run.
new Function(code)()
}
@@ -0,0 +1,152 @@
import type { EditorHost } from './editor-host.ts'
import { createFileImports } from './editor-files.ts'
import { createMonaco } from './editor-monaco.ts'
import { readPreference, writePreference } from './editor-preferences.ts'
import { createEditorSession, executeClassic } from './editor-session.ts'
export async function installEditor(host: EditorHost): Promise<void> {
const document = host.document
const element = <T extends HTMLElement>(selector: string): T => {
const node = document.querySelector<T>(selector)
if (!node)
throw new Error(`Missing editor element: ${selector}`)
return node
}
host.Artplayer.DEBUG = true
if (host.Artplayer.utils.isMobile) {
host.location.href = `./mobile.html${host.location.search}`
return
}
const run = element<HTMLButtonElement>('.run')
const libs = element<HTMLTextAreaElement>('.libsInput')
const file = element<HTMLInputElement>('#file')
const popup = element<HTMLDivElement>('.popups')
const output = element<HTMLDivElement>('.console')
host.consoleLog(output)
const controller = new AbortController()
const cleanups: (() => void)[] = []
const listen = (
target: EventTarget,
type: string,
callback: EventListener,
) => {
target.addEventListener(type, callback)
cleanups.push(() => target.removeEventListener(type, callback))
}
const report = (error: unknown) => {
if (!controller.signal.aborted)
host.console.error(error)
}
listen(host, 'error', event => host.console.error(event))
listen(host, 'unhandledrejection', event => host.console.error(event))
for (const key of ['prod', 'ts', 'code', 'log'] as const) {
const checkbox = element<HTMLInputElement>(`#${key}`)
checkbox.checked = readPreference(key, () => host.localStorage)
listen(checkbox, 'change', () => {
if (writePreference(key, checkbox.checked, () => host.localStorage)) {
host.location.reload()
}
else {
checkbox.checked = readPreference(key, () => host.localStorage)
report(
new Error('Browser storage is unavailable; preference was not saved'),
)
}
})
}
if (element<HTMLInputElement>('#code').checked)
element('#editor').style.display = 'none'
if (element<HTMLInputElement>('#log').checked)
output.style.display = 'none'
const imports = createFileImports(document, (name) => {
libs.value = `[${name}]`
})
cleanups.push(() => imports.dispose())
cleanups.push(() => {
host.dispatchEvent(new Event('artplayer:example:cleanup'))
for (const art of host.Artplayer.instances.slice()) {
try {
art.destroy(true)
}
catch (error) {
host.console.error(error)
}
}
host.art = undefined
})
listen(file, 'change', () => {
void imports.add(Array.from(file.files || [])).catch(report)
})
listen(popup, 'click', (event) => {
if (event.target === popup)
popup.style.display = 'none'
})
const onPageHide = (event: PageTransitionEvent) => {
if (event.persisted)
return
controller.abort()
for (const cleanup of cleanups.reverse()) {
try {
cleanup()
}
catch (error) {
host.console.error(error)
}
}
}
host.addEventListener('pagehide', onPageHide)
cleanups.push(() => host.removeEventListener('pagehide', onPageHide))
run.setAttribute('aria-disabled', 'true')
try {
const monaco = await createMonaco(
host,
element('.codeMirrorWrap'),
element<HTMLInputElement>('#ts').checked,
controller.signal,
)
const session = createEditorSession({
loadLibraries: (encoded) => {
libs.value = decodeURIComponent(encoded)
return host.ArtplayerDocsLoader.loadLibraries(encoded)
},
exampleSource: name => host.ArtplayerDocsLoader.exampleSource(name),
writeCode: code => monaco.editor.setValue(code),
compile: monaco.compile,
execute: (code) => {
host.dispatchEvent(new Event('artplayer:example:cleanup'))
for (const art of host.Artplayer.instances.slice()) art.destroy(true)
executeClassic(code)
host.art = host.Artplayer.instances[0]
},
})
cleanups.push(() => session.dispose(), monaco.dispose)
function restart() {
const code = encodeURIComponent(monaco.editor.getValue())
const dependencies = encodeURIComponent(libs.value)
host.history.pushState(
null,
'',
`${host.location.origin}${host.location.pathname}?libs=${dependencies}&code=${code}`,
)
void session.run({ libs: dependencies, code }).catch(report)
}
listen(run, 'click', restart)
listen(document, 'keydown', (event) => {
const key = event as KeyboardEvent
if ((key.ctrlKey || key.metaKey) && key.key === 's') {
key.preventDefault()
restart()
}
})
run.removeAttribute('aria-disabled')
await session.run(host.ArtplayerDocsLoader.parameters(host.location.href))
}
catch (error) {
report(error)
}
}
// HTML bootstraps the existing player, Monaco AMD loader and console bridge before this entry.
void installEditor(window as unknown as EditorHost).catch(error =>
console.error(error),
)