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.
This commit is contained in:
1 parent
3be4701a9d
commit
bbcee26cb5
14 files changed
+62
-20
No files matched your search
@@ -21,8 +21,8 @@
|
||||
en: 0894bd9f35430753
|
||||
zh: 2d28090e71134edd
|
||||
/cookbook-adding-a-workspace-package/5-add-optional-plugin-display-metadata:
|
||||
en: b5e571e44663df34
|
||||
zh: 8e94de25a969ba16
|
||||
en: ed501a72ab8ba6e7
|
||||
zh: 8ad775b735887a10
|
||||
/cookbook-adding-a-workspace-package/6-verify:
|
||||
en: 80c7fb90b1175aae
|
||||
zh: 2ba37962dbc57299
|
||||
@@ -147,7 +147,7 @@ Export `<package name>/locale/en.json` for locale lookup; expose `<package name>
|
||||
|
||||
For an image on bundle cards, details, and component rows, a package-root plugin sets top-level `"icon": "./icon.svg"` in its exported manifest, or exports `./icon` when that field is omitted. A subpath plugin exports its own icon, such as `"./search/icon": "./search.svg"`. Include each image in `files`. A manifest `icon` is relative to the manifest's directory; absolute paths, URLs, paths outside that directory, and symlinks resolving outside it are rejected. An exported icon resolves through Node exports like locale resources and must remain inside its package. SVG, PNG, JPEG (`.jpg`/`.jpeg`), and WebP files are supported up to 256 KiB. Images must be self-contained; SVG is rendered as an image, not inline HTML. The Host returns a data URL without activating the plugin. Invalid declarations or unreadable files produce a metadata diagnostic while retaining valid text, without trying the export; missing or undecodable images use the panel's default artwork.
|
||||
|
||||
A bundle can also set `meta.detail` to a Markdown file path relative to the package root, such as `"docs/DETAIL.md"` in `locale/en.json` and `"docs/DETAIL.zh.md"` in `locale/zh.json`; it is read from these files at the package root. The path uses `/`, stays inside the package, ends in `.md` or `.markdown`, and has no package-field fallback; keep its images beside it, and list its top-level directory, such as `docs`, in `files` right after the locale resources, as `check-workspace-constraints` requires. A bundle's page in **Plugins**, and its market entry page when the [custom plugin markets bundle](../../packages/experimental/plugin-market-bundle/README.md) is on, show the file for the active UI language as the plugin's detail document. Without `meta.detail` they show the package README, and a market entry page then falls back to the declared repository's README; the [document order](../../packages/util/plugin-detail/README.md#document-order) defines the rules. Write it for users, and reference images by paths relative to the file.
|
||||
A bundle can also set `meta.detail` to a Markdown file path relative to the package root, such as `"docs/DETAIL.md"` in `locale/en.json` and `"docs/DETAIL.zh.md"` in `locale/zh.json`; it is read from these files at the package root. The path uses `/`, stays inside the package, ends in `.md` or `.markdown`, and has no package-field fallback; keep its images beside it, and list its top-level directory, such as `docs`, in `files` right after the locale resources, as `check-workspace-constraints` requires. The named file must exist in a subdirectory of the package; a root-level document is rejected because its neighboring images would not be published. A bundle's page in **Plugins**, and its market entry page when the [custom plugin markets bundle](../../packages/experimental/plugin-market-bundle/README.md) is on, show the file for the active UI language as the plugin's detail document. Without `meta.detail` they show the package README, and a market entry page then falls back to the declared repository's README; the [document order](../../packages/util/plugin-detail/README.md#document-order) defines the rules. Write it for users, and reference images by paths relative to the file.
|
||||
|
||||
Installed bundle cards and details, component lists and configuration details, and Settings' plugin inventory display this metadata, including disabled and preset plugins. Reads do not activate plugins.
|
||||
|
||||
|
||||
@@ -147,7 +147,7 @@ Append-only, prefix-stable, replacing, or independent behavior, including the ex
|
||||
|
||||
要在组合包卡片、详情和组件行显示图片,包根插件在导出清单顶层设置 `"icon": "./icon.svg"`,或在省略该字段时导出 `./icon`。子路径插件导出自己的图标,例如 `"./search/icon": "./search.svg"`。将每张图片加入 `files`。清单 `icon` 相对于清单所在目录;绝对路径、URL、目录外路径,以及解析到目录外的符号链接均被拒绝。导出的图标像 locale 资源一样经 Node exports 解析,且必须位于所属包内。支持不超过 256 KiB 的 SVG、PNG、JPEG(`.jpg`/`.jpeg`)和 WebP 文件。图片必须自包含;SVG 作为图片渲染,不作为内联 HTML。Host 返回 data URL,不激活插件。声明无效或文件不可读时,显示元信息诊断并保留有效文本,不再尝试导出的图标;图片缺失或无法解码时使用面板的默认插画。
|
||||
|
||||
组合包还可以把 `meta.detail` 设为相对于包根目录的 Markdown 文件路径,例如在 `locale/en.json` 中设为 `"docs/DETAIL.md"`,在 `locale/zh.json` 中设为 `"docs/DETAIL.zh.md"`;读取时使用包根目录下的这些文件。路径使用 `/`,必须位于包内,以 `.md` 或 `.markdown` 结尾,且没有包字段回退;图片放在该文件旁边,并按 `check-workspace-constraints` 的要求,在 `files` 中紧接语言资源之后列出该文件的顶层目录,例如 `docs`。组合包在**插件**中的页面,以及启用[自定义插件市场组合包](../../packages/experimental/plugin-market-bundle/README.zh.md)时其市场条目页面,把当前界面语言对应的该文件作为插件的详细介绍文档显示。没有 `meta.detail` 时,它们显示包 README;市场条目页面在此之后还会回退到所声明仓库的 README;规则由[文档顺序](../../packages/util/plugin-detail/README.zh.md#document-order)定义。该文档面向用户编写,图片使用相对于该文件的路径引用。
|
||||
组合包还可以把 `meta.detail` 设为相对于包根目录的 Markdown 文件路径,例如在 `locale/en.json` 中设为 `"docs/DETAIL.md"`,在 `locale/zh.json` 中设为 `"docs/DETAIL.zh.md"`;读取时使用包根目录下的这些文件。路径使用 `/`,必须位于包内,以 `.md` 或 `.markdown` 结尾,且没有包字段回退;图片放在该文件旁边,并按 `check-workspace-constraints` 的要求,在 `files` 中紧接语言资源之后列出该文件的顶层目录,例如 `docs`。该文件必须位于包的子目录中且已存在;包根目录下的文档会被拒绝,因为其旁边的图片不会被发布。组合包在**插件**中的页面,以及启用[自定义插件市场组合包](../../packages/experimental/plugin-market-bundle/README.zh.md)时其市场条目页面,把当前界面语言对应的该文件作为插件的详细介绍文档显示。没有 `meta.detail` 时,它们显示包 README;市场条目页面在此之后还会回退到所声明仓库的 README;规则由[文档顺序](../../packages/util/plugin-detail/README.zh.md#document-order)定义。该文档面向用户编写,图片使用相对于该文件的路径引用。
|
||||
|
||||
已安装 bundle 的卡片和详情、组件列表与配置详情、设置中的插件清单都展示这些元信息,包括禁用插件和预设内插件。读取时不激活插件。
|
||||
|
||||
|
||||
@@ -12,8 +12,8 @@
|
||||
en: 08386e96eebf06d9
|
||||
zh: c6d2b41199b0c466
|
||||
/bilingual-documentation/scope-and-exclusions:
|
||||
en: db70e758813d339b
|
||||
zh: 3dcea283a08c47cb
|
||||
en: eeda71b7ce01c121
|
||||
zh: a6361361c1e3d578
|
||||
/bilingual-documentation/division-of-labor:
|
||||
en: 88097c97cb72f3ba
|
||||
zh: 78a18000626cc818
|
||||
@@ -47,6 +47,8 @@ The gate's limit, stated plainly: **a green gate means the pair was confirmed co
|
||||
|
||||
**Scope**: the root `CONTRIBUTING.md`, `BRAND_GUIDELINES.md`, and `SAFETY.md` documents, every non-vendor README, and every active document under `.agents/notes/**`, `docs/**`, and `python/**`. README matching is case-insensitive on the basename and covers future directories without another manifest edit. Dependency and ignored build-output trees and the frozen `.agents/notes/archived/` tree are discovery exclusions, not evolving translation source.
|
||||
|
||||
**Outside scope**: a bundle's detail documents under `packages/<group>/<package>/docs/`, which locale `meta.detail` names for the plugin page. The page shows the file for the active UI language, so these documents carry no in-document language switcher and no consistency record; [`verify-md-links`](../../scripts/verify-md-links.ts) and [`verify-md-wrap`](../../scripts/verify-md-wrap.ts) still check both languages, and review keeps them in step.
|
||||
|
||||
Generated English references and graphs participate in pairing when a reviewed Chinese counterpart is available. A generator puts its generated data in generated regions and writes each region into both pages, so regeneration never requires a manual Chinese update or a new record; only the prose outside the regions is translated and recorded. Freshness and pairing gates enforce their respective invariants independently.
|
||||
|
||||
Shared generated regions, such as the Cordis subsystem regions and the per-package regions of the [config catalog](../config-catalog.md), keep every generated byte identical across languages except localized paired-document paths; labels inside a region are code identifiers rather than translated words. A full-page generator can also render source-owned paired prose, such as [persistence-catalog-text.ts](../../scripts/persistence-catalog-text.ts), from the same structural data with matching Markdown structure and identical code blocks. It regenerates both pages and their consistency record together; review still verifies the paired prose's meaning and terminology.
|
||||
|
||||
@@ -49,6 +49,8 @@
|
||||
|
||||
**范围**:根目录 `CONTRIBUTING.md`、`BRAND_GUIDELINES.md` 与 `SAFETY.md` 文档、除 vendor 源码外的全部 README,以及 `.agents/notes/**`、`docs/**` 与 `python/**` 下的全部活跃文档。匹配 README 时只看文件名且不区分大小写,因此今后新增的目录无需再修改 manifest。依赖目录、被忽略的构建产物目录以及冻结的 `.agents/notes/archived/` 目录树只在发现阶段排除,不属于持续演进的翻译源文档。
|
||||
|
||||
**范围外**:组合包 `packages/<group>/<package>/docs/` 下由 locale `meta.detail` 指定给插件页的详情文档。插件页只显示当前界面语言对应的文件,因此这些文档不带文档内的语言切换行,也没有一致性记录;[`verify-md-links`](../../scripts/verify-md-links.ts) 与 [`verify-md-wrap`](../../scripts/verify-md-wrap.ts) 仍会检查两种语言,两侧是否同步由评审把关。
|
||||
|
||||
有经评审的中文对侧的生成英文参考文档和图文档遵循配对规则。生成器把生成数据放在生成区域中,并把每个区域写入两种语言的页面,因此重新生成不需要手工更新中文,也不需要重新记录;只有区域外的说明文字需要翻译并计入记录。新鲜度门禁与配对门禁各自独立强制其约束。
|
||||
|
||||
共享生成区域(例如 Cordis 子系统区域和[配置目录](../config-catalog.zh.md)中每个包的区域)在两种语言间保持每个生成字节相同,仅配对文档路径按语言本地化;区域内的标签使用代码标识符而非译文。整页生成器也可以从相同的结构数据渲染源码维护的双语说明,例如 [persistence-catalog-text.ts](../../scripts/persistence-catalog-text.ts),并保持 Markdown 结构对应、代码块完全相同。它同时重新生成两页及其一致性记录;评审仍须验证双语说明的含义与术语。
|
||||
|
||||
@@ -10,9 +10,7 @@
|
||||
/* A long README scrolls inside the section instead of pushing the page's later sections out of reach. */
|
||||
max-height: min(560px, 60vh);
|
||||
overflow-y: auto;
|
||||
padding: 4px 16px;
|
||||
border: 0.5px solid var(--dsw-alias-border-l1);
|
||||
border-radius: var(--dsw-radius-lg);
|
||||
padding: 4px 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
|
||||
@@ -23,9 +23,12 @@ export function languageChain(language: string): string[] {
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
function detailPath(value: unknown, label: string): string | undefined {
|
||||
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')
|
||||
|
||||
@@ -331,19 +331,43 @@ describe('package payload constraints', () => {
|
||||
it('publishes the directory of each locale detail document after the locale resources', () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'dsh-detail-files-'))
|
||||
onTestFinished(() => { rmSync(dir, { recursive: true, force: true }) })
|
||||
mkdirSync(join(dir, 'docs/guide'), { recursive: true })
|
||||
mkdirSync(join(dir, 'locale'))
|
||||
writeFileSync(join(dir, 'docs/DETAIL.md'), '')
|
||||
writeFileSync(join(dir, 'docs/guide/DETAIL.zh.md'), '')
|
||||
writeFileSync(join(dir, 'docs/DETAIL.fr.md'), '')
|
||||
writeFileSync(join(dir, 'locale/en.json'), JSON.stringify({ meta: { title: 'T', detail: 'docs/DETAIL.md' } }))
|
||||
writeFileSync(join(dir, 'locale/zh.json'), JSON.stringify({ meta: { title: 'T', detail: 'docs/guide/DETAIL.zh.md' } }))
|
||||
writeFileSync(join(dir, 'locale/fr.json'), JSON.stringify({ meta: { title: 'T', detail: 'DETAIL.fr.md' } }))
|
||||
writeFileSync(join(dir, 'locale/fr.json'), JSON.stringify({ meta: { title: 'T', detail: 'docs/DETAIL.fr.md' } }))
|
||||
writeFileSync(join(dir, 'locale/de.json'), JSON.stringify({ meta: { title: 'T' } }))
|
||||
writeFileSync(join(dir, 'locale/ja.json'), '{')
|
||||
const detailFiles = detailDocumentFiles(dir)
|
||||
expect(detailFiles).toEqual(['DETAIL.fr.md', 'docs'])
|
||||
expect(detailFiles).toEqual(['docs'])
|
||||
expect(expectedDshPackageFiles({ exports: { './locale/*.json': './locale/*.json' } }, detailFiles)).toEqual([
|
||||
'locale/*.json', 'DETAIL.fr.md', 'docs', 'lib/index.js', 'lib/types/**/*.d.ts',
|
||||
'locale/*.json', 'docs', 'lib/index.js', 'lib/types/**/*.d.ts',
|
||||
])
|
||||
})
|
||||
|
||||
it('rejects a detail document that is missing, outside the package, or at the package root', () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'dsh-detail-invalid-'))
|
||||
onTestFinished(() => { rmSync(dir, { recursive: true, force: true }) })
|
||||
mkdirSync(join(dir, 'locale'))
|
||||
const declare = (detail: unknown): void => {
|
||||
writeFileSync(join(dir, 'locale/en.json'), JSON.stringify({ meta: { detail } }))
|
||||
}
|
||||
declare('docs/DETAIL.md')
|
||||
expect(() => detailDocumentFiles(dir)).toThrow(/DETAIL\.md.*does not exist/)
|
||||
mkdirSync(join(dir, 'docs'))
|
||||
writeFileSync(join(dir, 'docs/DETAIL.md'), '')
|
||||
expect(detailDocumentFiles(dir)).toEqual(['docs'])
|
||||
declare('DETAIL.md')
|
||||
expect(() => detailDocumentFiles(dir)).toThrow(/must name a file in a subdirectory/)
|
||||
declare('../DETAIL.md')
|
||||
expect(() => detailDocumentFiles(dir)).toThrow(/must be a relative path inside the package/)
|
||||
writeFileSync(join(dir, 'locale/en.json'), 'null')
|
||||
expect(detailDocumentFiles(dir)).toEqual([])
|
||||
})
|
||||
|
||||
it('rejects a bundle whose locale detail document is missing from its publication files', () => {
|
||||
const dir = 'packages/experimental/badge-skill-bundle'
|
||||
const manifest = JSON.parse(readFileSync(join(import.meta.dirname, '..', dir, 'package.json'), 'utf8')) as WorkspaceManifest['manifest']
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
} from './experimental-package-policy.ts'
|
||||
import { hasTypertRemoteNavigation, isForbiddenPublicationFile } from './publication-payload.ts'
|
||||
import type { DshBundleManifest } from '../packages/util/package-manifest/src/types.ts'
|
||||
import { detailPath } from '../packages/util/plugin-detail/src/document.ts'
|
||||
import { OPTIONAL_BUNDLES, bundlePatchFiles } from '../packages/boot/app-boot/src/profile.ts'
|
||||
import { collectProjectReferenceFaceViolations } from './project-reference-faces.ts'
|
||||
|
||||
@@ -255,24 +256,30 @@ function sameStringList(actual: readonly string[] | undefined, expected: readonl
|
||||
|
||||
/**
|
||||
* List the publication entries that locale `meta.detail` documents need: the top-level directory holding
|
||||
* each document and its images, or the document itself at the package root.
|
||||
* each document and its images.
|
||||
* @param packageDir - absolute package directory.
|
||||
* @returns sorted, deduplicated entries.
|
||||
* @throws Error when a declared document is not a package-relative Markdown file in a subdirectory or does not exist,
|
||||
* so a typo cannot publish a directory that holds no document.
|
||||
*/
|
||||
export function detailDocumentFiles(packageDir: string): readonly string[] {
|
||||
const entries = new Set<string>()
|
||||
for (const file of globSync('locale/*.json', { cwd: packageDir })) {
|
||||
let locale: { meta?: { detail?: unknown } }
|
||||
let meta: unknown
|
||||
try {
|
||||
locale = JSON.parse(readFileSync(join(packageDir, file), 'utf8')) as { meta?: { detail?: unknown } }
|
||||
meta = (JSON.parse(readFileSync(join(packageDir, file), 'utf8')) as { meta?: unknown } | null)?.meta
|
||||
} catch (error: unknown) {
|
||||
// SyntaxError: verify-package-meta reports malformed locale files; this check only adds publication entries.
|
||||
void error
|
||||
continue
|
||||
}
|
||||
const detail = locale.meta?.detail
|
||||
if (typeof detail !== 'string') continue
|
||||
entries.add(detail.includes('/') ? detail.slice(0, detail.indexOf('/')) : detail)
|
||||
const label = `${join(packageDir, file)}: meta.detail`
|
||||
const detail = detailPath(typeof meta === 'object' && meta !== null ? (meta as { detail?: unknown }).detail : undefined, label)
|
||||
if (detail === undefined) continue
|
||||
const slash = detail.indexOf('/')
|
||||
if (slash < 0) throw new Error(`${label} ${JSON.stringify(detail)} must name a file in a subdirectory such as docs/`)
|
||||
if (!existsSync(join(packageDir, detail))) throw new Error(`${label} ${JSON.stringify(detail)} does not exist`)
|
||||
entries.add(detail.slice(0, slash))
|
||||
}
|
||||
return [...entries].sort()
|
||||
}
|
||||
|
||||
@@ -395,6 +395,7 @@ describe('translation scope discovery', () => {
|
||||
|
||||
it.each([
|
||||
'packages/example/guide.md',
|
||||
'packages/example/docs/DETAIL.md',
|
||||
'packages/example/CONTRIBUTING.md',
|
||||
'packages/example/BRAND_GUIDELINES.md',
|
||||
'other/tutorial.md',
|
||||
|
||||
@@ -45,11 +45,12 @@ describe('Markdown source discovery', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('checks package instructions and nested docs while excluding frozen notes', () => {
|
||||
it('checks package instructions, nested docs, and detail documents while excluding frozen notes', () => {
|
||||
const root = layout({
|
||||
'packages/AGENTS.md': '[missing](missing.md)\n',
|
||||
'packages/core/README.md': '[missing](missing.md)\n',
|
||||
'packages/core/probe/README.md': '[missing](missing.md)\n',
|
||||
'packages/core/probe/docs/DETAIL.md': '\n',
|
||||
'.agents/notes/archived/process/frozen.md': '[historical](missing.md)\n',
|
||||
})
|
||||
const sources = markdownLinkSourcePaths(root)
|
||||
@@ -58,6 +59,7 @@ describe('Markdown source discovery', () => {
|
||||
join('packages', 'AGENTS.md'),
|
||||
join('packages', 'core', 'README.md'),
|
||||
join('packages', 'core', 'probe', 'README.md'),
|
||||
join('packages', 'core', 'probe', 'docs', 'DETAIL.md'),
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -24,6 +24,8 @@ const PATTERNS = [
|
||||
'packages/*.md',
|
||||
'packages/*/*.md',
|
||||
'packages/*/*/*.md',
|
||||
// Plugin detail documents that locale `meta.detail` names.
|
||||
'packages/*/*/docs/*.md',
|
||||
'AGENTS.md',
|
||||
'.agents/skills/**/*.md',
|
||||
]
|
||||
|
||||
@@ -22,6 +22,7 @@ const PATTERNS = [
|
||||
'docs/**/*.md',
|
||||
'packages/*/*.md',
|
||||
'packages/*/*/*.md',
|
||||
'packages/*/*/docs/*.md',
|
||||
'snapshots/**/system-prompt.expected.md',
|
||||
'packages/**/system-prompt.expected.md',
|
||||
'AGENTS.md',
|
||||
|
||||
Reference in new issue
Block a user