Compare commits

...

8 Commits

Author SHA1 Message Date
coderabbitai[bot]
dc4d6f6c28 📝 Add docstrings to glary/website-cloud-nodes-mock-and-slugify
Docstrings generation was requested by @DrJKL.

* https://github.com/Comfy-Org/ComfyUI_frontend/pull/12277#issuecomment-4454346663

The following files were modified:

* `apps/website/src/utils/cloudNodes.build.ts`
* `apps/website/src/utils/cloudNodes.ts`
* `packages/object-info-parser/src/helpers/groupNodesByPack.ts`
2026-05-15 14:39:26 +00:00
Alexander Brown
764977aaf6 Merge branch 'main' into glary/website-cloud-nodes-mock-and-slugify 2026-05-15 07:38:12 -07:00
Glary-Bot
669bd5dbec test(website): cover first-wins alias contract when both registry hits resolve
CodeRabbit nitpick: the existing alias-collision test mocked
ComfyUI-QwenVL -> null + ComfyUI_QwenVL -> hit, so the assertion that
the first hit wins was actually proving 'the only non-null hit wins'.
A regression that returns the last non-null entry would still pass.

New test mocks BOTH aliases to non-null metadata with distinct
registryId/repoUrl, and pins registryId === 'ComfyUI-QwenVL' (the first
alias in pack.rawIds) so a 'last wins' or 'arbitrary wins' regression
fails immediately.
2026-05-14 20:39:54 +00:00
Glary-Bot
c753f30a5f test(website): isolate cloudNodes suite from WEBSITE_CLOUD_NODES_FIXTURE
Defensive hardening per CodeRabbit feedback. WEBSITE_CLOUD_NODES_FIXTURE
is currently read only by loadPacksForBuild (cloudNodes.build.ts), not
by fetchCloudNodesForBuild, so the tests in cloudNodes.test.ts are not
affected today. But clearing the env var in beforeEach and restoring it
in afterEach guards against future refactors that might let the override
bleed into the fetcher, and matches the pattern already used in
cloudNodes.build.test.ts.

Also fixes a latent bug in the existing afterEach: previously,
process.env.WEBSITE_CLOUD_API_KEY = savedCloudApiKey would set the env
var to the literal string 'undefined' when savedCloudApiKey was unset.
Now both env vars are conditionally restored (matching the cloudNodes.build
test convention).
2026-05-14 20:32:40 +00:00
Glary-Bot
1c5a0079f0 fix(website): fall back to raw upstream id when registry enrichment misses
Addresses CodeRabbit review: registryId was sourced purely from
registryPack?.id, so any pack whose registry lookup returned null (or
whose entire fetchRegistryPacks call threw) ended up with registryId
undefined. That breaks the new 'slugged id + raw registryId' contract
and leaves downstream code with no stable upstream identifier.

toDomainPack now takes a fallbackRegistryId derived from pack.rawIds[0]
and uses it when registryPack is null/undefined, so every Pack always
exposes the canonical raw alias even when the registry batch fails.

Two regression tests pin the contract:
* registry-miss path (fetchRegistryPacks returns empty map)
* registry-throw path (fetchRegistryPacks rejects)
2026-05-14 20:26:18 +00:00
Glary-Bot
df3c3c7efc fix(website): preserve optional metadata when snapshot packs merge on slug
Addresses CodeRabbit review: when normalizeSnapshotIds merged two snapshot
packs whose ids slugified to the same value, only the nodes were combined
and every other optional field (registryId, description, repoUrl, publisher,
downloads, githubStars, license, ...) was silently dropped from the later
alias. If the first row lacked metadata the second had, those fields were
lost from the rendered detail page.

mergeCollidedPacks now walks every key on the later pack and fills any
undefined or null fields on the merged result, never overwriting metadata
already present on the first row. The merge stays deterministic
(first-wins for filled fields) and the rule covers the full Pack shape
instead of an ad-hoc hand-listed subset.
2026-05-14 20:18:22 +00:00
Glary-Bot
b58d24403b fix(website): query every raw-id alias when pack slugs collide
Addresses Oracle review feedback: when two raw upstream ids slugify to
the same URL slug (e.g. ComfyUI-QwenVL + ComfyUI_QwenVL both -> comfyui-qwenvl)
the previous merge kept only the first rawId and used only that single
alias to fetch registry metadata. If that one alias missed but its twin
would have resolved, the merged pack lost banner/icon/license info.

Now NodePack carries rawIds: string[] holding every raw alias seen for
the slug. parseCloudNodes flattens all aliases into a single registry
batch and pickRegistryPack walks the alias list in insertion order to
find the first non-null hit.
2026-05-14 20:07:27 +00:00
Glary-Bot
30c106d972 fix(website): slugify cloud-node pack ids and mock e2e snapshot
The website's /cloud/supported-nodes routes use pack ids as URL segments,
but pack ids come straight from upstream Python module names which mix
PascalCase, snake_case, and kebab-case freely. After a Release: Website
snapshot refresh that surfaced packs like ComfyUI-Crystools and
basic_data_handling, the Playwright assertions in cloud-nodes.spec.ts
broke because the slug regex requires kebab-case and the hardcoded
comfyui-impact-pack assertions assumed registry contents would never
change.

Two coordinated fixes:

* slugifyPackId in @comfyorg/object-info-parser normalizes every pack
  id (lowercase + _\u2192-) at the boundaries where it enters the website:
  groupNodesByPack for fresh fetches and readSnapshot for the bundled
  fallback. The raw upstream id is preserved as rawId/registryId so
  registry enrichment continues to hit https://api.comfy.org/nodes
  with the exact node_id the API expects. Packs whose raw ids collide
  on the same slug (24 such pairs exist in the live registry today,
  e.g. ComfyUI-QwenVL + ComfyUI_QwenVL) are merged deterministically
  instead of producing duplicate static routes.

* WEBSITE_CLOUD_NODES_FIXTURE lets the e2e build read a committed
  fixture snapshot in place of the bundled one. CI sets it to
  e2e/fixtures/cloud-nodes.fixture.json so Playwright assertions run
  against deterministic pack content; future snapshot refreshes can
  no longer break the test contract by removing the Impact Pack or
  adding underscored ids.

Verification:
* 99/99 desktop Playwright tests pass against the fixture-built site
* 81/81 website Vitest unit tests pass (including new build/fixture
  coverage)
* 48/48 object-info-parser tests pass (11 new slugifyPackId cases,
  4 new groupNodesByPack cases)
