mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-22 21:38:52 +00:00
Compare commits
7 Commits
jaewon/fix
...
glary/webs
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
764977aaf6 | ||
|
|
669bd5dbec | ||
|
|
c753f30a5f | ||
|
|
1c5a0079f0 | ||
|
|
df3c3c7efc | ||
|
|
b58d24403b | ||
|
|
30c106d972 |
1
.github/workflows/ci-website-e2e.yaml
vendored
1
.github/workflows/ci-website-e2e.yaml
vendored
@@ -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
|
||||
|
||||
156
apps/website/e2e/fixtures/cloud-nodes.fixture.json
Normal file
156
apps/website/e2e/fixtures/cloud-nodes.fixture.json
Normal 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."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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}`
|
||||
|
||||
@@ -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 })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,6 +3,7 @@ import { readFile } from 'node:fs/promises'
|
||||
import {
|
||||
groupNodesByPack,
|
||||
sanitizeUserContent,
|
||||
slugifyPackId,
|
||||
validateComfyNodeDef
|
||||
} from '@comfyorg/object-info-parser'
|
||||
|
||||
@@ -237,12 +238,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 +251,27 @@ 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 }
|
||||
}
|
||||
|
||||
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])
|
||||
}
|
||||
|
||||
function safeExternalUrl(value: string | undefined): string | undefined {
|
||||
if (!value) return undefined
|
||||
try {
|
||||
@@ -273,6 +286,7 @@ function safeExternalUrl(value: string | undefined): string | undefined {
|
||||
|
||||
function toDomainPack(
|
||||
packId: string,
|
||||
fallbackRegistryId: string | undefined,
|
||||
fallbackDisplayName: string,
|
||||
nodes: Array<{
|
||||
className: string
|
||||
@@ -288,7 +302,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),
|
||||
@@ -338,18 +352,47 @@ 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) {
|
||||
bySlug.set(slug, mergeCollidedPacks(existing, pack))
|
||||
continue
|
||||
}
|
||||
bySlug.set(slug, { ...pack, id: slug })
|
||||
}
|
||||
return { ...snapshot, packs: [...bySlug.values()] }
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
function defaultSleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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,8 @@ export interface PackedNode {
|
||||
|
||||
export interface NodePack {
|
||||
id: string
|
||||
rawId: string
|
||||
rawIds: string[]
|
||||
displayName: string
|
||||
nodes: PackedNode[]
|
||||
}
|
||||
@@ -23,21 +26,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]
|
||||
})
|
||||
|
||||
34
packages/object-info-parser/src/helpers/slugifyPackId.ts
Normal file
34
packages/object-info-parser/src/helpers/slugifyPackId.ts
Normal 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, '')
|
||||
}
|
||||
@@ -2,3 +2,4 @@ export * from './schemas/nodeDefSchema'
|
||||
export * from './classifiers/nodeSource'
|
||||
export * from './helpers/groupNodesByPack'
|
||||
export * from './helpers/sanitizeUserContent'
|
||||
export * from './helpers/slugifyPackId'
|
||||
|
||||
Reference in New Issue
Block a user