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
This commit is contained in:
Glary-Bot
2026-05-14 19:54:30 +00:00
parent f090ea3d28
commit 30c106d972
12 changed files with 528 additions and 20 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,10 +10,21 @@ 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))
function isProductionBuild(): boolean {
return process.env.VERCEL_ENV === 'production'
}
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.
*
@@ -23,9 +37,18 @@ function isProductionBuild(): boolean {
* 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.
*
* Setting `WEBSITE_CLOUD_NODES_FIXTURE=<path>` overrides the bundled
* snapshot with a fixture file on disk. This is used by the e2e build
* step in CI so Playwright assertions can be written against deterministic
* pack content instead of whatever the upstream registry happens to expose
* at the moment of the test run. The override never fires the live cloud
* API; the fixture path goes straight to the snapshot-fallback branch.
*/
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 +57,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

@@ -306,4 +306,112 @@ describe('fetchCloudNodesForBuild', () => {
})
expect(outcome.status).toBe('fresh')
})
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('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 })
})
})

View File

@@ -3,6 +3,7 @@ import { readFile } from 'node:fs/promises'
import {
groupNodesByPack,
sanitizeUserContent,
slugifyPackId,
validateComfyNodeDef
} from '@comfyorg/object-info-parser'
@@ -240,7 +241,7 @@ async function parseCloudNodes(
let registryMap = new Map<string, RegistryPack | null>()
try {
registryMap = await fetchRegistryPacks(
grouped.map((pack) => pack.id),
grouped.map((pack) => pack.rawId),
{ fetchImpl: options.fetchImpl }
)
} catch {
@@ -252,7 +253,7 @@ async function parseCloudNodes(
pack.id,
pack.displayName,
pack.nodes,
registryMap.get(pack.id)
registryMap.get(pack.rawId)
)
)
@@ -338,18 +339,35 @@ 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
}
}
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) {
existing.nodes = [...existing.nodes, ...pack.nodes]
continue
}
bySlug.set(slug, { ...pack, id: slug })
}
return { ...snapshot, packs: [...bySlug.values()] }
}
function defaultSleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}

View File

@@ -51,4 +51,56 @@ 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'
])
})
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,6 +9,7 @@ export interface PackedNode {
export interface NodePack {
id: string
rawId: string
displayName: string
nodes: PackedNode[]
}
@@ -23,12 +25,17 @@ 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) {
@@ -36,8 +43,9 @@ export function groupNodesByPack(
continue
}
byPackId.set(packId, {
id: packId,
byPackId.set(slug, {
id: slug,
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'