* astro check: 0 errors
2026-05-14 19:54:30 +00:00
12 changed files with 889 additions and 33 deletions

View File

@@ -51,6 +51,7 @@ jobs:
- name: Build website
env:
WEBSITE_GITHUB_STARS_OVERRIDE: 110000
WEBSITE_CLOUD_NODES_FIXTURE: e2e/fixtures/cloud-nodes.fixture.json
run: pnpm --filter @comfyorg/website build
- name: Run Playwright tests

View File

@@ -0,0 +1,156 @@
{
"fetchedAt": "2026-01-01T00:00:00.000Z",
"packs": [
{
"id": "comfyui-impact-pack",
"registryId": "comfyui-impact-pack",
"displayName": "ComfyUI Impact Pack",
"description": "Production-grade detailer, detector, and SEG (segmentation) tooling. The most-used pack for face restoration, region-based refinement, and iterative upscaling on Comfy Cloud.",
"bannerUrl": "https://media.comfy.org/cloud-nodes/comfyui-impact-pack-banner.webp",
"iconUrl": "https://media.comfy.org/cloud-nodes/comfyui-impact-pack-icon.webp",
"repoUrl": "https://github.com/ltdrdata/ComfyUI-Impact-Pack",
"publisher": {
"id": "drltdata",
"name": "Dr.Lt.Data"
},
"downloads": 2618646,
"githubStars": 3092,
"latestVersion": "8.28.3",
"license": "GPL-3.0",
"lastUpdated": "2026-04-19T17:08:04.993918Z",
"nodes": [
{
"name": "FaceDetailer",
"displayName": "FaceDetailer",
"category": "ImpactPack/Detailer",
"description": "Detect and refine faces with iterative passes."
},
{
"name": "DetailerForEach",
"displayName": "DetailerForEach",
"category": "ImpactPack/Detailer",
"description": "Run iterative detail refinement over detected SEG regions."
},
{
"name": "UltralyticsDetectorProvider",
"displayName": "UltralyticsDetectorProvider",
"category": "ImpactPack/Detector",
"description": "Provide detector models powered by Ultralytics YOLO."
}
]
},
{
"id": "comfyui-crystools",
"registryId": "ComfyUI-Crystools",
"displayName": "ComfyUI-Crystools",
"description": "Live system monitoring (GPU, RAM, disk) and rich image inspection inside your workflow.",
"bannerUrl": "https://media.comfy.org/cloud-nodes/comfyui-crystools-banner.webp",
"iconUrl": "https://media.comfy.org/cloud-nodes/comfyui-crystools-icon.webp",
"repoUrl": "https://github.com/crystian/ComfyUI-Crystools",
"publisher": {
"id": "crystian",
"name": "Crystian"
},
"downloads": 1671447,
"githubStars": 1855,
"latestVersion": "1.27.4",
"license": "MIT",
"lastUpdated": "2025-10-26T19:11:09.943366Z",
"nodes": [
{
"name": "CCrystools_Show_Resources",
"displayName": "CCrystools_Show_Resources",
"category": "crystools/show",
"description": "Display GPU, RAM and disk usage live in the workflow."
},
{
"name": "CCrystools_Show_Image",
"displayName": "CCrystools_Show_Image",
"category": "crystools/show",
"description": "Inspect images at full resolution with metadata overlays."
}
]
},
{
"id": "alpha-test-pack",
"registryId": "alpha-test-pack",
"displayName": "Alpha Test Pack",
"description": "Deterministic fixture pack used to anchor the A\u2192Z sort assertion in Playwright. Its display name starts with 'A' so it surfaces first when sorted alphabetically.",
"bannerUrl": "https://media.comfy.org/cloud-nodes/alpha-test-pack-banner.webp",
"repoUrl": "https://github.com/Comfy-Org/alpha-test-pack",
"publisher": {
"id": "comfy-org",
"name": "Comfy Org"
},
"downloads": 42,
"githubStars": 7,
"latestVersion": "0.1.0",
"license": "MIT",
"lastUpdated": "2026-01-01T00:00:00.000Z",
"nodes": [
{
"name": "AlphaProbe",
"displayName": "AlphaProbe",
"category": "alpha/test",
"description": "Deterministic node used to verify alphabetical ordering."
}
]
},
{
"id": "rgthree-comfy",
"registryId": "rgthree-comfy",
"displayName": "rgthree-comfy",
"description": "Quality-of-life nodes from rgthree: Power Lora Loader, Display Any, Image Comparer, and more.",
"bannerUrl": "https://media.comfy.org/cloud-nodes/rgthree-comfy-banner.webp",
"repoUrl": "https://github.com/rgthree/rgthree-comfy",
"publisher": {
"id": "rgthree",
"name": "rgthree"
},
"downloads": 987654,
"githubStars": 1500,
"latestVersion": "2.0.0",
"license": "MIT",
"lastUpdated": "2026-03-15T00:00:00.000Z",
"nodes": [
{
"name": "PowerLoraLoader",
"displayName": "Power Lora Loader",
"category": "rgthree/loaders",
"description": "Load multiple LoRAs at once with strength control."
},
{
"name": "DisplayAny",
"displayName": "Display Any",
"category": "rgthree/display",
"description": "Display the value of any wire for debugging."
}
]
},
{
"id": "was-node-suite-comfyui",
"registryId": "was-node-suite-comfyui",
"displayName": "WAS Node Suite",
"description": "Large collection of utility nodes for image processing, text manipulation, and workflow control.",
"bannerUrl": "https://media.comfy.org/cloud-nodes/was-node-suite-banner.webp",
"repoUrl": "https://github.com/WASasquatch/was-node-suite-comfyui",
"publisher": {
"id": "wasasquatch",
"name": "WASasquatch"
},
"downloads": 1234567,
"githubStars": 1700,
"latestVersion": "1.0.0",
"license": "MIT",
"lastUpdated": "2026-02-01T00:00:00.000Z",
"nodes": [
{
"name": "WASImageBlend",
"displayName": "Image Blend",
"category": "WAS/image",
"description": "Blend two images using a configurable mode."
}
]
}
]
}

View File

