mirror of
https://github.com/zhw2590582/ArtPlayer.git
synced 2026-10-15 09:06:16 -08:00
test(site): [SITE-05] enforce built links and validate online editors
This commit is contained in:
1 parent
6b580d1667
commit
fb78328c43
17 files changed
+2122
-15
No files matched your search
@@ -0,0 +1,13 @@
|
||||
import process from 'node:process'
|
||||
import { checkSiteLinks } from './site-build/links.ts'
|
||||
|
||||
try {
|
||||
const report = await checkSiteLinks(`${process.cwd()}/docs`)
|
||||
console.log(JSON.stringify(report, null, 2))
|
||||
if (report.issues.length)
|
||||
process.exitCode = 1
|
||||
}
|
||||
catch (error) {
|
||||
console.error(error instanceof Error ? error.message : error)
|
||||
process.exitCode = 1
|
||||
}
|
||||
@@ -88,4 +88,23 @@ native radio selection and visible code blocks after clicking the built code-gro
|
||||
labels. SITE-03 still
|
||||
owns desktop editor UI migration, SITE-04 semantic/bilingual documentation and
|
||||
SITE-05 full site/search/link acceptance. No npm/Pages publication or remote CI
|
||||
run follows from a successful local build. No dependencies were added.
|
||||
run follows from a successful local build.
|
||||
|
||||
## Generated documentation links
|
||||
|
||||
`yarn check:site-links` reads every generated HTML page under `docs/document/`
|
||||
and the actual built `virtual_search-data.*.js` module. `links.ts` uses the pinned
|
||||
root development dependency `htmlparser2@10.1.0` to collect href/src references,
|
||||
IDs and legacy named anchors without executing page scripts. It resolves local
|
||||
files, directory indexes, encoded fragments and references back to the site root.
|
||||
Script contents and inert template contents are not treated as rendered links.
|
||||
An HTML base element requires explicit checker support rather than silently
|
||||
using the wrong base. Missing/ambiguous/malformed search data and missing HTML
|
||||
output fail the command. A missing target or anchor also exits nonzero.
|
||||
|
||||
The checker runs in `ci:check` against committed output and after the documentation
|
||||
build in `ci:build`. Its JSON lists external URLs without requesting them. External
|
||||
availability, CSS/JavaScript-discovered URLs, interactive Run Code forwarding,
|
||||
playback and online editor behavior need separate checks. `test/site-links.test.js`
|
||||
exercises valid HTML/entities, missing assets and anchors, invalid URLs and broken
|
||||
search modules; it is included in `test:site-build` and `test:node`.
|
||||
@@ -0,0 +1,156 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { createHash } from 'node:crypto'
|
||||
import fs from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { Parser } from 'htmlparser2'
|
||||
|
||||
export interface LinkIssue {
|
||||
source: string
|
||||
href: string
|
||||
reason: string
|
||||
}
|
||||
|
||||
async function files(directory: string): Promise<string[]> {
|
||||
const entries = await fs.readdir(directory, { withFileTypes: true })
|
||||
const result: string[] = []
|
||||
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
||||
assert(!entry.isSymbolicLink(), `Unexpected symlink: ${entry.name}`)
|
||||
const file = path.join(directory, entry.name)
|
||||
if (entry.isDirectory())
|
||||
result.push(...await files(file))
|
||||
else result.push(file)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function record(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value && typeof value === 'object' && !Array.isArray(value))
|
||||
}
|
||||
|
||||
function readHtml(source: string) {
|
||||
const anchors = new Set<string>()
|
||||
const references: string[] = []
|
||||
let templateDepth = 0
|
||||
new Parser({
|
||||
onopentag(name, attributes) {
|
||||
const inert = templateDepth > 0
|
||||
if (name === 'template')
|
||||
templateDepth++
|
||||
if (inert)
|
||||
return
|
||||
assert(name !== 'base' || !attributes.href, 'An HTML base URL requires explicit link-checker support')
|
||||
if (attributes.id)
|
||||
anchors.add(attributes.id)
|
||||
if (name === 'a' && attributes.name)
|
||||
anchors.add(attributes.name)
|
||||
for (const attribute of ['href', 'src']) {
|
||||
if (attributes[attribute])
|
||||
references.push(attributes[attribute])
|
||||
}
|
||||
},
|
||||
onclosetag(name) {
|
||||
if (name === 'template')
|
||||
templateDepth--
|
||||
},
|
||||
}).end(source)
|
||||
return { anchors, references }
|
||||
}
|
||||
|
||||
// Check the shipped index, not a separately regenerated approximation of its links.
|
||||
export async function readSearchLinks(documentRoot: string) {
|
||||
const chunks = path.join(documentRoot, 'assets/chunks')
|
||||
const matches = (await fs.readdir(chunks)).filter(name => /^virtual_search-data\.[\w-]+\.js$/.test(name))
|
||||
assert.equal(matches.length, 1, 'Expected exactly one built search data module')
|
||||
const name = matches[0]
|
||||
assert(name)
|
||||
const file = path.join(chunks, name)
|
||||
const sha256 = createHash('sha256').update(await fs.readFile(file)).digest('hex')
|
||||
const module: unknown = await import(`${pathToFileURL(file).href}?sha256=${sha256}`)
|
||||
assert(record(module) && record(module.default) && record(module.default.PREVIEW_LOOKUP), 'Invalid built search lookup')
|
||||
const links = Object.entries(module.default.PREVIEW_LOOKUP).map(([id, value]) => {
|
||||
assert(record(value) && typeof value.l === 'string' && value.l.length, `Missing search URL: ${id}`)
|
||||
return { id, href: value.l }
|
||||
})
|
||||
assert(links.length > 0, 'Empty search lookup')
|
||||
return { file, sha256, links }
|
||||
}
|
||||
|
||||
export async function checkSiteLinks(siteRoot: string) {
|
||||
const root = await fs.realpath(siteRoot)
|
||||
const documentRoot = path.join(root, 'document')
|
||||
const htmlFiles = (await files(documentRoot)).filter(file => file.endsWith('.html'))
|
||||
assert(htmlFiles.length > 0, 'No generated documentation pages')
|
||||
const search = await readSearchLinks(documentRoot)
|
||||
const documents = new Map<string, ReturnType<typeof readHtml>>()
|
||||
const external = new Set<string>()
|
||||
const issues: LinkIssue[] = []
|
||||
let localReferences = 0
|
||||
|
||||
async function document(file: string) {
|
||||
let value = documents.get(file)
|
||||
if (!value) {
|
||||
value = readHtml(await fs.readFile(file, 'utf8'))
|
||||
documents.set(file, value)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
async function check(href: string, source: string, base = source) {
|
||||
try {
|
||||
const url = new URL(href, new URL(base, 'https://artplayer.org'))
|
||||
if (!['http:', 'https:'].includes(url.protocol))
|
||||
return
|
||||
if (url.hostname !== 'artplayer.org') {
|
||||
external.add(url.href)
|
||||
return
|
||||
}
|
||||
localReferences++
|
||||
let file = path.resolve(root, `.${decodeURIComponent(url.pathname)}`)
|
||||
assert(file === root || file.startsWith(`${root}${path.sep}`), 'Target outside site root')
|
||||
let stat
|
||||
try {
|
||||
stat = await fs.stat(file)
|
||||
}
|
||||
catch {
|
||||
throw new Error('Missing local file')
|
||||
}
|
||||
if (stat.isDirectory()) {
|
||||
file = path.join(file, 'index.html')
|
||||
assert((await fs.stat(file)).isFile(), 'Missing directory index')
|
||||
}
|
||||
assert((await fs.realpath(file)).startsWith(`${root}${path.sep}`), 'Target resolves outside site root')
|
||||
if (url.hash && file.endsWith('.html')) {
|
||||
const id = decodeURIComponent(url.hash.slice(1))
|
||||
const target = await document(file)
|
||||
if (id && id.toLowerCase() !== 'top') {
|
||||
assert(target.anchors.has(id), `Missing anchor: ${id}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
issues.push({ source, href, reason: error instanceof Error ? error.message : String(error) })
|
||||
}
|
||||
}
|
||||
|
||||
for (const file of htmlFiles) {
|
||||
const source = `/${path.relative(root, file).replaceAll('\\', '/')}`
|
||||
const html = await document(file)
|
||||
for (const href of html.references)
|
||||
await check(href, source)
|
||||
}
|
||||
const pageIssues = issues.length
|
||||
for (const link of search.links)
|
||||
await check(link.href, `search:${link.id}`, '/document/')
|
||||
|
||||
return {
|
||||
pages: htmlFiles.length,
|
||||
localReferences,
|
||||
searchEntries: search.links.length,
|
||||
searchModule: { path: path.relative(root, search.file).replaceAll('\\', '/'), sha256: search.sha256 },
|
||||
externalUrls: [...external].sort(),
|
||||
pageIssues,
|
||||
searchIssues: issues.length - pageIssues,
|
||||
issues,
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,7 @@
|
||||
"build-site-notices.mjs",
|
||||
"build-i18n.js",
|
||||
"build-docs.js",
|
||||
"check-site-links.mjs",
|
||||
"../packages/artplayer-vitepress/build/**/*.ts",
|
||||
"build-vconsole.mjs",
|
||||
"browser-validation/**/*.ts",
|
||||
|
||||
Reference in new issue
Block a user