Files
deepseek-harness/packages/util/plugin-detail/src/document.ts
T
turtle1999 bbcee26cb5 fix(plugin-manager): gate detail documents and validate their publication path
verify-md-links and verify-md-wrap now scan packages/*/*/docs/*.md, the
pairing documentation records why detail documents stay outside the
pairing gate, and check-workspace-constraints reuses the runtime
meta.detail path rule, requires an existing file in a subdirectory, and
rejects malformed or missing declarations. The detail section drops its
border and inset padding so images meet the page's rounded corners.
2026-10-19 22:23:13 +08:00

141 lines
7.0 KiB
TypeScript

/**
* Plugin detail document selection: the Markdown file a package's locale `meta.detail` names in a display language,
* otherwise the package's README; and the images beside a selected document.
*/
import { posix } from 'node:path'
import { DetailFailure, joinPath, type DetailFiles } from './files.ts'
const LANGUAGE_ID = /^[a-z]{2,8}(?:-[a-z0-9]{1,8})*$/u
/**
* The languages a document is chosen in: the language, each shorter prefix, then English.
* @param language - a BCP 47 language id such as `zh-CN`.
* @returns lower-case language ids, most specific first.
* @throws when `language` is not a language id.
*/
export function languageChain(language: string): string[] {
const id = language.toLowerCase()
if (!LANGUAGE_ID.test(id)) throw new Error(`Not a language id: ${language}`)
const parts = id.split('-')
const chain = parts.map((_part, index) => parts.slice(0, parts.length - index).join('-'))
return [...new Set([...chain, 'en'])]
}
/**
* A locale `meta.detail` value: a `/`-separated Markdown path relative to the package root that cannot leave it.
* @param value - the declared `meta.detail` value, or `undefined` when the locale file omits it.
* @param label - the locale file path used in a failure message.
* @returns the validated path, or `undefined` when the value is absent.
* @throws {DetailFailure} `invalid` for another value.
*/
export function detailPath(value: unknown, label: string): string | undefined {
if (value === undefined) return undefined
const invalid = (rule: string): DetailFailure => new DetailFailure('invalid', `${label} ${rule}`)
if (typeof value !== 'string' || value.trim() === '') throw invalid('must be a non-empty string')
if (value.includes('\\') || /^[A-Za-z][A-Za-z\d+.-]*:/u.test(value) || value.split('/').some(segment => segment === '' || segment === '.' || segment === '..')) {
throw invalid('must be a relative path inside the package')
}
if (!/\.(?:md|markdown)$/iu.test(value)) throw invalid('must name a Markdown file')
return value
}
/** `meta.detail` of the first `locale/<language>.json` along the chain that declares one. */
async function localeDetail(
files: DetailFiles, dir: string, languages: readonly string[], maxBytes: number, signal: AbortSignal,
): Promise<string | undefined> {
for (const language of languages) {
// Locale files are named by language id: lower case, and also with an upper-case two-letter region.
const names = [...new Set([language, language.replace(/^([a-z]{2,3})-([a-z]{2})$/u, (_match, base: string, region: string) => `${base}-${region.toUpperCase()}`)])]
for (const name of names) {
const path = joinPath(dir, `locale/${name}.json`)
const bytes = await files.read(path, maxBytes, signal)
if (bytes === undefined) continue
let meta: unknown
try {
meta = (JSON.parse(new TextDecoder().decode(bytes)) as { meta?: unknown } | null)?.meta
} catch (error) {
throw new DetailFailure('invalid', `${path}: ${(error as SyntaxError).message}`, { cause: error })
}
const detail = detailPath(typeof meta === 'object' && meta !== null ? (meta as { detail?: unknown }).detail : undefined, `${path}: meta.detail`)
if (detail !== undefined) return detail
break
}
}
return undefined
}
/**
* Decode a Markdown document: UTF-8 without a byte order mark, without a leading YAML front matter block, which is file
* metadata rather than description text.
* @param bytes - the file's bytes.
* @returns the Markdown text.
*/
export function markdownOf(bytes: Uint8Array): string {
return new TextDecoder().decode(bytes).replace(/^\uFEFF/u, '').replace(/^---\r?\n[\s\S]*?\r?\n---[ \t]*(?:\r?\n|$)/u, '')
}
/** A package's detail document. */
export interface PackageDocument {
/** `locale` for the file `meta.detail` names, `readme` for the package's README. */
readonly origin: 'locale' | 'readme'
/** `/`-separated path of the document inside the files. */
readonly path: string
/** The document, decoded by {@link markdownOf}. */
readonly markdown: string
}
/**
* Read a package's detail document: the Markdown file that `meta.detail` in its `locale/<language>.json` names for the
* first language of the chain that declares one, otherwise its README.
* @param files - the package's files.
* @param dir - package directory inside `files`, `''` for its root.
* @param languages - the chain from {@link languageChain}.
* @param maxBytes - largest accepted locale file and document.
* @param signal - cancellation of the reads.
* @returns the document, or undefined when the package has neither.
* @throws {DetailFailure} `invalid` for a malformed locale file or `meta.detail` or a named file that does not exist, and
* the failures of `files`.
*/
export async function readPackageDocument(
files: DetailFiles, dir: string, languages: readonly string[], maxBytes: number, signal: AbortSignal,
): Promise<PackageDocument | undefined> {
const detail = await localeDetail(files, dir, languages, maxBytes, signal)
if (detail !== undefined) {
const path = joinPath(dir, detail)
const bytes = await files.read(path, maxBytes, signal)
if (bytes === undefined) throw new DetailFailure('invalid', `${path}, named by meta.detail, does not exist`)
return { origin: 'locale', path, markdown: markdownOf(bytes) }
}
const readme = await files.readme(dir, maxBytes, signal)
return readme === undefined ? undefined : { origin: 'readme', path: readme.path, markdown: markdownOf(readme.bytes) }
}
/** Image file types served beside a document, by lower-case extension. */
const IMAGE_TYPES = new Map([
['.png', 'image/png'], ['.jpg', 'image/jpeg'], ['.jpeg', 'image/jpeg'], ['.gif', 'image/gif'],
['.webp', 'image/webp'], ['.avif', 'image/avif'], ['.svg', 'image/svg+xml'],
])
/**
* Resolve an image destination a document writes.
* @param document - `/`-separated path of the document inside its files.
* @param authored - the destination as written: relative to the document, or to the root of its files when it starts
* with `/`; a query or fragment is ignored.
* @returns the image's path inside the files and its media type, or undefined for a malformed destination, a path that
* leaves the root, or a type other than PNG, JPEG, GIF, WebP, AVIF, and SVG.
*/
export function detailImage(document: string, authored: string): { path: string; mediaType: string } | undefined {
let decoded: string
try {
decoded = decodeURIComponent(authored.replace(/[?#].*$/su, ''))
} catch (_malformed) {
// A destination with a malformed percent escape names no file.
return undefined
}
if (decoded.includes('\\') || decoded.includes('\0')) return undefined
const path = posix.normalize(decoded.startsWith('/') ? decoded.slice(1) : posix.join(posix.dirname(document), decoded))
if (path === '.' || path.startsWith('../') || path === '..' || posix.isAbsolute(path)) return undefined
const mediaType = IMAGE_TYPES.get(posix.extname(path).toLowerCase())
return mediaType === undefined ? undefined : { path, mediaType }
}