@@ -50,7 +50,7 @@
]
},
{
"id": "ComfyUI-Crystools",
"id": "comfyui-crystools",
"registryId": "ComfyUI-Crystools",
"displayName": "ComfyUI-Crystools",
"description": "Live system monitoring (GPU, RAM, disk) and rich image inspection inside your workflow. The most-installed quality-of-life pack on the registry.",
@@ -201,7 +201,7 @@
"id": "comfyui-easy-use",
"registryId": "comfyui-easy-use",
"displayName": "ComfyUI-Easy-Use",
"description": "Simplified, opinionated nodes that bundle common patterns into single drop-ins full loader, pre-sampling, easy KSampler, and XY plotting.",
"description": "Simplified, opinionated nodes that bundle common patterns into single drop-ins \u2014 full loader, pre-sampling, easy KSampler, and XY plotting.",
"iconUrl": "https://mintlify.s3.us-west-1.amazonaws.com/yolain/images/logo.svg",
"repoUrl": "https://github.com/yolain/ComfyUI-Easy-Use",
"publisher": {
@@ -250,7 +250,7 @@
"id": "comfyui-advanced-controlnet",
"registryId": "comfyui-advanced-controlnet",
"displayName": "ComfyUI-Advanced-ControlNet",
"description": "ControlNet with timestep keyframes, per-frame masks, and advanced strength scheduling essential for animation and batched-latent workflows.",
"description": "ControlNet with timestep keyframes, per-frame masks, and advanced strength scheduling \u2014 essential for animation and batched-latent workflows.",
"repoUrl": "https://github.com/Kosinkadink/ComfyUI-Advanced-ControlNet",
"publisher": {
"id": "kosinkadink",
@@ -298,7 +298,7 @@
"id": "was-node-suite-comfyui",
"registryId": "was-node-suite-comfyui",
"displayName": "WAS Node Suite",
"description": "A broad utility suite covering image adjustments, compositing, text, math, and I/O the original \"kitchen sink\" pack still relied on by thousands of workflows.",
"description": "A broad utility suite covering image adjustments, compositing, text, math, and I/O \u2014 the original \"kitchen sink\" pack still relied on by thousands of workflows.",
"repoUrl": "https://github.com/WASasquatch/was-node-suite-comfyui",
"publisher": {
"id": "was",
@@ -343,10 +343,10 @@
]
},
{
"id": "comfyui_ipadapter_plus",
"id": "comfyui-ipadapter-plus",
"registryId": "comfyui_ipadapter_plus",
"displayName": "ComfyUI_IPAdapter_plus",
"description": "Reference-image conditioning with IPAdapter style transfer, Face ID, and multi-image embeddings. The most-installed conditioning pack on the registry, used in countless portrait, product, and animation workflows.",
"description": "Reference-image conditioning with IPAdapter \u2014 style transfer, Face ID, and multi-image embeddings. The most-installed conditioning pack on the registry, used in countless portrait, product, and animation workflows.",
"repoUrl": "https://github.com/cubiq/ComfyUI_IPAdapter_plus",
"publisher": {
"id": "matteo",

View File

@@ -4,7 +4,9 @@ import type { FetchOutcome } from './cloudNodes'
import type { NodesSnapshot } from '../data/cloudNodes'
const fetchCloudNodesMock = vi.hoisted(() =>
vi.fn<() => Promise<FetchOutcome>>()
vi.fn<
(options?: { snapshotUrl?: URL; apiKey?: string }) => Promise<FetchOutcome>
>()
)
const reportCloudNodesOutcomeMock = vi.hoisted(() => vi.fn())
@@ -33,19 +35,26 @@ const SNAPSHOT: NodesSnapshot = {
describe('loadPacksForBuild', () => {
const savedVercelEnv = process.env.VERCEL_ENV
const savedFixture = process.env.WEBSITE_CLOUD_NODES_FIXTURE
beforeEach(() => {
fetchCloudNodesMock.mockReset()
reportCloudNodesOutcomeMock.mockReset()
delete process.env.VERCEL_ENV
delete process.env.WEBSITE_CLOUD_NODES_FIXTURE
})
afterEach(() => {
if (savedVercelEnv === undefined) {
delete process.env.VERCEL_ENV
return
} else {
process.env.VERCEL_ENV = savedVercelEnv
}
if (savedFixture === undefined) {
delete process.env.WEBSITE_CLOUD_NODES_FIXTURE
} else {
process.env.WEBSITE_CLOUD_NODES_FIXTURE = savedFixture
}
process.env.VERCEL_ENV = savedVercelEnv
})
it('returns packs when fetch is fresh', async () => {
@@ -125,4 +134,50 @@ describe('loadPacksForBuild', () => {
await expect(loadPacksForBuild()).rejects.toThrow()
expect(reportCloudNodesOutcomeMock).toHaveBeenCalledTimes(1)
})
it('forwards WEBSITE_CLOUD_NODES_FIXTURE as snapshotUrl with an empty api key', async () => {
process.env.WEBSITE_CLOUD_NODES_FIXTURE =
'e2e/fixtures/cloud-nodes.fixture.json'
fetchCloudNodesMock.mockResolvedValue({
status: 'stale',
snapshot: SNAPSHOT,
reason: 'missing WEBSITE_CLOUD_API_KEY'
})
await loadPacksForBuild()
const call = fetchCloudNodesMock.mock.calls[0]?.[0]
expect(call?.snapshotUrl).toBeInstanceOf(URL)
expect(call?.snapshotUrl?.protocol).toBe('file:')
expect(call?.snapshotUrl?.pathname).toMatch(
/apps\/website\/e2e\/fixtures\/cloud-nodes\.fixture\.json$/
)
expect(call?.apiKey).toBe('')
})
it('accepts an absolute path for WEBSITE_CLOUD_NODES_FIXTURE', async () => {
process.env.WEBSITE_CLOUD_NODES_FIXTURE = '/etc/cloud-nodes.fixture.json'
fetchCloudNodesMock.mockResolvedValue({
status: 'stale',
snapshot: SNAPSHOT,
reason: 'missing WEBSITE_CLOUD_API_KEY'
})
await loadPacksForBuild()
const call = fetchCloudNodesMock.mock.calls[0]?.[0]
expect(call?.snapshotUrl?.pathname).toBe('/etc/cloud-nodes.fixture.json')
})
it('does not throw on stale-in-production when the fixture override is set', async () => {
process.env.VERCEL_ENV = 'production'
process.env.WEBSITE_CLOUD_NODES_FIXTURE =
'e2e/fixtures/cloud-nodes.fixture.json'
fetchCloudNodesMock.mockResolvedValue({
status: 'stale',
snapshot: SNAPSHOT,
reason: 'missing WEBSITE_CLOUD_API_KEY'
})
const packs = await loadPacksForBuild()
expect(packs).toBe(SNAPSHOT.packs)
})
})

View File

@@ -1,3 +1,6 @@
import { isAbsolute, resolve as resolvePath } from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
import type { Pack } from '../data/cloudNodes'
import { fetchCloudNodesForBuild } from './cloudNodes'
@@ -7,25 +10,42 @@ const REFRESH_HINT =
'Run `pnpm --filter @comfyorg/website cloud-nodes:refresh-snapshot` locally and commit the snapshot, ' +
'or re-run the `Release: Website` workflow with a valid WEBSITE_CLOUD_API_KEY.'
const WEBSITE_PACKAGE_ROOT = fileURLToPath(new URL('../..', import.meta.url))
/**
* Determine whether the current build is a production Vercel deployment.
*
* @returns `true` if `process.env.VERCEL_ENV` is exactly `'production'`, `false` otherwise.
*/
function isProductionBuild(): boolean {
return process.env.VERCEL_ENV === 'production'
}
/**
* Produces a file:// URL pointing to a local fixture snapshot when WEBSITE_CLOUD_NODES_FIXTURE is set.
*
* @returns A `URL` for the resolved fixture path, or `undefined` if the environment variable is not set.
*/
function fixtureSnapshotUrl(): URL | undefined {
const fixturePath = process.env.WEBSITE_CLOUD_NODES_FIXTURE
if (!fixturePath) return undefined
const absolute = isAbsolute(fixturePath)
? fixturePath
: resolvePath(WEBSITE_PACKAGE_ROOT, fixturePath)
return pathToFileURL(absolute)
}
/**
* Resolve the list of packs to render at build time.
*
* Used by both the index page and the per-pack detail pages so that the
* static index and the static detail routes are always derived from the
* same source. `fetchCloudNodesForBuild` is memoized on a module-level
* `inflight` promise, so repeated calls in the same build process share a
* single network round-trip and the same outcome.
* The same resolved snapshot is used to derive both the site index and per-pack detail routes so static pages share a single source of truth. In production builds a stale snapshot causes the build to fail unless a local fixture override is provided via the `WEBSITE_CLOUD_NODES_FIXTURE` environment variable, which forces use of the on-disk snapshot instead of the live cloud API.
*
* Production builds (VERCEL_ENV=production) fail hard on a stale outcome
* to prevent silently shipping out-of-date snapshot data. Preview and
* local builds continue to use the committed snapshot.
* @returns The array of `Pack` objects from the resolved snapshot to render at build time.
*/
export async function loadPacksForBuild(): Promise<Pack[]> {
const outcome = await fetchCloudNodesForBuild()
const snapshotUrl = fixtureSnapshotUrl()
const options = snapshotUrl ? { snapshotUrl, apiKey: '' } : {}
const outcome = await fetchCloudNodesForBuild(options)
reportCloudNodesOutcome(outcome)
if (outcome.status === 'failed') {
@@ -34,7 +54,7 @@ export async function loadPacksForBuild(): Promise<Pack[]> {
)
}
if (outcome.status === 'stale' && isProductionBuild()) {
if (outcome.status === 'stale' && isProductionBuild() && !snapshotUrl) {
throw new Error(
`Cloud nodes fetch returned stale data in a production build (VERCEL_ENV=production). ` +
`Reason: ${outcome.reason}. ${REFRESH_HINT}`

View File

@@ -87,6 +87,7 @@ function withSnapshotDir(snapshot: NodesSnapshot | null): URL {
describe('fetchCloudNodesForBuild', () => {
const savedCloudApiKey = process.env.WEBSITE_CLOUD_API_KEY
const savedCloudNodesFixture = process.env.WEBSITE_CLOUD_NODES_FIXTURE
beforeEach(() => {
resetCloudNodesFetcherForTests()
@@ -94,11 +95,21 @@ describe('fetchCloudNodesForBuild', () => {
fetchRegistryPacksMock.mockResolvedValue(new Map())
sanitizeCallSpy.mockReset()
delete process.env.WEBSITE_CLOUD_API_KEY
delete process.env.WEBSITE_CLOUD_NODES_FIXTURE
})
afterEach(() => {
vi.restoreAllMocks()
process.env.WEBSITE_CLOUD_API_KEY = savedCloudApiKey
if (savedCloudApiKey === undefined) {
delete process.env.WEBSITE_CLOUD_API_KEY
} else {
process.env.WEBSITE_CLOUD_API_KEY = savedCloudApiKey
}
if (savedCloudNodesFixture === undefined) {
delete process.env.WEBSITE_CLOUD_NODES_FIXTURE
} else {
process.env.WEBSITE_CLOUD_NODES_FIXTURE = savedCloudNodesFixture
}
})
it('returns fresh when API succeeds', async () => {
@@ -306,4 +317,335 @@ describe('fetchCloudNodesForBuild', () => {
})
expect(outcome.status).toBe('fresh')
})
it('falls back to the raw upstream id for registryId when registry lookup misses', async () => {
fetchRegistryPacksMock.mockResolvedValue(new Map())
const fetchImpl = vi.fn(async () =>
response({
QwenNode: validNode({
name: 'QwenNode',
python_module: 'custom_nodes.ComfyUI_QwenVL.nodes'
})
})
)
const outcome = await fetchCloudNodesForBuild({
apiKey: KEY,
baseUrl: BASE_URL,
fetchImpl: fetchImpl as typeof fetch
})
expect(outcome.status).toBe('fresh')
if (outcome.status !== 'fresh') return
expect(outcome.snapshot.packs[0]?.id).toBe('comfyui-qwenvl')
expect(outcome.snapshot.packs[0]?.registryId).toBe('ComfyUI_QwenVL')
})
it('falls back to the raw upstream id for registryId when fetchRegistryPacks throws', async () => {
fetchRegistryPacksMock.mockImplementation(async () => {
throw new Error('registry unreachable')
})
const fetchImpl = vi.fn(async () =>
response({
QwenNode: validNode({
name: 'QwenNode',
python_module: 'custom_nodes.ComfyUI_QwenVL.nodes'
})
})
)
const outcome = await fetchCloudNodesForBuild({
apiKey: KEY,
baseUrl: BASE_URL,
fetchImpl: fetchImpl as typeof fetch
})
expect(outcome.status).toBe('fresh')
if (outcome.status !== 'fresh') return
expect(outcome.snapshot.packs[0]?.registryId).toBe('ComfyUI_QwenVL')
})
it('slugifies pack ids while querying the registry with the raw id', async () => {
fetchRegistryPacksMock.mockResolvedValue(
new Map([
[
'ComfyUI_QwenVL',
{
id: 'ComfyUI_QwenVL',
name: 'ComfyUI QwenVL',
repository: 'https://github.com/example/ComfyUI_QwenVL'
}
]
])
)
const fetchImpl = vi.fn(async () =>
response({
QwenNode: validNode({
name: 'QwenNode',
python_module: 'custom_nodes.ComfyUI_QwenVL.nodes'
})
})
)
const outcome = await fetchCloudNodesForBuild({
apiKey: KEY,
baseUrl: BASE_URL,
fetchImpl: fetchImpl as typeof fetch
})
expect(outcome.status).toBe('fresh')
if (outcome.status !== 'fresh') return
expect(outcome.snapshot.packs[0]?.id).toBe('comfyui-qwenvl')
expect(outcome.snapshot.packs[0]?.registryId).toBe('ComfyUI_QwenVL')
expect(fetchRegistryPacksMock).toHaveBeenCalledWith(
['ComfyUI_QwenVL'],
expect.anything()
)
})
it('queries every raw-id alias when packs collide on the same slug and picks the first hit', async () => {
fetchRegistryPacksMock.mockResolvedValue(
new Map<string, unknown>([
['ComfyUI-QwenVL', null],
[
'ComfyUI_QwenVL',
{
id: 'ComfyUI_QwenVL',
name: 'ComfyUI QwenVL',
repository: 'https://github.com/example/ComfyUI_QwenVL'
}
]
])
)
const fetchImpl = vi.fn(async () =>
response({
QwenDash: validNode({
name: 'QwenDash',
python_module: 'custom_nodes.ComfyUI-QwenVL.nodes'
}),
QwenUnder: validNode({
name: 'QwenUnder',
python_module: 'custom_nodes.ComfyUI_QwenVL.nodes'
})
})
)
const outcome = await fetchCloudNodesForBuild({
apiKey: KEY,
baseUrl: BASE_URL,
fetchImpl: fetchImpl as typeof fetch
})
expect(outcome.status).toBe('fresh')
if (outcome.status !== 'fresh') return
expect(outcome.snapshot.packs).toHaveLength(1)
expect(outcome.snapshot.packs[0]?.id).toBe('comfyui-qwenvl')
expect(outcome.snapshot.packs[0]?.registryId).toBe('ComfyUI_QwenVL')
expect(outcome.snapshot.packs[0]?.repoUrl).toBe(
'https://github.com/example/ComfyUI_QwenVL'
)
expect(fetchRegistryPacksMock).toHaveBeenCalledWith(
['ComfyUI-QwenVL', 'ComfyUI_QwenVL'],
expect.anything()
)
})
it('prefers the first non-null registry result when every alias resolves', async () => {
fetchRegistryPacksMock.mockResolvedValue(
new Map<string, unknown>([
[
'ComfyUI-QwenVL',
{
id: 'ComfyUI-QwenVL',
name: 'Dash Variant',
repository: 'https://github.com/example/dash-first'
}
],
[
'ComfyUI_QwenVL',
{
id: 'ComfyUI_QwenVL',
name: 'Underscore Variant',
repository: 'https://github.com/example/underscore-second'
}
]
])
)
const fetchImpl = vi.fn(async () =>
response({
QwenDash: validNode({
name: 'QwenDash',
python_module: 'custom_nodes.ComfyUI-QwenVL.nodes'
}),
QwenUnder: validNode({
name: 'QwenUnder',
python_module: 'custom_nodes.ComfyUI_QwenVL.nodes'
})
})
)
const outcome = await fetchCloudNodesForBuild({
apiKey: KEY,
baseUrl: BASE_URL,
fetchImpl: fetchImpl as typeof fetch
})
expect(outcome.status).toBe('fresh')
if (outcome.status !== 'fresh') return
expect(outcome.snapshot.packs).toHaveLength(1)
expect(outcome.snapshot.packs[0]?.registryId).toBe('ComfyUI-QwenVL')
expect(outcome.snapshot.packs[0]?.repoUrl).toBe(
'https://github.com/example/dash-first'
)
expect(fetchRegistryPacksMock).toHaveBeenCalledWith(
['ComfyUI-QwenVL', 'ComfyUI_QwenVL'],
expect.anything()
)
})
it('normalizes pack ids when reading a fallback snapshot', async () => {
const snapshotUrl = withSnapshotDir({
fetchedAt: '2026-04-01T00:00:00.000Z',
packs: [
{
id: 'ComfyUI-Crystools',
displayName: 'ComfyUI-Crystools',
nodes: [
{
name: 'CrystoolsNode',
displayName: 'Crystools Node',
category: 'x'
}
]
},
{
id: 'basic_data_handling',
displayName: 'basic_data_handling',
nodes: [
{ name: 'BasicNode', displayName: 'Basic Node', category: 'x' }
]
}
]
})
const outcome = await fetchCloudNodesForBuild({
snapshotUrl,
fetchImpl: vi.fn() as unknown as typeof fetch
})
expect(outcome.status).toBe('stale')
if (outcome.status !== 'stale') return
expect(outcome.snapshot.packs.map((p) => p.id)).toEqual([
'comfyui-crystools',
'basic-data-handling'
])
rmSync(new URL('.', snapshotUrl), { recursive: true, force: true })
})
it('merges packs in the fallback snapshot whose ids slugify to the same value', async () => {
const snapshotUrl = withSnapshotDir({
fetchedAt: '2026-04-01T00:00:00.000Z',
packs: [
{
id: 'ComfyUI-QwenVL',
displayName: 'ComfyUI QwenVL',
nodes: [{ name: 'A', displayName: 'A', category: 'x' }]
},
{
id: 'ComfyUI_QwenVL',
displayName: 'ComfyUI QwenVL',
nodes: [{ name: 'B', displayName: 'B', category: 'x' }]
}
]
})
const outcome = await fetchCloudNodesForBuild({
snapshotUrl,
fetchImpl: vi.fn() as unknown as typeof fetch
})
expect(outcome.status).toBe('stale')
if (outcome.status !== 'stale') return
expect(outcome.snapshot.packs).toHaveLength(1)
expect(outcome.snapshot.packs[0]?.id).toBe('comfyui-qwenvl')
expect(outcome.snapshot.packs[0]?.nodes.map((n) => n.name).sort()).toEqual([
'A',
'B'
])
rmSync(new URL('.', snapshotUrl), { recursive: true, force: true })
})
it('preserves optional metadata from later aliases when snapshot packs collide on slug', async () => {
const snapshotUrl = withSnapshotDir({
fetchedAt: '2026-04-01T00:00:00.000Z',
packs: [
{
id: 'ComfyUI-QwenVL',
displayName: 'ComfyUI QwenVL',
nodes: [{ name: 'A', displayName: 'A', category: 'x' }]
},
{
id: 'ComfyUI_QwenVL',
displayName: 'ComfyUI QwenVL',
registryId: 'ComfyUI_QwenVL',
description: 'rich description from the underscore variant',
repoUrl: 'https://github.com/example/ComfyUI_QwenVL',
publisher: { id: 'qwen-team', name: 'Qwen Team' },
downloads: 1234,
githubStars: 7,
license: 'MIT',
nodes: [{ name: 'B', displayName: 'B', category: 'x' }]
}
]
})
const outcome = await fetchCloudNodesForBuild({
snapshotUrl,
fetchImpl: vi.fn() as unknown as typeof fetch
})
expect(outcome.status).toBe('stale')
if (outcome.status !== 'stale') return
const merged = outcome.snapshot.packs[0]
expect(merged?.id).toBe('comfyui-qwenvl')
expect(merged?.registryId).toBe('ComfyUI_QwenVL')
expect(merged?.description).toBe(
'rich description from the underscore variant'
)
expect(merged?.repoUrl).toBe('https://github.com/example/ComfyUI_QwenVL')
expect(merged?.publisher).toEqual({ id: 'qwen-team', name: 'Qwen Team' })
expect(merged?.downloads).toBe(1234)
expect(merged?.githubStars).toBe(7)
expect(merged?.license).toBe('MIT')
rmSync(new URL('.', snapshotUrl), { recursive: true, force: true })
})
it('does not overwrite metadata already present on the first slug-collided pack', async () => {
const snapshotUrl = withSnapshotDir({
fetchedAt: '2026-04-01T00:00:00.000Z',
packs: [
{
id: 'ComfyUI-QwenVL',
displayName: 'first wins',
registryId: 'ComfyUI-QwenVL',
repoUrl: 'https://github.com/example/ComfyUI-QwenVL',
nodes: [{ name: 'A', displayName: 'A', category: 'x' }]
},
{
id: 'ComfyUI_QwenVL',
displayName: 'second loses',
registryId: 'ComfyUI_QwenVL',
repoUrl: 'https://github.com/example/ComfyUI_QwenVL',
nodes: [{ name: 'B', displayName: 'B', category: 'x' }]
}
]
})
const outcome = await fetchCloudNodesForBuild({
snapshotUrl,
fetchImpl: vi.fn() as unknown as typeof fetch
})
expect(outcome.status).toBe('stale')
if (outcome.status !== 'stale') return
const merged = outcome.snapshot.packs[0]
expect(merged?.displayName).toBe('first wins')
expect(merged?.registryId).toBe('ComfyUI-QwenVL')
expect(merged?.repoUrl).toBe('https://github.com/example/ComfyUI-QwenVL')
rmSync(new URL('.', snapshotUrl), { recursive: true, force: true })
})
})

View File

@@ -3,6 +3,7 @@ import { readFile } from 'node:fs/promises'
import {
groupNodesByPack,
sanitizeUserContent,
slugifyPackId,
validateComfyNodeDef
} from '@comfyorg/object-info-parser'
@@ -213,6 +214,13 @@ async function callOnce(
}
}
/**
* Parses and validates a raw cloud nodes envelope into domain packs, enriches packs with registry metadata when available, and collects validation failures.
*
* @param envelope - Raw payload object from the cloud API keyed by node class name containing node definitions to validate and parse.
* @param options - Fetch and behavior options used when resolving registry pack metadata (for example, `fetchImpl`).
* @returns The `'ok'` outcome containing `packs` (an array of domain `Pack` objects) and `droppedNodes` (an array of `{ name, reason }` entries for definitions that failed validation).
*/
async function parseCloudNodes(
envelope: Record<string, unknown>,
options: FetchCloudNodesOptions
@@ -237,12 +245,12 @@ async function parseCloudNodes(
)
const grouped = groupNodesByPack(sanitizedDefs)
const allAliases = grouped.flatMap((pack) => pack.rawIds)
let registryMap = new Map<string, RegistryPack | null>()
try {
registryMap = await fetchRegistryPacks(
grouped.map((pack) => pack.id),
{ fetchImpl: options.fetchImpl }
)
registryMap = await fetchRegistryPacks(allAliases, {
fetchImpl: options.fetchImpl
})
} catch {
registryMap = new Map()
}
@@ -250,15 +258,40 @@ async function parseCloudNodes(
const packs = grouped.map((pack) =>
toDomainPack(
pack.id,
pack.rawIds[0],
pack.displayName,
pack.nodes,
registryMap.get(pack.id)
pickRegistryPack(registryMap, pack.rawIds)
)
)
return { kind: 'ok', packs, droppedNodes }
}
/**
* Selects the most appropriate registry pack for a pack using its ordered aliases.
*
* @param registryMap - Map from alias to `RegistryPack` or explicit `null` indicating a known-but-empty entry
* @param aliases - Ordered aliases to probe; earlier aliases have higher priority
* @returns A `RegistryPack` if any alias maps to a non-null value; `null` if no alias had a non-null value but the first alias exists in the map with value `null`; `undefined` if the first alias is absent from the map
*/
function pickRegistryPack(
registryMap: Map<string, RegistryPack | null>,
aliases: readonly string[]
): RegistryPack | null | undefined {
for (const alias of aliases) {
const hit = registryMap.get(alias)
if (hit) return hit
}
return registryMap.get(aliases[0])
}
/**
* Validate and normalize an external URL string.
*
* @param value - The input URL string to validate; may be `undefined`.
* @returns The canonical `http` or `https` URL string if `value` is a valid absolute URL with a host, `undefined` otherwise.
*/
function safeExternalUrl(value: string | undefined): string | undefined {
if (!value) return undefined
try {
@@ -271,8 +304,19 @@ function safeExternalUrl(value: string | undefined): string | undefined {
}
}
/**
* Convert parsed pack data and optional registry metadata into a domain `Pack`.
*
* @param packId - The canonical identifier to use for the pack
* @param fallbackRegistryId - Registry id to use when `registryPack` does not provide one
* @param fallbackDisplayName - Display name to use when `registryPack` does not provide a name
* @param nodes - Array of node entries containing the class name and validated node definition
* @param registryPack - Optional registry metadata for enriching pack fields; may be `null` or `undefined`
* @returns A `Pack` with normalized fields, safe external URLs, optional publisher info, registry-derived metadata when available, and nodes converted to `PackNode` objects sorted by display name
*/
function toDomainPack(
packId: string,
fallbackRegistryId: string | undefined,
fallbackDisplayName: string,
nodes: Array<{
className: string
@@ -288,7 +332,7 @@ function toDomainPack(
): Pack {
return {
id: packId,
registryId: registryPack?.id,
registryId: registryPack?.id ?? fallbackRegistryId,
displayName: registryPack?.name?.trim() || fallbackDisplayName || packId,
description: registryPack?.description?.trim() || undefined,
bannerUrl: safeExternalUrl(registryPack?.banner_url),
@@ -334,22 +378,82 @@ function toDomainNode(
}
}
/**
* Load and validate a nodes snapshot from a provided file URL or from the bundled snapshot, normalizing pack IDs.
*
* If `snapshotUrl` is provided, reads the file, parses JSON, and returns the snapshot after `isNodesSnapshot` validation and `normalizeSnapshotIds` normalization.
* If `snapshotUrl` is omitted, validates and returns the bundled snapshot after normalization.
* Returns `null` if reading, parsing, or validation fails.
*
* @param snapshotUrl - Optional file `URL` pointing to a snapshot JSON; when omitted the bundled snapshot is used
* @returns The normalized `NodesSnapshot` if available and valid, `null` otherwise
*/
async function readSnapshot(
snapshotUrl: URL | undefined
): Promise<NodesSnapshot | null> {
if (!snapshotUrl) {
return isNodesSnapshot(bundledSnapshot) ? bundledSnapshot : null
return isNodesSnapshot(bundledSnapshot)
? normalizeSnapshotIds(bundledSnapshot)
: null
}
try {
const text = await readFile(snapshotUrl, 'utf8')
const parsed: unknown = JSON.parse(text)
if (isNodesSnapshot(parsed)) return parsed
if (isNodesSnapshot(parsed)) return normalizeSnapshotIds(parsed)
return null
} catch {
return null
}
}
/**
* Normalize pack IDs by slugifying each pack's `id`, omitting packs with empty slugs, and merging packs that produce the same slug.
*
* The returned snapshot preserves the original snapshot fields but replaces `packs` with a list whose `id` values are the slugified IDs. When multiple packs map to the same slug, their nodes and non-nullish metadata are merged into a single pack.
*
* @param snapshot - The snapshot whose pack IDs should be normalized and deduplicated
* @returns A new `NodesSnapshot` with pack IDs replaced by slugs, colliding packs merged, and packs with falsy slugs removed
*/
function normalizeSnapshotIds(snapshot: NodesSnapshot): NodesSnapshot {
const bySlug = new Map<string, Pack>()
for (const pack of snapshot.packs) {
const slug = slugifyPackId(pack.id)
if (!slug) continue
const existing = bySlug.get(slug)
if (existing) {
bySlug.set(slug, mergeCollidedPacks(existing, pack))
continue
}
bySlug.set(slug, { ...pack, id: slug })
}
return { ...snapshot, packs: [...bySlug.values()] }
}
/**
* Merge two packs that represent the same logical pack by concatenating their nodes and filling any missing metadata from the later pack.
*
* @param first - The base pack whose values take precedence.
* @param next - The colliding pack whose `nodes` are appended and whose non-null, non-`id` fields supply values only when `first` has them missing.
* @returns A new `Pack` whose `nodes` are `first.nodes` followed by `next.nodes`, with other fields taken from `first` unless absent, in which case the corresponding value from `next` is used.
*/
function mergeCollidedPacks(first: Pack, next: Pack): Pack {
const merged: Pack = { ...first, nodes: [...first.nodes, ...next.nodes] }
for (const [key, value] of Object.entries(next) as [keyof Pack, unknown][]) {
if (key === 'id' || key === 'nodes') continue
if (value === undefined || value === null) continue
if (merged[key] === undefined || merged[key] === null) {
;(merged as Record<keyof Pack, unknown>)[key] = value
}
}
return merged
}
/**
* Pause execution for the specified duration.
*
* @param ms - Duration to wait in milliseconds
* @returns A promise that resolves with no value when the delay has elapsed
*/
function defaultSleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}

View File

@@ -51,4 +51,67 @@ describe('groupNodesByPack', () => {
grouped.find((pack) => pack.id === 'comfyui-controlnet-aux')?.nodes
).toHaveLength(1)
})
it('slugifies pack ids to lowercase, hyphen-only URL slugs', () => {
const grouped = groupNodesByPack({
A: makeNodeDef('A', 'custom_nodes.ComfyUI-Crystools.nodes'),
B: makeNodeDef('B', 'custom_nodes.basic_data_handling.nodes'),
C: makeNodeDef('C', 'custom_nodes.ComfyUI_yanc.nodes')
})
expect(grouped.map((pack) => pack.id)).toEqual([
'basic-data-handling',
'comfyui-crystools',
'comfyui-yanc'
])
})
it('preserves the raw upstream id for registry lookups', () => {
const grouped = groupNodesByPack({
A: makeNodeDef('A', 'custom_nodes.ComfyUI-Crystools.nodes'),
B: makeNodeDef('B', 'custom_nodes.basic_data_handling.nodes')
})
expect(grouped.find((pack) => pack.id === 'comfyui-crystools')?.rawId).toBe(
'ComfyUI-Crystools'
)
expect(
grouped.find((pack) => pack.id === 'basic-data-handling')?.rawId
).toBe('basic_data_handling')
})
it('merges packs whose raw ids slugify to the same URL slug', () => {
const grouped = groupNodesByPack({
QwenA: makeNodeDef('QwenA', 'custom_nodes.ComfyUI-QwenVL.nodes'),
QwenB: makeNodeDef('QwenB', 'custom_nodes.ComfyUI_QwenVL.nodes')
})
expect(grouped).toHaveLength(1)
expect(grouped[0].id).toBe('comfyui-qwenvl')
expect(grouped[0].nodes.map((n) => n.className).sort()).toEqual([
'QwenA',
'QwenB'
])
expect(grouped[0].rawIds).toEqual(['ComfyUI-QwenVL', 'ComfyUI_QwenVL'])
})
it('does not record duplicate aliases when the same raw id appears twice', () => {
const grouped = groupNodesByPack({
QwenA: makeNodeDef('QwenA', 'custom_nodes.ComfyUI-QwenVL.nodes'),
QwenB: makeNodeDef('QwenB', 'custom_nodes.ComfyUI-QwenVL.nodes')
})
expect(grouped).toHaveLength(1)
expect(grouped[0].rawIds).toEqual(['ComfyUI-QwenVL'])
})
it('strips version suffix before slugifying', () => {
const grouped = groupNodesByPack({
A: makeNodeDef('A', 'custom_nodes.ComfyUI_yanc@1_0_3.nodes')
})
expect(grouped).toHaveLength(1)
expect(grouped[0].id).toBe('comfyui-yanc')
expect(grouped[0].rawId).toBe('ComfyUI_yanc')
})
})

View File

@@ -0,0 +1,52 @@
import { describe, expect, it } from 'vitest'
import { slugifyPackId } from '../helpers/slugifyPackId'
describe('slugifyPackId', () => {
it.for([
['comfyui-impact-pack', 'comfyui-impact-pack'],
['ComfyUI-Crystools', 'comfyui-crystools'],
['comfyui_impact_pack', 'comfyui-impact-pack'],
['ComfyUI_QwenVL', 'comfyui-qwenvl'],
['basic_data_handling', 'basic-data-handling'],
['ComfyUI_Step1X-Edit', 'comfyui-step1x-edit'],
['HunyuanVideo_Foley', 'hunyuanvideo-foley']
])('slugifies %s -> %s', ([input, expected]) => {
expect(slugifyPackId(input)).toBe(expected)
})
it('collapses runs of hyphens introduced by adjacent separators', () => {
expect(slugifyPackId('a__b')).toBe('a-b')
expect(slugifyPackId('a-_b')).toBe('a-b')
expect(slugifyPackId('a___-_b')).toBe('a-b')
})
it('strips leading and trailing separators', () => {
expect(slugifyPackId('_pack_')).toBe('pack')
expect(slugifyPackId('-pack-')).toBe('pack')
expect(slugifyPackId('__a__')).toBe('a')
})
it('produces URL-slug-safe output for every registry id observed today', () => {
const samples = [
'ComfyUI-AniPortrait',
'comfyui_aniportrait',
'ComfyUI-API-Manager',
'ComfyUI_API_Manager',
'comfy-oiio',
'comfy_oiio',
'ComfyUI-FlashVSR_Ultra_Fast',
'comfyui-frame-interpolation',
'Qwen3_TTS',
'qwen3-tts'
]
for (const sample of samples) {
expect(slugifyPackId(sample)).toMatch(/^[a-z0-9-]+$/)
}
})
it('returns the input unchanged when already a clean slug', () => {
expect(slugifyPackId('comfyui-impact-pack')).toBe('comfyui-impact-pack')
expect(slugifyPackId('rgthree-comfy')).toBe('rgthree-comfy')
})
})

View File

@@ -1,5 +1,6 @@
import { getNodeSource, NodeSourceType } from '../classifiers/nodeSource'
import type { ComfyNodeDef } from '../schemas/nodeDefSchema'
import { slugifyPackId } from './slugifyPackId'
export interface PackedNode {
className: string
@@ -8,10 +9,27 @@ export interface PackedNode {
export interface NodePack {
id: string
rawId: string
rawIds: string[]
displayName: string
nodes: PackedNode[]
}
/**
* Group custom Comfy node definitions into packs keyed by a slugified pack identifier.
*
* Processes the provided node definitions, selects those identified as custom nodes, extracts
* a raw pack identifier from each definition's `python_module`, converts it to a slug, and
* aggregates nodes that share the same slug into a single `NodePack`.
*
* @param defs - Map of class name to `ComfyNodeDef` objects to be grouped
* @returns An array of `NodePack` objects sorted by `id` (ascending). Each `NodePack` includes:
* - `id`: the slugified pack identifier
* - `rawId`: the raw identifier extracted from a representative node's `python_module`
* - `rawIds`: all distinct raw identifiers that were mapped to the same slug
* - `displayName`: display text taken from the node source metadata
* - `nodes`: the list of packed node entries (`{ className, def }`)
*/
export function groupNodesByPack(
defs: Record<string, ComfyNodeDef>
): NodePack[] {
@@ -23,21 +41,31 @@ export function groupNodesByPack(
continue
}
const packId = def.python_module.split('.')[1]?.split('@')[0]
if (!packId) {
const rawId = def.python_module.split('.')[1]?.split('@')[0]
if (!rawId) {
continue
}
const existing = byPackId.get(packId)
const slug = slugifyPackId(rawId)
if (!slug) {
continue
}
const existing = byPackId.get(slug)
const node = { className, def }
if (existing) {
existing.nodes.push(node)
if (!existing.rawIds.includes(rawId)) {
existing.rawIds.push(rawId)
}
continue
}
byPackId.set(packId, {
id: packId,
byPackId.set(slug, {
id: slug,
rawId,
rawIds: [rawId],
displayName: source.displayText,
nodes: [node]
})

View File

@@ -0,0 +1,34 @@
/**
* Normalize a custom-node pack identifier into a URL-safe slug.
*
* Pack ids originate from Python module names exposed by ComfyUI and the
* Comfy custom-node registry. The upstream names mix three conventions
* freely: kebab-case (`comfyui-impact-pack`), snake_case
* (`comfyui_impact_pack`), and PascalCase (`ComfyUI-Crystools`). Using
* those raw strings as URL segments produces routes that are inconsistent
* across packs and fail the website's `[a-z0-9-]+` slug contract.
*
* `slugifyPackId` produces a deterministic, lowercase, hyphen-only slug
* suitable for use as a URL segment and as an `Astro.params` value. It
* does NOT replace the raw id used for registry lookups; callers that
* need to query the registry API must keep the raw `node_id` separately.
*
* The transformation is intentionally narrow:
* - lowercase
* - replace `_` with `-`
* - collapse runs of `-` to a single `-`
* - strip leading / trailing `-`
*
* Any other character (digits, letters, `-`) is preserved verbatim so
* legitimate registry ids like `comfyui-flashvsr-ultra-fast` survive
* untouched. The output is guaranteed to match `/^[a-z0-9-]+$/` as long
* as the input only contains ASCII letters, digits, `_`, and `-` — which
* is the case for every pack id observed in the registry today.
*/
export function slugifyPackId(rawId: string): string {
return rawId
.toLowerCase()
.replace(/_/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '')
}

View File

@@ -2,3 +2,4 @@ export * from './schemas/nodeDefSchema'
export * from './classifiers/nodeSource'
export * from './helpers/groupNodesByPack'
export * from './helpers/sanitizeUserContent'
export * from './helpers/slugifyPackId'