feat(plugin-market): define index order and optional popularity metadata
This commit is contained in:
1 parent
7be4511492
commit
d047e2c5f9
25 files changed
+463
-61
No files matched your search
@@ -15,11 +15,11 @@
|
||||
en: 1b1c960c966cf912
|
||||
zh: 97be87cb14893c26
|
||||
/agent-note-plugin-market-index-format/decision/root-fields:
|
||||
en: 4fac5a9b8261342d
|
||||
zh: f9168a8a4209feab
|
||||
en: 1723f6d4f6ff1b67
|
||||
zh: 02021e246a8f7d9f
|
||||
/agent-note-plugin-market-index-format/decision/plugin-fields:
|
||||
en: b79abd05a957948b
|
||||
zh: b6f0c203ea8e4838
|
||||
en: d083215d5f5f94b8
|
||||
zh: e0175c8869be6ff4
|
||||
/agent-note-plugin-market-index-format/decision/version-fields:
|
||||
en: d6c0fa99e1cbb068
|
||||
zh: 3d2a7b85299c679a
|
||||
@@ -33,8 +33,8 @@
|
||||
en: 923849223cb1e70e
|
||||
zh: f3daf398bc06c81a
|
||||
/agent-note-plugin-market-index-format/decision/reader-rules:
|
||||
en: 92903cbe87d9c0ca
|
||||
zh: 54af6cb33e9da252
|
||||
en: d41a2bd38b0e3830
|
||||
zh: 5cdf73d5bda7d596
|
||||
/agent-note-plugin-market-index-format/alternatives-considered:
|
||||
en: 908407ed974101d2
|
||||
zh: b4b7391c513bda60
|
||||
|
||||
@@ -104,10 +104,12 @@ The market reuses npm's package names, versions, ranges, distribution tags, and
|
||||
| `metadata.categories` | no | Object mapping category id → `{ title: LocalizedText }`. |
|
||||
| `metadata.labels` | no | Object mapping label id → `{ title: LocalizedText }`. |
|
||||
| `metadata.sortKeys` | no | Object mapping sort-key id → `{ title: LocalizedText, order: "asc" \| "desc" }`; `order` is the initial sort direction. |
|
||||
| `plugins` | yes | Array of plugins, possibly empty; its order is the index's default ordering. |
|
||||
| `plugins` | yes | Array of plugins, possibly empty; clients must use this array order for the initial unfiltered view. |
|
||||
|
||||
Category, label, and sort-key ids are non-empty strings. Readers look ids up as own properties, so ids such as `constructor` have no special meaning.
|
||||
|
||||
Clients must preserve the publisher's `plugins` array order unless the user selects another sort or text search uses relevance ranking. Filtering alone preserves the relative array order of matching plugins. Declaring sort keys or publishing metric values does not authorize clients to choose a default order by popularity, downloads, or publish time.
|
||||
|
||||
### Plugin fields
|
||||
|
||||
| Field | Required | Rule |
|
||||
@@ -123,12 +125,16 @@ Category, label, and sort-key ids are non-empty strings. Readers look ids up as
|
||||
| `metadata.category` | no | One declared category id. |
|
||||
| `metadata.labels` | no | Array of distinct declared label ids. |
|
||||
| `metadata.keywords` | no | Array of free-form strings; no root declaration required. |
|
||||
| `metadata.sortKeys` | no | Object mapping a declared sort-key id → finite number. |
|
||||
| `metadata.sortKeys` | no | Object mapping a declared sort-key id → finite number or `null` for unavailable data. |
|
||||
| `dist-tags` | no | Object mapping npm distribution-tag names to exact listed version strings; absent means no recorded channels. |
|
||||
| `versions` | yes | Non-empty array of versions; see below. Array order does not select the installation version. |
|
||||
|
||||
`metadata.keywords` conventionally comes from npm `package.json` `keywords`, but generators may use another source, add strings, or adjust values. Neither presence nor equality with the package manifest is required. Readers preserve the strings and their order; an empty array is valid.
|
||||
|
||||
A plugin's `metadata`, its `category`, `keywords`, `labels`, and `sortKeys` members, and individual declared sort-key values may be absent or `null` when unavailable. Readers normalize these to absent category, empty arrays/maps, and omitted numeric values. They never substitute zero; a measured zero is valid. String placeholders such as `"n/a"` are invalid.
|
||||
|
||||
Recommended standardized definitions for optional metadata keys are `github_stars` for a repository's GitHub star count and `npm_weekly_downloads` for the public npm package's download count over the window returned by npm's `last-week` endpoint. Both values are non-negative integer counts. Publishers using these keys declare them under root `metadata.sortKeys` with a localized title and their chosen `order`, and publish available values under each plugin's `metadata.sortKeys`. These definitions standardize names, meanings, value types, and measurement periods. Other declared finite numeric keys remain valid. Generators omit unavailable counts, including fetch failures, and never relabel an upstream monthly count as weekly downloads. The reference generator uses an npm package's author-declared repository for `github_stars` without verifying ownership or source correspondence; that count is not a trust or quality signal.
|
||||
|
||||
### Version fields
|
||||
|
||||
| Field | Required | Rule |
|
||||
@@ -175,7 +181,7 @@ A reader cannot verify these rules; a generator that violates them publishes a m
|
||||
|
||||
A reader rejects the whole index when the document violates any rule in Root fields, Plugin fields, Version fields, or the `source` forms, including an undeclared category, label, or sort-key reference, an unlisted distribution-tag target, a duplicate `name` or exact `version` string, or a `format` other than `1`. Each such defect is a generator bug, and dropping entries silently would hide it. Readers may bound the document size.
|
||||
|
||||
A reader ignores members that this note does not define, at every level; an unknown `source.type` value is rejected. Format 1 can then gain optional members without breaking existing readers; a change that existing readers would misinterpret uses a new `format` value.
|
||||
A reader ignores members that this note does not define, at every level; an unknown `source.type` value is rejected. Keys inside declaration maps, localized-text maps, and plugin sort-value maps are data and still follow their respective validation rules.
|
||||
|
||||
Tag names must parse as registry tag specs under npm's maintained `npm-package-arg`; targets are exact listed version strings. npm sources use canonical published identifiers (`node-semver.valid(version) === version`); Git sources retain build metadata and pin their exact source by commit.
|
||||
|
||||
|
||||
@@ -104,10 +104,12 @@ DSH 只能从用户已知的 spec 安装第三方组合包(bundle):npm 包
|
||||
| `metadata.categories` | 否 | 对象,将分类 id 映射为 `{ title: LocalizedText }`。 |
|
||||
| `metadata.labels` | 否 | 对象,将标签 id 映射为 `{ title: LocalizedText }`。 |
|
||||
| `metadata.sortKeys` | 否 | 对象,将排序键 id 映射为 `{ title: LocalizedText, order: "asc" \| "desc" }`;`order` 是初始排序方向。 |
|
||||
| `plugins` | 是 | 插件数组,可以为空;数组顺序即索引的默认排序。 |
|
||||
| `plugins` | 是 | 插件数组,可以为空;客户端必须在初始的未筛选视图中使用此数组顺序。 |
|
||||
|
||||
分类、标签和排序键的 id 均为非空字符串。读取器按自有属性查找 id,因此 `constructor` 等 id 没有特殊含义。
|
||||
|
||||
客户端必须保留发布者的 `plugins` 数组顺序,除非用户选择其他排序方式,或文本搜索按相关性排序。仅进行筛选时,匹配的插件仍保持它们在数组中的相对顺序。声明排序键或发布指标值并不允许客户端自行选择按热度、下载量或发布时间进行默认排序。
|
||||
|
||||
### 插件字段
|
||||
|
||||
| 字段 | 必填 | 规则 |
|
||||
@@ -123,12 +125,16 @@ DSH 只能从用户已知的 spec 安装第三方组合包(bundle):npm 包
|
||||
| `metadata.category` | 否 | 一个已声明的分类 id。 |
|
||||
| `metadata.labels` | 否 | 由互不相同的已声明标签 id 组成的数组。 |
|
||||
| `metadata.keywords` | 否 | 自由字符串数组;无需在根部声明。 |
|
||||
| `metadata.sortKeys` | 否 | 对象,将已声明的排序键 id 映射为有限数值。 |
|
||||
| `metadata.sortKeys` | 否 | 对象,将已声明的排序键 id 映射为有限数值,或以 `null` 表示数据不可用。 |
|
||||
| `dist-tags` | 否 | 将 npm 发布标签名称映射为已列出的精确版本字符串的对象;缺省表示未记录发布渠道。 |
|
||||
| `versions` | 是 | 非空的版本数组;见下文。数组顺序不选择安装版本。 |
|
||||
|
||||
`metadata.keywords` 按约定来自 npm `package.json` 的 `keywords`,但生成器可以使用其他来源、添加字符串或调整值。不要求提供此字段,也不要求它与包 manifest 一致。读取器保留字符串及其顺序;允许空数组。
|
||||
|
||||
插件的 `metadata`、其中的 `category`、`keywords`、`labels` 和 `sortKeys` 成员,以及单个已声明排序键的值,在不可用时均可缺省或为 `null`。读取器将它们归一化为缺省的分类、空数组或空映射,并省略对应数值。读取器绝不以零代替缺失值;实际测得的零是有效值。`"n/a"` 等字符串占位值无效。
|
||||
|
||||
建议对可选元数据键采用以下统一定义:`github_stars` 表示仓库的 GitHub star 数,`npm_weekly_downloads` 表示公共 npm 包在 npm `last-week` 端点返回的时间窗口内的下载量。两者均为非负整数计数。发布者使用这些键时,在根级 `metadata.sortKeys` 中声明本地化标题和自行选择的 `order`,并在各插件的 `metadata.sortKeys` 中发布可用值。这些定义统一名称、含义、值类型和统计周期。其他已声明的有限数值键仍然有效。生成器省略不可用的计数,包括获取失败的计数,绝不把上游月下载量标为周下载量。参考生成器使用 npm 包作者声明的仓库获取 `github_stars`,不验证仓库归属或与包源码的对应关系;该计数不代表可信度或质量。
|
||||
|
||||
### 版本字段
|
||||
|
||||
| 字段 | 必填 | 规则 |
|
||||
@@ -175,7 +181,7 @@ DSH 只能从用户已知的 spec 安装第三方组合包(bundle):npm 包
|
||||
|
||||
文档违反根字段、插件字段、版本字段或 `source` 形式中的任何规则时,读取器拒绝整个索引,包括引用了未声明的分类、标签或排序键,发布标签指向未列出的目标,`name` 或精确的 `version` 字符串重复,以及 `format` 不是 `1`。每个此类缺陷都是生成器 bug,静默丢弃条目会掩盖它。读取器可以限制文档大小。
|
||||
|
||||
读取器在每一层都忽略本 Agent Note 未定义的成员;未知的 `source.type` 值会被拒绝。这样格式 1 可以增加可选成员而不破坏现有读取器;现有读取器会误解的变更使用新的 `format` 值。
|
||||
读取器在每一层都忽略本 Agent Note 未定义的成员;未知的 `source.type` 值会被拒绝。声明映射、本地化文本映射和插件排序值映射中的键是数据,仍遵循各自的校验规则。
|
||||
|
||||
标签名称必须能被 npm 维护的 `npm-package-arg` 解析为 registry 标签 spec;目标是已列出的精确版本字符串。npm 来源使用规范发布标识(`node-semver.valid(version) === version`);Git 来源保留版本编译信息并用提交固定精确来源。
|
||||
|
||||
|
||||
@@ -6,17 +6,17 @@
|
||||
en: 531349889ccc3124
|
||||
zh: 08769a2d9fc586fd
|
||||
/plugin-market-index-generator/usage:
|
||||
en: ed2a83d06f6789ef
|
||||
zh: e5c1e26486dfefb0
|
||||
en: b354d10e2463e99e
|
||||
zh: 1f83a96f164864fc
|
||||
/plugin-market-index-generator/what-gets-listed:
|
||||
en: 4a01101246724006
|
||||
zh: 8455f6bc4552b787
|
||||
en: bcf7ee834276a804
|
||||
zh: d6723321c0baeef7
|
||||
/plugin-market-index-generator/caching-and-repeated-runs:
|
||||
en: a62c9bf12310c33c
|
||||
zh: 60aa01288f46133c
|
||||
en: 02a87e53b76a017c
|
||||
zh: 951a71238a073c44
|
||||
/plugin-market-index-generator/security:
|
||||
en: de5821128b676a61
|
||||
zh: aad295eeb80cad7e
|
||||
/plugin-market-index-generator/limits:
|
||||
en: b5e02017e961e818
|
||||
zh: 0f0aaa26d519acb1
|
||||
en: 08a91f2f2fdd71a6
|
||||
zh: 0641a53608eb342a
|
||||
@@ -39,7 +39,9 @@ pnpm --filter @deepseek-ai/dsh-plugin-market-generator start \
|
||||
| `--timeout-ms <n>` | `60000` | Deadline for one request or Git command. |
|
||||
| `-h`, `--help` | | Print these options and the exit statuses. |
|
||||
|
||||
The exit status is 0 when every source was read, 1 when a source could not be read, and 2 for invalid arguments or an invalid list. A list line that the index format would reject, such as an `http:` URL, a URL with credentials, a query, or a fragment, a URL with fewer than two path segments, or a tarball URL, is an invalid list. A source that cannot be read keeps its last successful result, so a temporary outage does not remove plugins from the index; the failure is still reported.
|
||||
Set `GITHUB_TOKEN` to collect `github_stars`. Surrounding whitespace is trimmed; an unset or blank token skips GitHub metric requests and omits that metric. The token is used only in the GitHub API Authorization header and is not written to the index, cache, or diagnostic messages. npm weekly downloads do not require it.
|
||||
|
||||
The exit status is 0 when every source was read, 1 when a source could not be read, and 2 for invalid arguments or an invalid list. A list line that the index format would reject, such as an `http:` URL, a URL with credentials, a query, or a fragment, a URL with fewer than two path segments, or a tarball URL, is an invalid list. A source that cannot be read keeps its last successful result, so a temporary outage does not remove plugins from the index; the failure is still reported. Metric failures warn and omit the unavailable values, including counts present in an earlier output. They leave exit status 0 only when every source was read successfully; offline source fallback still returns 1.
|
||||
|
||||
## What gets listed
|
||||
|
||||
@@ -59,11 +61,13 @@ Display fields come from the highest listed version and follow the rules DSH use
|
||||
|
||||
`metadata.keywords` is an optional array of strings for discovery. npm `package.json` `keywords` is the convention; index generators may supplement or adjust it. This reference generator copies the highest listed version's manifest `keywords` unchanged, including empty strings, duplicates, and an empty array. An absent field is omitted; a non-array value or an array containing any non-string produces a warning and omits the whole field without dropping the package.
|
||||
|
||||
The generator reads optional `github_stars` from the GitHub repository API and `npm_weekly_downloads` from npm's `downloads/point/last-week` endpoint. GitHub counts use the actual Git source repository for Git packages and the author-declared manifest repository for npm packages. npm repository ownership and source correspondence are not verified, so stars are not a trust or quality signal. npm counts are requested only for public npm sources, never custom registries or Git-only packages. Weekly means the window returned by npm's `last-week`, not an upstream 30-day total. Available counts, including zero, appear in plugin `metadata.sortKeys`; root `metadata.sortKeys` declares only metrics available for at least one plugin, with descending initial order, and root `metadata` is omitted when no metric is available. Fetch failures and invalid counts produce warnings and omit the metric without dropping the plugin or failing the run.
|
||||
|
||||
When several sources produce the same package name, the first one in the list wins.
|
||||
|
||||
## Caching and repeated runs
|
||||
|
||||
Each run reads the registry document of every npm source and the refs of every Git repository. Results are cached per immutable input: an npm plugin per registry, icon threshold, package, and set of listed versions; a Git repository per default branch commit and release tags; and each Git package per commit and directory. A Git run therefore fetches only the commits whose packages are not cached. Cache files are written atomically, and a cache file that is damaged or that the index format rejects is rebuilt. Output is deterministic: entries follow the list order, and the index and icon files are rewritten only when their contents change, so running the generator again without upstream changes leaves the output untouched.
|
||||
Each run reads the registry document of every npm source and the refs of every Git repository. Results are cached per immutable input: an npm plugin per registry, icon threshold, package, and set of listed versions; a Git repository per default branch commit and release tags; and each Git package per commit and directory. A Git run therefore fetches only the commits whose packages are not cached. Cache files are written atomically, and a cache file that is damaged or that the index format rejects is rebuilt. Enabled metrics are refreshed on every run outside these caches, with shared requests per repository or package and the configured concurrency and timeout limits. Metric failures never reuse stale counts. For unchanged package data and metric responses, output is deterministic: entries follow the list order, and the index and icon files are rewritten only when their contents change, so running the generator again without upstream changes leaves the output untouched.
|
||||
|
||||
## Security
|
||||
|
||||
@@ -73,6 +77,8 @@ Index content is what the packages claim about themselves. Review the list you p
|
||||
|
||||
## Limits
|
||||
|
||||
- GitHub token permissions and API rate limits apply; refused or rate-limited requests warn and omit the affected counts.
|
||||
- Cached package data does not make a run offline: source documents/refs and enabled metrics are requested on each run. Unreachable metric APIs remove those counts from the output, without reusing stale values; source failures follow the exit-status rules above.
|
||||
- A Git server must allow fetching a commit by its SHA, as GitHub, GitLab, and Gitea do. When a server refuses, the generator fetches the tags and the default branch by name, which fails if a ref moved after it was listed.
|
||||
- Registry tokens from `.npmrc` are not applied; only public or unauthenticated registries work.
|
||||
- Git never prompts for credentials. A repository that needs them must be readable through a credential helper or an ssh agent.
|
||||
@@ -39,7 +39,9 @@ pnpm --filter @deepseek-ai/dsh-plugin-market-generator start \
|
||||
| `--timeout-ms <n>` | `60000` | 单个请求或 Git 命令的时限。 |
|
||||
| `-h`、`--help` | | 打印这些选项和退出状态。 |
|
||||
|
||||
所有来源都读取成功时退出状态为 0,有来源无法读取时为 1,参数无效或列表无效时为 2。索引格式会拒绝的列表行属于无效列表,例如 `http:` URL、带凭据、查询或片段的 URL、路径段少于两个的 URL,以及 tarball URL。无法读取的来源保留其上一次成功的结果,因此临时故障不会把插件从索引中移除;生成器仍会报告该失败。
|
||||
设置 `GITHUB_TOKEN` 以采集 `github_stars`。生成器会去掉令牌首尾的空白;令牌未设置或为空白时,会跳过 GitHub 指标请求并省略该指标。令牌仅用于 GitHub API 的 Authorization 请求头,不会写入索引、缓存或诊断消息。npm 周下载量不需要该令牌。
|
||||
|
||||
所有来源都读取成功时退出状态为 0,有来源无法读取时为 1,参数无效或列表无效时为 2。索引格式会拒绝的列表行属于无效列表,例如 `http:` URL、带凭据、查询或片段的 URL、路径段少于两个的 URL,以及 tarball URL。无法读取的来源保留其上一次成功的结果,因此临时故障不会把插件从索引中移除;生成器仍会报告该失败。指标获取失败时会发出警告并省略不可用的值,包括此前输出中已有的计数。只有所有来源都读取成功时,指标获取失败才仍返回退出状态 0;离线时回退到缓存的来源结果仍返回 1。
|
||||
|
||||
## 列出的内容
|
||||
|
||||
@@ -59,11 +61,13 @@ pnpm --filter @deepseek-ai/dsh-plugin-market-generator start \
|
||||
|
||||
`metadata.keywords` 是用于发现插件的可选字符串数组。按约定使用 npm `package.json` 的 `keywords`;索引生成器可以补充或调整它。本参考生成器原样复制最高列出版本 manifest 的 `keywords`,包括空字符串、重复值和空数组。字段缺省时省略;如果值不是数组,或数组包含任何非字符串成员,则发出警告并省略整个字段,但保留该包。
|
||||
|
||||
生成器从 GitHub 仓库 API 读取可选的 `github_stars`,从 npm 的 `downloads/point/last-week` 端点读取可选的 `npm_weekly_downloads`。Git 包的 GitHub 计数取自实际 Git 来源仓库,npm 包则取自作者在 manifest 中声明的仓库。生成器不验证 npm 包声明的仓库归属或与包源码的对应关系,因此 star 数不代表可信度或质量。npm 计数仅对公共 npm 来源发起请求,不对自定义注册表或仅有 Git 来源的包发起请求。周下载量指 npm 的 `last-week` 返回的时间窗口,不是上游的 30 天总量。可用的计数(包括零)写入插件的 `metadata.sortKeys`;根级 `metadata.sortKeys` 仅声明至少有一个插件提供值的指标,初始顺序为降序;没有可用指标时,省略根级 `metadata`。获取失败和无效计数会产生警告并省略该指标,不会丢弃插件或使运行失败。
|
||||
|
||||
多个来源产生相同包名时,列表中靠前的来源生效。
|
||||
|
||||
## 缓存与重复运行
|
||||
|
||||
每次运行时,生成器读取每个 npm 来源的注册表文档和每个 Git 仓库的引用(ref)。结果按不可变的输入缓存:npm 插件按注册表、图标阈值、包和列出版本的集合缓存;Git 仓库按默认分支提交和发布标签缓存;每个 Git 包按提交和目录缓存。因此 Git 运行只拉取其包尚未缓存的提交。生成器以原子方式写入缓存文件,并重建损坏的或索引格式会拒绝的缓存文件。输出是确定性的:条目按列表顺序排列,索引文件和图标文件只在内容变化时才重写,因此上游没有变化时再次运行生成器不会改动输出。
|
||||
每次运行时,生成器读取每个 npm 来源的注册表文档和每个 Git 仓库的引用(ref)。结果按不可变的输入缓存:npm 插件按注册表、图标阈值、包和列出版本的集合缓存;Git 仓库按默认分支提交和发布标签缓存;每个 Git 包按提交和目录缓存。因此 Git 运行只拉取其包尚未缓存的提交。生成器以原子方式写入缓存文件,并重建损坏的或索引格式会拒绝的缓存文件。已启用的指标在这些缓存之外于每次运行时刷新,同一仓库或包共享请求,并遵循配置的并发和超时限制。指标获取失败时绝不复用陈旧计数。包数据和指标响应不变时,输出是确定性的:条目按列表顺序排列,索引文件和图标文件只在内容变化时才重写,因此上游没有变化时再次运行生成器不会改动输出。
|
||||
|
||||
## 安全
|
||||
|
||||
@@ -73,6 +77,8 @@ pnpm --filter @deepseek-ai/dsh-plugin-market-generator start \
|
||||
|
||||
## 限制
|
||||
|
||||
- 请求受 GitHub 令牌权限和 API 速率限制约束;请求被拒绝或受到速率限制时,会发出警告并省略受影响的计数。
|
||||
- 已缓存包数据不表示运行可以离线完成:每次运行仍会请求来源文档或引用以及已启用的指标。指标 API 无法访问时,输出会移除这些计数,不复用陈旧值;来源获取失败遵循上述退出状态规则。
|
||||
- Git 服务器必须允许按 SHA 拉取提交,GitHub、GitLab 和 Gitea 都允许。服务器拒绝时,生成器按名称拉取标签和默认分支;如果某个引用在列出之后移动了,这一步会失败。
|
||||
- 生成器不使用 `.npmrc` 中的注册表令牌;只支持公开或无需认证的注册表。
|
||||
- Git 从不提示输入凭据。需要凭据的仓库必须能通过凭据助手或 ssh agent 读取。
|
||||
@@ -15,10 +15,9 @@ import { MAX_INLINE_ICON_BYTES } from '@deepseek-ai/dsh-plugin-market-index'
|
||||
import { writeIfChanged } from './files.ts'
|
||||
import { generate } from './generate.ts'
|
||||
import { GENERATED_ICON } from './metadata.ts'
|
||||
import { PUBLIC_NPM_REGISTRY } from './fetchers.ts'
|
||||
import { parseSources } from './sources.ts'
|
||||
|
||||
const PUBLIC_REGISTRY = 'https://registry.npmjs.org/'
|
||||
|
||||
/** Parsed command-line options. */
|
||||
interface Options {
|
||||
readonly input: string
|
||||
@@ -51,7 +50,7 @@ function program(): Command {
|
||||
.requiredOption('--title <text>', 'market title')
|
||||
.option('--description <text>', 'market description')
|
||||
.option('--cache <dir>', 'cache directory (default: .plugin-market-cache beside the input list)')
|
||||
.option('--registry <url>', 'npm registry', PUBLIC_REGISTRY)
|
||||
.option('--registry <url>', 'npm registry', PUBLIC_NPM_REGISTRY)
|
||||
.addOption(new Option('--inline-icon-bytes <n>', `largest icon embedded as a data URL, at most ${String(MAX_INLINE_ICON_BYTES)}`)
|
||||
.argParser(integer(MAX_INLINE_ICON_BYTES)).default(16384))
|
||||
.addOption(new Option('--concurrency <n>', 'sources read at once').argParser(integer()).default(4))
|
||||
@@ -68,11 +67,13 @@ async function run(options: Options): Promise<number> {
|
||||
const input = resolve(base, options.input)
|
||||
const output = resolve(base, options.output)
|
||||
const registry = new URL(options.registry).href.replace(/\/?$/u, '/')
|
||||
const githubToken = process.env.GITHUB_TOKEN?.trim() || undefined
|
||||
const result = await generate({
|
||||
sources: parseSources(readFileSync(input, 'utf8')),
|
||||
cacheDir: options.cache === undefined ? join(dirname(input), '.plugin-market-cache') : resolve(base, options.cache),
|
||||
registry,
|
||||
recordRegistry: registry !== PUBLIC_REGISTRY,
|
||||
recordRegistry: registry !== PUBLIC_NPM_REGISTRY,
|
||||
...githubToken === undefined ? {} : { githubToken },
|
||||
title: options.title,
|
||||
...options.description === undefined ? {} : { description: options.description },
|
||||
inlineIconBytes: options.inlineIconBytes,
|
||||
|
||||
@@ -16,6 +16,9 @@ import { readManifest, type Manifest } from './packages.ts'
|
||||
|
||||
const run = promisify(execFile)
|
||||
|
||||
/** Public npm registry whose package identity permits npm download statistics. */
|
||||
export const PUBLIC_NPM_REGISTRY = 'https://registry.npmjs.org/'
|
||||
|
||||
/** Network limits for one source. */
|
||||
export interface FetchLimits {
|
||||
readonly timeoutMs: number
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
/** Turn a source list into a validated format-1 index, reusing cached results for unchanged releases and commits. */
|
||||
import { existsSync } from 'node:fs'
|
||||
import { join, posix } from 'node:path'
|
||||
import { parseMarketIndex } from '@deepseek-ai/dsh-plugin-market-index'
|
||||
import { parseMarketIndex, type MarketSortKeyDeclaration } from '@deepseek-ai/dsh-plugin-market-index'
|
||||
import semver from 'semver'
|
||||
import { cacheKey, gitRefs, npmPackument, npmTarball, withGitObjects, type FetchLimits, type GitObjects, type GitTransports } from './fetchers.ts'
|
||||
import { readJsonFile, writeIfChanged, type PackageFiles } from './files.ts'
|
||||
import { GENERATED_ICON, readDisplayMetadata, type Display, type IconFile } from './metadata.ts'
|
||||
import { dshRange, findBundlePackages, isBundle, isPrivate, readManifest, type Manifest } from './packages.ts'
|
||||
import { METRIC_DECLARATIONS, metricReader } from './metrics.ts'
|
||||
import type { Source } from './sources.ts'
|
||||
|
||||
/** One generated version, in the field order of the format. */
|
||||
@@ -18,8 +19,10 @@ export interface GeneratedVersion {
|
||||
}
|
||||
|
||||
/** One generated plugin, in the field order of the format. */
|
||||
export interface GeneratedEntry extends Display {
|
||||
export interface GeneratedEntry extends Omit<Display, 'metadata'> {
|
||||
readonly name: string
|
||||
/** Manifest keywords and optional statistics added after reading source caches. */
|
||||
readonly metadata?: { readonly keywords?: readonly string[]; readonly sortKeys?: Readonly<Record<string, number>> }
|
||||
readonly versions: readonly GeneratedVersion[]
|
||||
}
|
||||
|
||||
@@ -31,6 +34,8 @@ export interface GenerateOptions {
|
||||
readonly registry: string
|
||||
/** Whether versions name `registry`; false for the public registry, which clients use by default. */
|
||||
readonly recordRegistry: boolean
|
||||
/** Nonblank token used only for GitHub API metrics; absent disables GitHub star collection. */
|
||||
readonly githubToken?: string
|
||||
readonly title: string
|
||||
readonly description?: string
|
||||
readonly inlineIconBytes: number
|
||||
@@ -43,7 +48,13 @@ export interface GenerateOptions {
|
||||
|
||||
/** A finished run: the index document, the icon files it refers to, and the sources that could not be read. */
|
||||
export interface GenerateResult {
|
||||
readonly index: { readonly format: 1; readonly title: string; readonly description?: string; readonly plugins: readonly GeneratedEntry[] }
|
||||
readonly index: {
|
||||
readonly format: 1
|
||||
readonly title: string
|
||||
readonly description?: string
|
||||
readonly metadata?: { readonly sortKeys: Readonly<Record<string, MarketSortKeyDeclaration>> }
|
||||
readonly plugins: readonly GeneratedEntry[]
|
||||
}
|
||||
/** Icon files by index-relative path; contents live under the cache. */
|
||||
readonly icons: ReadonlyMap<string, string>
|
||||
readonly failures: readonly string[]
|
||||
@@ -350,11 +361,16 @@ export async function generate(options: GenerateOptions): Promise<GenerateResult
|
||||
plugins.push(entry)
|
||||
if (entry.icon?.startsWith('icons/') === true) icons.set(entry.icon, join(options.cacheDir, entry.icon))
|
||||
}
|
||||
const measured = await mapLimited(plugins, options.concurrency,
|
||||
metricReader(options.limits, options.registry, options.warn, options.githubToken))
|
||||
const sortKeys = Object.fromEntries(Object.entries(METRIC_DECLARATIONS)
|
||||
.filter(([id]) => measured.some(entry => Object.hasOwn(entry.metadata?.sortKeys ?? {}, id))))
|
||||
const index = {
|
||||
format: 1 as const,
|
||||
title: options.title,
|
||||
...options.description === undefined ? {} : { description: options.description },
|
||||
plugins,
|
||||
...Object.keys(sortKeys).length === 0 ? {} : { metadata: { sortKeys } },
|
||||
plugins: measured,
|
||||
}
|
||||
parseMarketIndex(index, VALIDATION_URL)
|
||||
return { index, icons, failures: failed.filter(message => message !== undefined) }
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
/** Fetch optional popularity counts separately from immutable package metadata. */
|
||||
import type { MarketSortKeyDeclaration } from '@deepseek-ai/dsh-plugin-market-index'
|
||||
import { PUBLIC_NPM_REGISTRY, type FetchLimits } from './fetchers.ts'
|
||||
import type { GeneratedEntry } from './generate.ts'
|
||||
|
||||
/** Declarations for the standardized metric definitions. */
|
||||
export const METRIC_DECLARATIONS = {
|
||||
github_stars: { title: { en: 'GitHub stars', zh: 'GitHub 星标数' }, order: 'desc' },
|
||||
npm_weekly_downloads: { title: { en: 'npm weekly downloads', zh: 'npm 周下载量' }, order: 'desc' },
|
||||
} satisfies Record<string, MarketSortKeyDeclaration>
|
||||
|
||||
/**
|
||||
* Create a per-run reader that shares requests for the same repository or package.
|
||||
* @param limits - deadline for each metric request.
|
||||
* @param registry - the registry used to discover npm packages; only public npm supplies downloads.
|
||||
* @param warn - reports unavailable metrics without failing package discovery.
|
||||
* @param githubToken - nonblank GitHub API token; absent skips GitHub star requests.
|
||||
* @returns an entry reader; failed or invalid counts are omitted, including on repeated runs.
|
||||
*/
|
||||
export function metricReader(
|
||||
limits: FetchLimits, registry: string, warn: (message: string) => void, githubToken?: string,
|
||||
): (entry: GeneratedEntry) => Promise<GeneratedEntry> {
|
||||
const requests = new Map<string, Promise<number | undefined>>()
|
||||
async function count(url: string, field: string): Promise<number | undefined> {
|
||||
const authenticated = githubToken !== undefined && new URL(url).origin === 'https://api.github.com'
|
||||
let response: Response | undefined
|
||||
let value: unknown
|
||||
try {
|
||||
response = await fetch(url, {
|
||||
signal: AbortSignal.timeout(limits.timeoutMs),
|
||||
...authenticated ? { headers: { Authorization: `Bearer ${githubToken}` } } : {},
|
||||
})
|
||||
if (!response.ok) {
|
||||
await response.body?.cancel()
|
||||
warn(`${url}: HTTP ${response.status}; metric omitted`)
|
||||
return undefined
|
||||
}
|
||||
value = await response.json()
|
||||
} catch (error) {
|
||||
const reason = authenticated ? 'GitHub metric request failed' : String(error)
|
||||
warn(`${url}: ${response === undefined ? '' : `HTTP ${response.status}; `}${reason}; metric omitted`)
|
||||
return undefined
|
||||
}
|
||||
const result = typeof value === 'object' && value !== null && field in value ? (value as Record<string, unknown>)[field] : undefined
|
||||
if (typeof result === 'number' && Number.isSafeInteger(result) && result >= 0) return result
|
||||
warn(`${url}: ${field} must be a non-negative safe integer; metric omitted`)
|
||||
return undefined
|
||||
}
|
||||
function shared(url: string, field: string): Promise<number | undefined> {
|
||||
let request = requests.get(url)
|
||||
if (request === undefined) {
|
||||
request = count(url, field)
|
||||
requests.set(url, request)
|
||||
}
|
||||
return request
|
||||
}
|
||||
return async (entry) => {
|
||||
const sortKeys: Record<string, number> = {}
|
||||
const source = entry.versions[0]?.source
|
||||
const repositoryUrl = source?.type === 'git' ? source.url : entry.repository?.url
|
||||
const repository = repositoryUrl === undefined ? undefined : new URL(repositoryUrl)
|
||||
const match = repository?.hostname === 'github.com' ? /^\/([^/]+)\/([^/]+?)\/?$/u.exec(repository.pathname) : null
|
||||
if (githubToken !== undefined && match !== null) {
|
||||
const stars = await shared(`https://api.github.com/repos/${match[1]}/${match[2]?.replace(/\.git$/u, '')}`, 'stargazers_count')
|
||||
if (stars !== undefined) sortKeys.github_stars = stars
|
||||
}
|
||||
// Private registries can publish unrelated packages under a public npm name.
|
||||
if (registry === PUBLIC_NPM_REGISTRY && entry.versions.some(({ source }) => source.type === 'npm')) {
|
||||
const downloads = await shared(`https://api.npmjs.org/downloads/point/last-week/${encodeURIComponent(entry.name)}`, 'downloads')
|
||||
if (downloads !== undefined) sortKeys.npm_weekly_downloads = downloads
|
||||
}
|
||||
return Object.keys(sortKeys).length === 0 ? entry : { ...entry, metadata: { ...entry.metadata, sortKeys } }
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,16 @@
|
||||
/** Command-line parsing and help of the generator. */
|
||||
import { readFileSync, writeFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import { main } from '../src/cli.ts'
|
||||
import { generate } from '../src/generate.ts'
|
||||
import { scratch } from './support.ts'
|
||||
|
||||
afterEach(() => { vi.restoreAllMocks() })
|
||||
vi.mock('../src/generate.ts', () => ({
|
||||
generate: vi.fn(async () => ({ index: { format: 1, title: 'T', plugins: [] }, icons: new Map(), failures: [] })),
|
||||
}))
|
||||
|
||||
afterEach(() => { vi.restoreAllMocks(); vi.unstubAllEnvs() })
|
||||
|
||||
/** Run the CLI and collect what it writes to stdout and stderr. */
|
||||
async function cli(...argv: string[]) {
|
||||
@@ -16,6 +24,24 @@ async function cli(...argv: string[]) {
|
||||
|
||||
const REQUIRED = ['--input', '/nonexistent/list.txt', '--output', '/nonexistent/index.json', '--title', 'T']
|
||||
|
||||
it.each([
|
||||
[undefined, undefined], ['', undefined], [' \t ', undefined], [' test-only-cli-token ', 'test-only-cli-token'],
|
||||
])('resolves the GitHub token from the environment (%#)', async (authored, expected) => {
|
||||
vi.stubEnv('GITHUB_TOKEN', authored)
|
||||
vi.stubEnv('GH_TOKEN', 'ignored-test-token')
|
||||
vi.mocked(generate).mockClear()
|
||||
const dir = scratch()
|
||||
const input = join(dir, 'plugins.txt')
|
||||
const output = join(dir, 'index.json')
|
||||
writeFileSync(input, 'dsh-example\n')
|
||||
expect((await cli('--input', input, '--output', output, '--title', 'T')).status).toBe(0)
|
||||
expect(generate).toHaveBeenCalledOnce()
|
||||
const options = vi.mocked(generate).mock.calls[0]?.[0]
|
||||
expect(options?.githubToken).toBe(expected)
|
||||
if (expected === undefined) expect(options).not.toHaveProperty('githubToken')
|
||||
expect(readFileSync(output, 'utf8')).not.toContain('test-only-cli-token')
|
||||
})
|
||||
|
||||
it('prints help with every option, its default, and the exit statuses', async () => {
|
||||
const { status, out } = await cli('--help')
|
||||
expect(status).toBe(0)
|
||||
|
||||
@@ -3,11 +3,17 @@ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from
|
||||
import { createServer } from 'node:http'
|
||||
import { join } from 'node:path'
|
||||
import { c as createTar } from 'tar'
|
||||
import { expect, it } from 'vitest'
|
||||
import { expect, it, vi } from 'vitest'
|
||||
import { main } from '../src/cli.ts'
|
||||
import { generate, type GenerateOptions } from '../src/generate.ts'
|
||||
import { metricReader } from '../src/metrics.ts'
|
||||
import { onCleanup, scratch } from './support.ts'
|
||||
|
||||
vi.mock('../src/metrics.ts', async original => ({
|
||||
...await original<typeof import('../src/metrics.ts')>(),
|
||||
metricReader: vi.fn(() => async (entry: import('../src/generate.ts').GeneratedEntry) => entry),
|
||||
}))
|
||||
|
||||
/** Pack a package directory the way npm does: every file under `package/`. */
|
||||
async function pack(files: Record<string, string | Uint8Array>): Promise<Buffer> {
|
||||
const dir = scratch()
|
||||
@@ -259,3 +265,57 @@ it('writes the index and icons once, keeps the cache beside the input, and remov
|
||||
writeFileSync(list, 'http://example.com/o/r\n')
|
||||
expect(await main(['--input', list, '--output', other, '--title', 'T'])).toBe(2)
|
||||
})
|
||||
|
||||
it('refreshes optional metrics outside source caches and keeps plugins on metric outages', async () => {
|
||||
const server = await registry({ '@scope/dsh-a': { '1.0.0': {
|
||||
time: '2026-06-01T00:00:00.000Z', manifest: BUNDLE,
|
||||
tarball: await pack({ 'package.json': bundleManifest('@scope/dsh-a', '1.0.0', { repository: 'github:team/plugins', keywords: ['tools'] }) }),
|
||||
} } })
|
||||
const actual = await vi.importActual<typeof import('../src/metrics.ts')>('../src/metrics.ts')
|
||||
const network = globalThis.fetch
|
||||
let unavailable = false
|
||||
const metricUrls: string[] = []
|
||||
const authorization: [string, string | null][] = []
|
||||
const fetch = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => {
|
||||
const url = input instanceof Request ? input.url : input.toString()
|
||||
authorization.push([url, new Headers(init?.headers).get('authorization')])
|
||||
if (url.startsWith('https://registry.npmjs.org/')) return network(url.replace('https://registry.npmjs.org/', server.url), init)
|
||||
if (!url.startsWith('https://api.')) return network(input, init)
|
||||
metricUrls.push(url)
|
||||
if (unavailable) return new Response('', { status: 503 })
|
||||
return Response.json(url.includes('github.com') ? { stargazers_count: 0 } : { downloads: 42 })
|
||||
})
|
||||
onCleanup(() => { fetch.mockRestore() })
|
||||
const warnings: string[] = []
|
||||
const token = 'test-only-integration-token'
|
||||
const opts = { ...options(server.url, scratch(), ['@scope/dsh-a'], warnings), registry: 'https://registry.npmjs.org/', recordRegistry: false, githubToken: token }
|
||||
vi.mocked(metricReader).mockImplementationOnce(actual.metricReader)
|
||||
const first = await generate(opts)
|
||||
expect(first.failures).toEqual([])
|
||||
expect(first.index.metadata?.sortKeys).toEqual(actual.METRIC_DECLARATIONS)
|
||||
expect(first.index.plugins[0]?.metadata).toEqual({ keywords: ['tools'], sortKeys: { github_stars: 0, npm_weekly_downloads: 42 } })
|
||||
expect(metricUrls).toContain('https://api.npmjs.org/downloads/point/last-week/%40scope%2Fdsh-a')
|
||||
vi.mocked(metricReader).mockImplementationOnce(actual.metricReader)
|
||||
const { githubToken: _token, ...withoutToken } = opts
|
||||
const disabled = await generate(withoutToken)
|
||||
expect(disabled.index.plugins[0]?.metadata).toEqual({ keywords: ['tools'], sortKeys: { npm_weekly_downloads: 42 } })
|
||||
expect(disabled.index.metadata?.sortKeys).toEqual({ npm_weekly_downloads: actual.METRIC_DECLARATIONS.npm_weekly_downloads })
|
||||
expect(metricUrls.filter(url => url.startsWith('https://api.github.com/'))).toHaveLength(1)
|
||||
unavailable = true
|
||||
vi.mocked(metricReader).mockImplementationOnce(actual.metricReader)
|
||||
const second = await generate(opts)
|
||||
expect(second.failures).toEqual([])
|
||||
expect(second.index.plugins[0]?.metadata).toEqual({ keywords: ['tools'] })
|
||||
expect(second.index).not.toHaveProperty('metadata')
|
||||
expect(server.tarballs).toHaveLength(1)
|
||||
expect(warnings.filter(message => message.includes('metric omitted'))).toHaveLength(2)
|
||||
for (const [url, header] of authorization) {
|
||||
expect(header).toBe(url.startsWith('https://api.github.com/') ? `Bearer ${token}` : null)
|
||||
}
|
||||
expect(JSON.stringify([first, disabled, second, warnings])).not.toContain(token)
|
||||
for (const directory of ['last', 'results']) {
|
||||
for (const name of readdirSync(join(opts.cacheDir, directory))) {
|
||||
expect(readFileSync(join(opts.cacheDir, directory, name), 'utf8')).not.toContain(token)
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -2,10 +2,15 @@ import { execFileSync } from 'node:child_process'
|
||||
import { existsSync, mkdirSync, readdirSync, symlinkSync, writeFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { expect, it } from 'vitest'
|
||||
import { expect, it, vi } from 'vitest'
|
||||
import { generate, type GenerateOptions } from '../src/generate.ts'
|
||||
import { scratch, setEnv } from './support.ts'
|
||||
|
||||
vi.mock('../src/metrics.ts', async original => ({
|
||||
...await original<typeof import('../src/metrics.ts')>(),
|
||||
metricReader: () => async (entry: import('../src/generate.ts').GeneratedEntry) => entry,
|
||||
}))
|
||||
|
||||
const REPOSITORY = 'https://git.example/team/plugins'
|
||||
|
||||
/** Git subprocesses run under CI load, so each test gets more than the default deadline. */
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import type { GeneratedEntry } from '../src/generate.ts'
|
||||
import { metricReader } from '../src/metrics.ts'
|
||||
|
||||
const limits = { timeoutMs: 10, maxTarballBytes: 1024 }
|
||||
const TOKEN = 'test-only-github-token'
|
||||
const plugin = (name = '@team/dsh-test', source: Record<string, string> = { type: 'npm' }): GeneratedEntry => ({
|
||||
name, repository: { url: 'https://github.com/team/plugins' },
|
||||
versions: [{ version: '1.0.0', publishTimestamp: 0, source }],
|
||||
})
|
||||
|
||||
afterEach(() => { vi.unstubAllGlobals() })
|
||||
|
||||
it('shares repository requests across packages and retains true zero counts', async () => {
|
||||
const fetch = vi.fn().mockResolvedValue(Response.json({ stargazers_count: 0 }))
|
||||
vi.stubGlobal('fetch', fetch)
|
||||
const read = metricReader(limits, 'https://registry.npmjs.org/', vi.fn(), TOKEN)
|
||||
const entries = await Promise.all(['a', 'b'].map(name => read(plugin(name, { type: 'git', url: 'https://github.com/team/plugins' }))))
|
||||
expect(entries.map(entry => entry.metadata?.sortKeys)).toEqual([{ github_stars: 0 }, { github_stars: 0 }])
|
||||
expect(fetch).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it.each([null, {}, { downloads: null }, { downloads: -1 }, { downloads: 1.2 }, { downloads: '3' }, { downloads: 1e20 }])(
|
||||
'omits invalid count response %j while keeping an independently available metric', async (body) => {
|
||||
vi.stubGlobal('fetch', vi.fn(async (url: string) => Response.json(url.includes('github.com') ? { stargazers_count: 7 } : body)))
|
||||
const warn = vi.fn()
|
||||
expect((await metricReader(limits, 'https://registry.npmjs.org/', warn, TOKEN)(plugin())).metadata?.sortKeys).toEqual({ github_stars: 7 })
|
||||
expect(warn).toHaveBeenCalledOnce()
|
||||
},
|
||||
)
|
||||
|
||||
it('omits rate-limited GitHub counts while retaining npm downloads and sharing the failed request', async () => {
|
||||
const fetch = vi.fn(async (url: string) => url.includes('github.com')
|
||||
? Response.json({ message: 'API rate limit exceeded' }, { status: 403, headers: { 'x-ratelimit-remaining': '0' } })
|
||||
: Response.json({ downloads: 42 }))
|
||||
vi.stubGlobal('fetch', fetch)
|
||||
const warn = vi.fn()
|
||||
const read = metricReader(limits, 'https://registry.npmjs.org/', warn, TOKEN)
|
||||
for (const result of await Promise.all([read(plugin()), read(plugin())])) {
|
||||
expect(result.metadata?.sortKeys).toEqual({ npm_weekly_downloads: 42 })
|
||||
}
|
||||
expect(fetch).toHaveBeenCalledTimes(2)
|
||||
expect(warn).toHaveBeenCalledExactlyOnceWith('https://api.github.com/repos/team/plugins: HTTP 403; metric omitted')
|
||||
})
|
||||
|
||||
it.each(['http', 'json', 'timeout', 'network'])('omits %s failures without dropping the entry', async (failure) => {
|
||||
vi.stubGlobal('fetch', vi.fn(async () => {
|
||||
if (failure === 'http') return new Response('', { status: 429 })
|
||||
if (failure === 'json') return new Response('not json')
|
||||
throw failure === 'timeout' ? new DOMException('deadline', 'TimeoutError') : new TypeError('offline')
|
||||
}))
|
||||
const warn = vi.fn()
|
||||
const entry = plugin()
|
||||
expect(await metricReader(limits, 'https://registry.npmjs.org/', warn, TOKEN)(entry)).toEqual(entry)
|
||||
expect(warn).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it.each([false, true])('settles streaming HTTP error responses before returning (cancel rejects: %s)', async (rejectCancel) => {
|
||||
const started = Promise.withResolvers<undefined>()
|
||||
const cancellation = Promise.withResolvers<undefined>()
|
||||
const cancel = vi.fn(() => {
|
||||
started.resolve(undefined)
|
||||
return cancellation.promise
|
||||
})
|
||||
vi.stubGlobal('fetch', vi.fn(async () => new Response(new ReadableStream({
|
||||
start(controller) { controller.enqueue(new TextEncoder().encode('unavailable')) },
|
||||
cancel,
|
||||
}), { status: 503 })))
|
||||
const entry = plugin('git-only', { type: 'git', url: 'https://github.com/team/plugins' })
|
||||
const warn = vi.fn()
|
||||
let finished = false
|
||||
const reading = metricReader(limits, 'https://registry.npmjs.org/', warn, TOKEN)(entry).then((result) => {
|
||||
finished = true
|
||||
return result
|
||||
})
|
||||
await started.promise
|
||||
expect(finished).toBe(false)
|
||||
if (rejectCancel) cancellation.reject(new Error('cancel failed'))
|
||||
else cancellation.resolve(undefined)
|
||||
expect(await reading).toEqual(entry)
|
||||
expect(cancel).toHaveBeenCalledOnce()
|
||||
expect(warn).toHaveBeenCalledExactlyOnceWith(
|
||||
`https://api.github.com/repos/team/plugins: HTTP 503; ${rejectCancel ? 'GitHub metric request failed; ' : ''}metric omitted`,
|
||||
)
|
||||
})
|
||||
|
||||
it('does not look up public counts for private-registry names or non-GitHub repositories', async () => {
|
||||
const fetch = vi.fn()
|
||||
vi.stubGlobal('fetch', fetch)
|
||||
const entry = { ...plugin('private-name', { type: 'npm', registry: 'https://registry.example/' }), repository: { url: 'https://gitlab.example/team/plugins' } }
|
||||
expect(await metricReader(limits, 'https://registry.example/', vi.fn(), TOKEN)(entry)).toEqual(entry)
|
||||
expect(fetch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('attributes Git stars to the installed repository instead of a conflicting manifest repository', async () => {
|
||||
const fetch = vi.fn().mockResolvedValue(Response.json({ stargazers_count: 3 }))
|
||||
vi.stubGlobal('fetch', fetch)
|
||||
await metricReader(limits, 'https://registry.npmjs.org/', vi.fn(), TOKEN)(plugin('git-only', { type: 'git', url: 'https://github.com/actual/source.git' }))
|
||||
expect(fetch).toHaveBeenCalledExactlyOnceWith('https://api.github.com/repos/actual/source', expect.any(Object))
|
||||
})
|
||||
|
||||
it('skips GitHub without a token while keeping npm downloads', async () => {
|
||||
const fetch = vi.fn().mockResolvedValue(Response.json({ downloads: 42 }))
|
||||
vi.stubGlobal('fetch', fetch)
|
||||
const warn = vi.fn()
|
||||
const read = metricReader(limits, 'https://registry.npmjs.org/', warn)
|
||||
expect((await read(plugin())).metadata?.sortKeys).toEqual({ npm_weekly_downloads: 42 })
|
||||
const git = plugin('git-only', { type: 'git', url: 'https://github.com/team/plugins' })
|
||||
expect(await read(git)).toEqual(git)
|
||||
expect(fetch).toHaveBeenCalledExactlyOnceWith('https://api.npmjs.org/downloads/point/last-week/%40team%2Fdsh-test', expect.any(Object))
|
||||
expect(warn).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('authenticates only the GitHub API request and leaves npm unauthenticated', async () => {
|
||||
const fetch = vi.fn(async (url: string, _init?: RequestInit) => Response.json(url.includes('github.com')
|
||||
? { stargazers_count: 7 } : { downloads: 42 }))
|
||||
vi.stubGlobal('fetch', fetch)
|
||||
const entry = await metricReader(limits, 'https://registry.npmjs.org/', vi.fn(), TOKEN)(plugin())
|
||||
expect(entry.metadata?.sortKeys).toEqual({ github_stars: 7, npm_weekly_downloads: 42 })
|
||||
expect(fetch).toHaveBeenCalledTimes(2)
|
||||
for (const [url, init] of fetch.mock.calls) {
|
||||
expect(new Headers(init?.headers).get('authorization')).toBe(url.startsWith('https://api.github.com/') ? `Bearer ${TOKEN}` : null)
|
||||
}
|
||||
expect(JSON.stringify(entry)).not.toContain(TOKEN)
|
||||
})
|
||||
|
||||
it.each(['fetch', 'json', 'cancel'])('does not expose a token in %s errors from GitHub', async (failure) => {
|
||||
vi.stubGlobal('fetch', vi.fn(async () => {
|
||||
if (failure === 'fetch') throw new Error(`rejected header Bearer ${TOKEN}`)
|
||||
if (failure === 'json') return new Response(`invalid ${TOKEN}`)
|
||||
return new Response(new ReadableStream({ cancel: () => Promise.reject(new Error(TOKEN)) }), { status: 503 })
|
||||
}))
|
||||
const warn = vi.fn()
|
||||
const entry = plugin('git-only', { type: 'git', url: 'https://github.com/team/plugins' })
|
||||
expect(await metricReader(limits, 'https://registry.npmjs.org/', warn, TOKEN)(entry)).toEqual(entry)
|
||||
expect(warn).toHaveBeenCalledExactlyOnceWith(
|
||||
`https://api.github.com/repos/team/plugins: ${failure === 'fetch' ? '' : `HTTP ${failure === 'json' ? 200 : 503}; `}GitHub metric request failed; metric omitted`,
|
||||
)
|
||||
expect(JSON.stringify(warn.mock.calls)).not.toContain(TOKEN)
|
||||
})
|
||||
@@ -21,11 +21,11 @@
|
||||
en: b85b82b22c1e14b8
|
||||
zh: 920c0d6f20956c07
|
||||
/plugin-indexes/generate-an-index-with-the-reference-generator:
|
||||
en: 6501406f530ffe33
|
||||
zh: 700acae3f466c985
|
||||
en: c4326a91313ddb75
|
||||
zh: 02455354b61e0d79
|
||||
/plugin-indexes/index-format-reference:
|
||||
en: 8fed66180a76f815
|
||||
zh: 7069ac4eed4bdfd9
|
||||
en: 49a19adfe82d8244
|
||||
zh: 2d744c3e225ed181
|
||||
/plugin-indexes/further-exploration:
|
||||
en: 2223e92806e7e79a
|
||||
zh: 093309e1c40caa6c
|
||||
|
||||
@@ -99,12 +99,12 @@ pnpm --filter @deepseek-ai/dsh-plugin-market-generator start --input plugins.txt
|
||||
|
||||
For an npm package, the index lists every version that is neither deprecated nor private and declares `dsh.bundle`. For a Git repository, it finds the bundle packages on the default branch whose `package.json` does not set `"private": true` and lists their versions at `vX.Y.Z`, `X.Y.Z`, and `<name>@X.Y.Z` tags, plus each package's version on the default branch. Titles, descriptions, icons, authors, and homepages come from each plugin's highest version. Paths are relative to the directory where you run pnpm.
|
||||
|
||||
The generator only downloads and reads files; it never installs packages or runs their code. It keeps a cache in `.plugin-market-cache` next to the list, so a repeated run downloads only new versions and commits. Publish the output directory, `index.json` together with its `icons/` directory, as described above.
|
||||
The generator only downloads and reads files; it never installs packages or runs their code. It keeps package data in `.plugin-market-cache` next to the list and reuses unchanged versions and commits. Each run still reads source documents or Git refs and refreshes enabled metrics; unavailable metric values are omitted. Set `GITHUB_TOKEN` to collect GitHub stars; without a nonblank token, GitHub metric requests and `github_stars` are omitted while npm weekly downloads remain enabled. Publish the output directory, `index.json` together with its `icons/` directory, as described above.
|
||||
|
||||
<a id="index-format"></a>
|
||||
## Index format reference
|
||||
|
||||
Text fields marked `LocalizedText` are a string or an object of strings keyed by lowercase language ids, such as `{ "en": "Foo", "zh": "甲" }`; an object must contain `en`. Members DSH does not know are ignored.
|
||||
Text fields marked `LocalizedText` are a string or an object of strings keyed by lowercase language ids, such as `{ "en": "Foo", "zh": "甲" }`; an object must contain `en`. DSH ignores unknown object fields at every level. Known fields and `source.type` still require valid values; keys inside declaration, localized-text, and plugin sort-value maps are validated as data.
|
||||
|
||||
| Index field | Required | Meaning |
|
||||
|---|---|---|
|
||||
@@ -112,7 +112,7 @@ Text fields marked `LocalizedText` are a string or an object of strings keyed by
|
||||
| `title`, `description` | `title` only | Index name and description, `LocalizedText`. |
|
||||
| `metadata.categories`, `metadata.labels` | no | Objects mapping an id to `{ "title": LocalizedText }`. |
|
||||
| `metadata.sortKeys` | no | Object mapping an id to `{ "title": LocalizedText, "order": "asc" \| "desc" }`; `order` is the initial direction. |
|
||||
| `plugins` | yes | Plugins in the index's default order. |
|
||||
| `plugins` | yes | Plugins in the publisher's default order; clients must preserve this array order on initial display and when filtering, unless the user chooses another sort or text search ranks by relevance. |
|
||||
|
||||
| Plugin field | Required | Meaning |
|
||||
|---|---|---|
|
||||
@@ -124,9 +124,11 @@ Text fields marked `LocalizedText` are a string or an object of strings keyed by
|
||||
| `repository` | no | `{ "url": string, "directory"?: string }`: the source repository's web page, and the package directory inside it. The plugin page links it as **Source code**. |
|
||||
| `metadata.category` | no | One declared category id. |
|
||||
| `metadata.labels` | no | Declared label ids. |
|
||||
| `metadata.sortKeys` | no | Object mapping declared sort-key ids to numbers, such as downloads or ratings. |
|
||||
| `metadata.sortKeys` | no | Object mapping declared sort-key ids to finite numbers, or `null` when unavailable. |
|
||||
| `versions` | yes | At least one version, in any order. |
|
||||
|
||||
A plugin may omit `metadata`, any of its fields, or individual sort values, or use `null` for unavailable data. Missing values sort last in both directions; a real zero is a value, not missing data. The recommended definitions standardize `github_stars` as a repository's GitHub star count and `npm_weekly_downloads` as a public npm package's download count over npm's `last-week` window. Both are non-negative integers; weekly downloads are not a monthly count. Publishers using these keys declare them under root `metadata.sortKeys` and populate available values under each plugin's `metadata.sortKeys`. Other declared numeric keys remain supported.
|
||||
|
||||
| Version field | Required | Meaning |
|
||||
|---|---|---|
|
||||
| `version` | yes | Exact SemVer version, unique within the plugin. |
|
||||
|
||||
@@ -99,12 +99,12 @@ pnpm --filter @deepseek-ai/dsh-plugin-market-generator start --input plugins.txt
|
||||
|
||||
对于 npm 包,索引列出所有未弃用、未设为私有且声明了 `dsh.bundle` 的版本。对于 Git 仓库,生成器在默认分支上查找 `package.json` 没有设置 `"private": true` 的组合包,列出它们在 `vX.Y.Z`、`X.Y.Z` 和 `<name>@X.Y.Z` 标签处的版本,以及每个包在默认分支上的版本。标题、描述、图标、作者和主页取自每个插件的最高版本。路径相对于你运行 pnpm 的目录。
|
||||
|
||||
生成器只下载和读取文件,从不安装包或运行其代码。它在列表旁的 `.plugin-market-cache` 中保存缓存,因此再次运行时只下载新的版本和提交。按上文所述发布输出目录,即 `index.json` 及其 `icons/` 目录。
|
||||
生成器只下载和读取文件,从不安装包或运行其代码。它在列表旁的 `.plugin-market-cache` 中保存包数据,并复用未变更的版本和提交。每次运行仍会读取源文档或 Git 引用,并刷新已启用的指标;不可用的指标值会被省略。设置 `GITHUB_TOKEN` 以采集 GitHub star 数;没有非空白令牌时,不会发起 GitHub 指标请求,也不会输出 `github_stars`,但 npm 周下载量仍保持启用。按上文所述发布输出目录,即 `index.json` 及其 `icons/` 目录。
|
||||
|
||||
<a id="index-format"></a>
|
||||
## 索引格式参考
|
||||
|
||||
标为 `LocalizedText` 的文本字段是字符串,或以小写语言 id 为键的字符串对象,例如 `{ "en": "Foo", "zh": "甲" }`;对象必须包含 `en`。DSH 不认识的成员会被忽略。
|
||||
标为 `LocalizedText` 的文本字段是字符串,或以小写语言 id 为键的字符串对象,例如 `{ "en": "Foo", "zh": "甲" }`;对象必须包含 `en`。DSH 在每一层都忽略未知的对象字段。已知字段和 `source.type` 仍须具有有效值;声明映射、本地化文本映射和插件排序值映射中的键作为数据进行校验。
|
||||
|
||||
| 索引字段 | 必填 | 含义 |
|
||||
|---|---|---|
|
||||
@@ -112,7 +112,7 @@ pnpm --filter @deepseek-ai/dsh-plugin-market-generator start --input plugins.txt
|
||||
| `title`、`description` | 仅 `title` | 索引名称和描述,类型为 `LocalizedText`。 |
|
||||
| `metadata.categories`、`metadata.labels` | 否 | 将 id 映射为 `{ "title": LocalizedText }` 的对象。 |
|
||||
| `metadata.sortKeys` | 否 | 将 id 映射为 `{ "title": LocalizedText, "order": "asc" \| "desc" }` 的对象;`order` 是初始方向。 |
|
||||
| `plugins` | 是 | 按索引默认顺序排列的插件。 |
|
||||
| `plugins` | 是 | 按发布者的默认顺序排列的插件;客户端必须在初次展示和筛选时保留此数组顺序,除非用户选择其他排序方式,或文本搜索按相关性排序。 |
|
||||
|
||||
| 插件字段 | 必填 | 含义 |
|
||||
|---|---|---|
|
||||
@@ -124,9 +124,11 @@ pnpm --filter @deepseek-ai/dsh-plugin-market-generator start --input plugins.txt
|
||||
| `repository` | 否 | `{ "url": string, "directory"?: string }`:源代码仓库的网页,以及包在其中的目录。插件页面以**源代码**链接到它。 |
|
||||
| `metadata.category` | 否 | 一个已声明的分类 id。 |
|
||||
| `metadata.labels` | 否 | 已声明的标签 id。 |
|
||||
| `metadata.sortKeys` | 否 | 将已声明的排序键 id 映射为数值的对象,例如下载量或评分。 |
|
||||
| `metadata.sortKeys` | 否 | 将已声明的排序键 id 映射为有限数值的对象;不可用时值为 `null`。 |
|
||||
| `versions` | 是 | 至少一个版本,顺序不限。 |
|
||||
|
||||
插件可以省略 `metadata`、其中的任意字段或单个排序值,也可以用 `null` 表示数据不可用。无论升序还是降序,缺失值都排在最后;实际的零是有效值,不是缺失数据。建议采用的统一定义是:`github_stars` 表示仓库的 GitHub star 数,`npm_weekly_downloads` 表示公共 npm 包在 npm `last-week` 时间窗口内的下载量。两者均为非负整数;周下载量不是月下载量。发布者使用这些键时,在根级 `metadata.sortKeys` 中声明,并在各插件的 `metadata.sortKeys` 中填入可用值。其他已声明的数值键仍受支持。
|
||||
|
||||
| 版本字段 | 必填 | 含义 |
|
||||
|---|---|---|
|
||||
| `version` | 是 | 精确的 SemVer 版本,在插件内唯一。 |
|
||||
|
||||
@@ -380,8 +380,10 @@ async function searching() {
|
||||
}
|
||||
}
|
||||
|
||||
it('filters by category and every label, and an undeclared id matches nothing', async () => {
|
||||
it('preserves publisher order before sorting and through category and label filters', async () => {
|
||||
const names = await searching()
|
||||
expect(await names({})).toEqual(['dsh-weather', 'dsh-auto-review', 'dsh-notes'])
|
||||
expect(await names({ labels: ['ui'] })).toEqual(['dsh-weather', 'dsh-auto-review'])
|
||||
expect(await names({ category: 'work' })).toEqual(['dsh-auto-review', 'dsh-notes'])
|
||||
expect(await names({ labels: ['official', 'ui'] })).toEqual(['dsh-auto-review'])
|
||||
expect(await names({ category: 'fun', labels: ['official'] })).toEqual([])
|
||||
@@ -474,10 +476,11 @@ it('reads one plugin by name from the cached document', async () => {
|
||||
})
|
||||
|
||||
it('keeps index order among equal sort-key values and among plugins without one', async () => {
|
||||
const valued = (name: string, downloads?: number) => entry(name, { metadata: { sortKeys: downloads === undefined ? {} : { downloads } } })
|
||||
serve(json({ ...SEARCHABLE, plugins: [valued('a'), valued('b', 1), valued('c'), valued('d', 2), valued('e', 1)] }))
|
||||
const valued = (name: string, downloads?: number | null) =>
|
||||
entry(name, { metadata: { sortKeys: downloads === undefined ? {} : { downloads } } })
|
||||
serve(json({ ...SEARCHABLE, plugins: [valued('a'), valued('b', 1), valued('c'), valued('d', 2), valued('e', 1), valued('f', 0), valued('g', null), entry('h'), entry('i', { metadata: null })] }))
|
||||
const { market } = await mount()
|
||||
for (const [direction, expected] of [['asc', ['b', 'e', 'd', 'a', 'c']], ['desc', ['d', 'b', 'e', 'a', 'c']]] as const) {
|
||||
for (const [direction, expected] of [['asc', ['f', 'b', 'e', 'd', 'a', 'c', 'g', 'h', 'i']], ['desc', ['d', 'b', 'e', 'f', 'a', 'c', 'g', 'h', 'i']]] as const) {
|
||||
const result = await market.search(URL_A, { sort: { kind: 'key', id: 'downloads', direction }, offset: 0, limit: 10 })
|
||||
expect(result.entries.map(item => item.name)).toEqual(expected)
|
||||
}
|
||||
|
||||
@@ -62,6 +62,12 @@ function setup(indexes: MarketIndexSummary[] = [{ url: A }, { url: B }]) {
|
||||
describe('DiscoverController', () => {
|
||||
it('reads sources on first render, selects the first, and reads its listing', async () => {
|
||||
const { face, market, snapshot } = setup()
|
||||
const entries = [plugin('publisher-first'), plugin('more-downloads')]
|
||||
market.search.mockImplementationOnce(() => ok(listing(A, 'A', 'Desc', {
|
||||
metadata: { categories: {}, labels: {}, sortKeys: { downloads: { title: 'Downloads', order: 'desc' } } },
|
||||
entries: entries.map((entry, index) => ({ ...entry, metadata: { ...entry.metadata, sortKeys: { downloads: index + 1 } } })),
|
||||
total: 2,
|
||||
})))
|
||||
expect(snapshot().status).toBe('idle')
|
||||
face.ensure()
|
||||
expect(snapshot().status).toBe('loading')
|
||||
@@ -70,7 +76,8 @@ describe('DiscoverController', () => {
|
||||
expect(market.listIndexes).toHaveBeenCalledOnce()
|
||||
expect(snapshot()).toMatchObject({ status: 'ready', selected: A, listing: { url: A, value: { title: 'A' } } })
|
||||
expect(snapshot().indexes).toEqual([{ url: A, title: 'A', description: 'Desc' }, { url: B }])
|
||||
expect(market.search).toHaveBeenCalledWith(A, FIRST_PAGE)
|
||||
expect(market.search).toHaveBeenCalledExactlyOnceWith(A, FIRST_PAGE)
|
||||
expect(snapshot().listing?.value?.entries.map(entry => entry.name)).toEqual(['publisher-first', 'more-downloads'])
|
||||
expect(face.hooks.locale.getSnapshot().active).toBeTypeOf('string')
|
||||
})
|
||||
|
||||
|
||||
@@ -15,8 +15,8 @@
|
||||
en: cd2a60a905c978d6
|
||||
zh: cc71d387d0cbd442
|
||||
/dsh-plugin-market-index/use-this-package:
|
||||
en: 8eac2625e2e366a8
|
||||
zh: 652131f240e929e0
|
||||
en: e9555c8347aa9e2e
|
||||
zh: ddd410caf24bcf78
|
||||
/dsh-plugin-market-index/api:
|
||||
en: e0e4444616427bf2
|
||||
zh: 3960a7602bb17705
|
||||
|
||||
@@ -28,6 +28,8 @@ It is a library, not a service or plugin: no `ctx`, no registrations, no state,
|
||||
|
||||
Parse the fetched document as JSON, then pass the value and the URL it was read from to `parseMarketIndex`. The first violated rule throws `MarketIndexError` with the JSON path of the offending value. A successful result drops members the format does not define, fills absent `metadata` groups, `keywords`, `labels`, `sortKeys`, and `engines` with empty values, orders each plugin's `versions` by descending SemVer precedence, and replaces each relative `icon` reference with its absolute URL.
|
||||
|
||||
Within plugin metadata, `null` means unavailable: the metadata object and its fields normalize like absent input, and null sort values are omitted. Numeric zero is retained. Generators omit unavailable metadata and values. Unknown object fields are ignored, while declared map entries, known fields, and source discriminators remain validated.
|
||||
|
||||
Install one version with `marketInstallTarget(name, version)`: it returns the exact pnpm spec, `name@version` for npm and a commit-pinned `git+https` URL with an optional `path:` parameter for Git, and the registry to ask first when the version names one.
|
||||
|
||||
-----
|
||||
|
||||
@@ -28,6 +28,8 @@ kind: "package-library"
|
||||
|
||||
先把获取到的文档解析为 JSON,再把该值和读取它的 URL 传给 `parseMarketIndex`。遇到第一条被违反的规则时,它抛出 `MarketIndexError`,并附带违规值的 JSON 路径。成功的结果会丢弃格式未定义的成员,用空值填充缺失的 `metadata` 分组、`keywords`、`labels`、`sortKeys` 和 `engines`,按 SemVer 优先级降序排列每个插件的 `versions`,并把每个相对 `icon` 引用替换为其绝对 URL。
|
||||
|
||||
在插件元数据中,`null` 表示不可用:元数据对象及其字段按缺省输入归一化,值为 null 的排序值会被省略。数值零会被保留。生成器省略不可用的元数据和值。未知的对象字段会被忽略,但已声明的映射条目、已知字段和来源判别字段仍会校验。
|
||||
|
||||
使用 `marketInstallTarget(name, version)` 安装一个版本:它返回精确的 pnpm spec(npm 为 `name@version`,Git 为固定到提交、可带 `path:` 参数的 `git+https` URL),该版本指定了 registry 时还返回首先询问的 registry。
|
||||
|
||||
-----
|
||||
|
||||
@@ -113,25 +113,28 @@ function declaredId(value: unknown, path: string, declared: Readonly<Record<stri
|
||||
}
|
||||
|
||||
function pluginMetadata(value: unknown, path: string, index: MarketIndexMetadata): MarketPluginMetadata {
|
||||
const object = value === undefined ? {} : objectAt(value, path)
|
||||
const category = optional(object, 'category', path, (member, at) => declaredId(member, at, index.categories))
|
||||
const keywords = optional(object, 'keywords', path, (member, at) => {
|
||||
const object = value === undefined || value === null ? {} : objectAt(value, path)
|
||||
const field = <T>(key: string, read: (value: unknown, path: string) => T): T | undefined =>
|
||||
object[key] === null ? undefined : optional(object, key, path, read)
|
||||
const category = field('category', (member, at) => declaredId(member, at, index.categories))
|
||||
const keywords = field('keywords', (member, at) => {
|
||||
if (!Array.isArray(member)) fail(at, 'must be an array')
|
||||
return member.map((keyword, position) => {
|
||||
if (typeof keyword !== 'string') fail(`${at}[${String(position)}]`, 'must be a string')
|
||||
return keyword
|
||||
})
|
||||
}) ?? []
|
||||
const labels = optional(object, 'labels', path, (member, at) => {
|
||||
const labels = field('labels', (member, at) => {
|
||||
if (!Array.isArray(member)) fail(at, 'must be an array')
|
||||
const ids = member.map((label, position) => declaredId(label, `${at}[${String(position)}]`, index.labels))
|
||||
if (new Set(ids).size !== ids.length) fail(at, 'must not repeat a label')
|
||||
return ids
|
||||
}) ?? []
|
||||
const sortKeys = optional(object, 'sortKeys', path, (member, at) => {
|
||||
const sortKeys = field('sortKeys', (member, at) => {
|
||||
const values: Record<string, number> = {}
|
||||
for (const [id, sortValue] of Object.entries(objectAt(member, at))) {
|
||||
declaredId(id, `${at}.${id}`, index.sortKeys)
|
||||
if (sortValue === null) continue
|
||||
if (typeof sortValue !== 'number' || !Number.isFinite(sortValue)) fail(`${at}.${id}`, 'must be a finite number')
|
||||
own(values, id, sortValue)
|
||||
}
|
||||
@@ -290,6 +293,7 @@ function plugin(value: unknown, path: string, index: MarketIndexMetadata, indexU
|
||||
|
||||
/**
|
||||
* Validate a parsed format-1 index. Members the format does not define are ignored and dropped.
|
||||
* Null plugin metadata fields normalize like absent fields; null sort values are omitted, while zero is retained.
|
||||
* @param value - the parsed JSON document.
|
||||
* @param indexUrl - absolute URL the index was read from; relative icon references resolve against it.
|
||||
* @returns the validated index with absent metadata groups and lists filled with empty values, and each plugin's
|
||||
|
||||
@@ -26,9 +26,10 @@ export interface MarketIndexMetadata {
|
||||
/** Market-defined values of one plugin; category, label, and sort-key ids require index declarations. */
|
||||
export interface MarketPluginMetadata {
|
||||
readonly category?: string
|
||||
/** Free-form strings, conventionally from package keywords; absent input becomes an empty array. */
|
||||
/** Free-form strings, conventionally from package keywords; absent or null input becomes an empty array. */
|
||||
readonly keywords: readonly string[]
|
||||
readonly labels: readonly string[]
|
||||
/** Unavailable values (absent or null in JSON) are omitted; zero remains a measured value. */
|
||||
readonly sortKeys: Readonly<Record<string, number>>
|
||||
}
|
||||
|
||||
|
||||
@@ -114,6 +114,36 @@ describe('parseMarketIndex', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('ignores extra object fields while validating declaration maps and known discriminators', () => {
|
||||
const original = document()
|
||||
const extended = document({
|
||||
future: { nested: true },
|
||||
metadata: {
|
||||
future: [],
|
||||
categories: { productivity: { title: 'Productivity', future: null } },
|
||||
labels: { official: { title: { en: 'Official', zh: '官方' }, future: false }, ui: { title: 'UI' } },
|
||||
sortKeys: { downloads: { title: 'Downloads', order: 'desc', future: {} } },
|
||||
},
|
||||
plugins: [entry({ versions: [npmVersion({ future: [], source: { type: 'npm', future: false } })] })],
|
||||
})
|
||||
expect(parseMarketIndex(extended, INDEX_URL)).toEqual(parseMarketIndex(original, INDEX_URL))
|
||||
expect(rejection(document({ format: 2, future: true })).path).toBe('$.format')
|
||||
expect(versionRejection({ source: { type: 'future', extra: true } })).toContain('.source.type')
|
||||
expect(entryRejection({ metadata: { sortKeys: { future: null } } })).toContain('references undeclared id')
|
||||
})
|
||||
|
||||
it('normalizes absent and null plugin metadata to unavailable values without inventing zero', () => {
|
||||
const cases = [undefined, null, {}, { category: null, keywords: null, labels: null, sortKeys: null }, { sortKeys: { downloads: null } }]
|
||||
for (const metadata of cases) {
|
||||
const plugin = entry(metadata === undefined ? {} : { metadata })
|
||||
expect(parseMarketIndex(document({ plugins: [plugin] }), INDEX_URL).plugins[0]?.metadata)
|
||||
.toEqual({ keywords: [], labels: [], sortKeys: {} })
|
||||
}
|
||||
expect(parseMarketIndex(document({ plugins: [entry({ metadata: { sortKeys: { downloads: 0 } } })] }), INDEX_URL)
|
||||
.plugins[0]?.metadata.sortKeys).toEqual({ downloads: 0 })
|
||||
expect(entryRejection({ metadata: { sortKeys: { downloads: 'n/a' } } })).toContain('must be a finite number')
|
||||
})
|
||||
|
||||
it('preserves free-form keywords without declarations, including order, duplicates, and empty strings', () => {
|
||||
for (const keywords of [[], ['market-specific', ' npm ', '中文', '', 'market-specific']]) {
|
||||
const index = parseMarketIndex({ format: 1, title: 'Keywords', plugins: [entry({ metadata: { keywords } })] }, INDEX_URL)
|
||||
@@ -122,7 +152,7 @@ describe('parseMarketIndex', () => {
|
||||
})
|
||||
|
||||
it('rejects keywords that are not a string array with the offending JSON path', () => {
|
||||
for (const keywords of ['review', null, {}]) {
|
||||
for (const keywords of ['review', {}]) {
|
||||
expect(entryRejection({ metadata: { keywords } })).toBe('$.plugins[0].metadata.keywords must be an array')
|
||||
}
|
||||
expect(entryRejection({ metadata: { keywords: ['review', 1] } })).toBe('$.plugins[0].metadata.keywords[1] must be a string')
|
||||
|
||||
Reference in new issue
Block a user