@heyclaude/mcp 0.1.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,8 +1,4 @@
1
1
  export const SUBMISSION_SITE_URL = "https://heyclau.de/submit";
2
- export const GITHUB_NEW_ISSUE_URL =
3
- "https://github.com/JSONbored/awesome-claude/issues/new";
4
-
5
- const defaultLabels = ["content-submission", "needs-review"];
6
2
 
7
3
  function normalizeText(value) {
8
4
  return String(value || "").trim();
@@ -78,28 +74,31 @@ function isHttpsUrl(value) {
78
74
  }
79
75
  }
80
76
 
77
+ const TRACKING_QUERY_KEYS = new Set([
78
+ "aff",
79
+ "affiliate",
80
+ "affiliate_id",
81
+ "campaign",
82
+ "coupon",
83
+ "irclickid",
84
+ "partner",
85
+ "referral",
86
+ "referral_code",
87
+ "via",
88
+ ]);
89
+
90
+ function isTrackingQueryKey(key) {
91
+ const normalized = key.trim().toLowerCase();
92
+ return normalized.startsWith("utm_") || TRACKING_QUERY_KEYS.has(normalized);
93
+ }
94
+
81
95
  function isLikelyAffiliateUrl(value) {
82
96
  const trimmed = normalizeText(value);
83
97
  if (!trimmed) return false;
84
98
  try {
85
99
  const url = new URL(trimmed);
86
- const affiliateParams = new Set([
87
- "aff",
88
- "affiliate",
89
- "affiliate_id",
90
- "campaign",
91
- "coupon",
92
- "irclickid",
93
- "partner",
94
- "referral",
95
- "referral_code",
96
- "via",
97
- ]);
98
100
  for (const key of url.searchParams.keys()) {
99
- const normalized = key.trim().toLowerCase();
100
- if (normalized.startsWith("utm_") || affiliateParams.has(normalized)) {
101
- return true;
102
- }
101
+ if (isTrackingQueryKey(key)) return true;
103
102
  }
104
103
  } catch {
105
104
  return false;
@@ -166,6 +165,32 @@ function compactWhitespace(value) {
166
165
  return output.trim();
167
166
  }
168
167
 
168
+ function splitList(value) {
169
+ const items = [];
170
+ let current = "";
171
+ for (const char of String(value || "")) {
172
+ if (char === "\n" || char === ",") {
173
+ const next = current.trim();
174
+ if (next) items.push(next);
175
+ current = "";
176
+ continue;
177
+ }
178
+ current += char;
179
+ }
180
+ const next = current.trim();
181
+ if (next) items.push(next);
182
+ return items;
183
+ }
184
+
185
+ function isIsoDate(value) {
186
+ const text = normalizeText(value);
187
+ if (text.length !== 10) return false;
188
+ return [...text].every((char, index) => {
189
+ if (index === 4 || index === 7) return char === "-";
190
+ return char >= "0" && char <= "9";
191
+ });
192
+ }
193
+
169
194
  function tagsToText(value) {
170
195
  return Array.isArray(value)
171
196
  ? value.map(normalizeText).filter(Boolean).join(", ")
@@ -217,14 +242,6 @@ function modelFor(spec, category) {
217
242
  return spec?.categories?.[category] || null;
218
243
  }
219
244
 
220
- function templateFor(spec, category) {
221
- return spec?.issueTemplates?.[category] || null;
222
- }
223
-
224
- function labelsFor(spec, category) {
225
- return templateFor(spec, category)?.labels || defaultLabels;
226
- }
227
-
228
245
  function requiredFields(model) {
229
246
  return (model?.fields || [])
230
247
  .filter((field) => field.required)
@@ -235,6 +252,23 @@ function fieldLabels(model) {
235
252
  return new Map((model?.fields || []).map((field) => [field.id, field.label]));
236
253
  }
237
254
 
255
+ function fieldOptions(model, fieldId) {
256
+ const field = (model?.fields || []).find((item) => item.id === fieldId);
257
+ return new Set((field?.options || []).map(normalizeLower).filter(Boolean));
258
+ }
259
+
260
+ function validateSelectOptions(model, normalized, errors) {
261
+ for (const field of model?.fields || []) {
262
+ if (!Array.isArray(field.options) || !field.options.length) continue;
263
+ const value = normalizeLower(normalized[field.id]);
264
+ if (!value) continue;
265
+ const validValues = new Set(field.options.map(normalizeLower));
266
+ if (!validValues.has(value)) {
267
+ errors.push(`Invalid ${field.id}: ${value}`);
268
+ }
269
+ }
270
+ }
271
+
238
272
  function selectedCategory(spec, category) {
239
273
  const normalized = normalizeLower(category);
240
274
  return categoryKeys(spec).includes(normalized) ? normalized : "";
@@ -300,12 +334,21 @@ function validateAgainstSpec(spec, fields = {}) {
300
334
  if (normalized.brand_domain && !isCanonicalDomain(normalized.brand_domain)) {
301
335
  errors.push("brand_domain must be a canonical domain such as asana.com.");
302
336
  }
337
+
338
+ validateSelectOptions(model, normalized, errors);
339
+
303
340
  if (
304
341
  category === "skills" &&
305
342
  !normalizeText(normalized.install_command) &&
306
- !normalizeText(normalized.download_url)
343
+ !normalizeText(normalized.download_url) &&
344
+ !normalizeText(normalized.github_url) &&
345
+ !normalizeText(normalized.docs_url) &&
346
+ !normalizeText(normalized.full_copyable_content) &&
347
+ !normalizeText(normalized.retrieval_sources)
307
348
  ) {
308
- errors.push("Skills submissions require install_command or download_url.");
349
+ errors.push(
350
+ "Skills submissions require install_command, source URL, retrieval_sources, or full_copyable_content.",
351
+ );
309
352
  }
310
353
  if (category === "collections" && !normalizeText(normalized.items)) {
311
354
  errors.push("Collections submissions require items.");
@@ -313,6 +356,32 @@ function validateAgainstSpec(spec, fields = {}) {
313
356
  if (category === "guides" && !normalizeText(normalized.guide_content)) {
314
357
  errors.push("Guide submissions require guide_content.");
315
358
  }
359
+ if (category === "skills") {
360
+ const skillType = normalizeLower(normalized.skill_type);
361
+ const skillLevel = normalizeLower(normalized.skill_level);
362
+ const verifiedAt = normalizeText(normalized.verified_at);
363
+ const retrievalSources = normalizeText(normalized.retrieval_sources);
364
+ if (verifiedAt && !isIsoDate(verifiedAt)) {
365
+ errors.push("verified_at must use YYYY-MM-DD format.");
366
+ }
367
+ if (skillType === "capability-pack") {
368
+ if (!verifiedAt)
369
+ errors.push("capability-pack skills require verified_at.");
370
+ if (!retrievalSources) {
371
+ errors.push("capability-pack skills require retrieval_sources.");
372
+ }
373
+ if (skillLevel && skillLevel !== "expert") {
374
+ errors.push("capability-pack skills must use skill_level=expert.");
375
+ }
376
+ }
377
+ if (retrievalSources) {
378
+ for (const url of splitList(retrievalSources)) {
379
+ if (!isHttpsUrl(url)) {
380
+ errors.push(`retrieval_sources must use https URLs: ${url}`);
381
+ }
382
+ }
383
+ }
384
+ }
316
385
  if (!normalized.github_url && !normalized.docs_url) {
317
386
  warnings.push("No github_url/docs_url provided.");
318
387
  }
@@ -331,7 +400,7 @@ function validateAgainstSpec(spec, fields = {}) {
331
400
  };
332
401
  }
333
402
 
334
- export function buildIssueDraftFromSpec(spec, fields = {}) {
403
+ export function buildPrDraftFromSpec(spec, fields = {}) {
335
404
  const validation = validateAgainstSpec(spec, fields);
336
405
  const category = validation.category;
337
406
  const model = modelFor(spec, category);
@@ -362,9 +431,8 @@ export function buildIssueDraftFromSpec(spec, fields = {}) {
362
431
  const label = modelLabel.endsWith("s") ? modelLabel.slice(0, -1) : modelLabel;
363
432
 
364
433
  return {
365
- title: `Submit ${label}: ${validation.normalized.name || "New directory entry"}`,
434
+ title: `Add ${label}: ${validation.normalized.name || "New directory entry"}`,
366
435
  body,
367
- labels: labelsFor(spec, category),
368
436
  };
369
437
  }
370
438
 
@@ -377,26 +445,16 @@ export function buildSubmissionUrlsFromSpec(spec, args = {}) {
377
445
  const fields = normalizeSubmissionFields(args.fields || {});
378
446
  const validation = validateAgainstSpec(spec, fields);
379
447
  const category = validation.category || fields.category || "";
380
- const issueDraft = buildIssueDraftFromSpec(spec, fields);
381
- const template = templateFor(spec, category)?.template || "submit-entry.md";
382
-
448
+ const prDraft = buildPrDraftFromSpec(spec, fields);
383
449
  const submitUrl = new URL(SUBMISSION_SITE_URL);
384
- const issueUrl = new URL(GITHUB_NEW_ISSUE_URL);
385
- issueUrl.searchParams.set("template", template);
386
450
 
387
451
  for (const [key, value] of Object.entries(fields)) {
388
452
  if (key === "source_url" && (fields.github_url || fields.docs_url))
389
453
  continue;
390
454
  setParam(submitUrl.searchParams, key, value);
391
- if (key !== "source_url") setParam(issueUrl.searchParams, key, value);
392
455
  }
393
456
  if (category) {
394
457
  submitUrl.searchParams.set("category", category);
395
- issueUrl.searchParams.set("category", category);
396
- }
397
- if (fields.name) {
398
- issueUrl.searchParams.set("title", issueDraft.title);
399
- issueUrl.searchParams.set("name", fields.name);
400
458
  }
401
459
 
402
460
  return {
@@ -405,17 +463,17 @@ export function buildSubmissionUrlsFromSpec(spec, args = {}) {
405
463
  category,
406
464
  slug: fields.slug || "",
407
465
  submitUrl: submitUrl.toString(),
408
- githubIssueUrl: issueUrl.toString(),
409
- issueDraft: args.includeIssueBody
410
- ? issueDraft
411
- : { title: issueDraft.title, labels: issueDraft.labels },
466
+ reviewUrl: submitUrl.toString(),
467
+ prDraft: args.includePrBody ? prDraft : { title: prDraft.title },
412
468
  validation: {
413
469
  errors: validation.errors,
414
470
  warnings: validation.warnings,
415
471
  missingRequiredFields: validation.missingRequiredFields || [],
416
472
  },
417
473
  reviewModel:
418
- "Issue-first: maintainers review accepted submissions before an import PR is opened.",
474
+ "PR-first: source-backed submissions are opened as single-entry PRs. Content-only PRs may be merged automatically after content validation, Superagent, and private maintainer-agent review pass.",
475
+ artifactPolicy:
476
+ "Community ZIP/MCPB artifacts are review material only and are not published as HeyClaude-hosted downloads.",
419
477
  };
420
478
  }
421
479
 
@@ -437,16 +495,14 @@ export function getSubmissionSchemaFromSpec(spec, args = {}) {
437
495
  categories: categoryKeys(spec),
438
496
  category: category || "",
439
497
  schema: category ? spec.categories[category] : spec.categories,
440
- issueTemplate: category
441
- ? spec.issueTemplates?.[category]
442
- : spec.issueTemplates,
498
+ prIntake: spec.prIntake || null,
443
499
  };
444
500
  }
445
501
 
446
502
  export function validateSubmissionDraftFromSpec(spec, args = {}) {
447
503
  const validation = validateAgainstSpec(spec, args.fields || {});
448
- const issueDraft = validation.category
449
- ? buildIssueDraftFromSpec(spec, validation.normalized)
504
+ const prDraft = validation.category
505
+ ? buildPrDraftFromSpec(spec, validation.normalized)
450
506
  : null;
451
507
 
452
508
  return {
@@ -459,30 +515,368 @@ export function validateSubmissionDraftFromSpec(spec, args = {}) {
459
515
  missingRequiredFields: validation.missingRequiredFields || [],
460
516
  errors: validation.errors,
461
517
  warnings: validation.warnings,
462
- issuePreview: issueDraft
463
- ? { title: issueDraft.title, labels: issueDraft.labels }
518
+ prPreview: prDraft
519
+ ? {
520
+ title: prDraft.title,
521
+ }
464
522
  : null,
465
523
  nextSteps: validation.valid
466
524
  ? [
467
525
  "Check for duplicate registry entries.",
468
- "Open the generated HeyClaude submit URL or GitHub issue URL.",
469
- "Maintainers review accepted submissions before an import PR is opened.",
526
+ "Open the generated HeyClaude submit URL and continue with GitHub.",
527
+ "Source-backed, non-artifact submissions are reviewed through the private PR gate.",
528
+ "Eligible content PRs may be merged automatically after validation, Superagent, and private maintainer-agent review pass.",
529
+ ]
530
+ : ["Fix validation errors before opening a public submission PR."],
531
+ artifactPolicy:
532
+ "Do not request HeyClaude /downloads hosting for community-submitted ZIP/MCPB artifacts.",
533
+ };
534
+ }
535
+
536
+ function singularLabel(value) {
537
+ const label = normalizeText(value || "Entry");
538
+ return label.endsWith("s") ? label.slice(0, -1) : label;
539
+ }
540
+
541
+ function exampleValueForField(fieldId, category, label) {
542
+ switch (fieldId) {
543
+ case "name":
544
+ case "title":
545
+ return `Example ${singularLabel(label)}`;
546
+ case "slug":
547
+ return `example-${category || "entry"}`;
548
+ case "category":
549
+ return category;
550
+ case "github_url":
551
+ return `https://github.com/example/example-${category || "entry"}`;
552
+ case "docs_url":
553
+ case "source_url":
554
+ return `https://example.com/${category || "entry"}/docs`;
555
+ case "download_url":
556
+ return `https://example.com/${category || "entry"}/download.zip`;
557
+ case "brand_name":
558
+ return "Example";
559
+ case "brand_domain":
560
+ return "example.com";
561
+ case "author":
562
+ return "@example";
563
+ case "contact_email":
564
+ return "@example";
565
+ case "tags":
566
+ return "claude, workflow, example";
567
+ case "description":
568
+ return `A practical ${singularLabel(label).toLowerCase()} for Claude users that includes source-backed setup and usage details.`;
569
+ case "card_description":
570
+ return `Practical ${singularLabel(label).toLowerCase()} for Claude workflows.`;
571
+ case "full_copyable_content":
572
+ return `# Example ${singularLabel(label)}\n\nUse this complete content as the copyable public artifact.`;
573
+ case "install_command":
574
+ return `npx -y example-${category || "entry"}`;
575
+ case "usage_snippet":
576
+ return `Use this ${singularLabel(label).toLowerCase()} to speed up a Claude workflow.`;
577
+ case "command_syntax":
578
+ return `/example-${category || "entry"} <input>`;
579
+ case "trigger":
580
+ return "PostToolUse";
581
+ case "guide_content":
582
+ return `# Example ${singularLabel(label)}\n\nExplain the setup, usage, verification, and troubleshooting steps.`;
583
+ case "items":
584
+ return `- Example ${singularLabel(label)} item\n- Source-backed companion resource`;
585
+ case "script_language":
586
+ return "bash";
587
+ case "skill_type":
588
+ return "workflow";
589
+ case "skill_level":
590
+ return "intermediate";
591
+ case "verification_status":
592
+ return "validated";
593
+ case "verified_at":
594
+ return "2026-05-17";
595
+ case "config_snippet":
596
+ return JSON.stringify({ example: true }, null, 2);
597
+ case "retrieval_sources":
598
+ return "- https://example.com/docs";
599
+ case "tested_platforms":
600
+ return "Claude Code, Codex, Cursor";
601
+ case "prerequisites":
602
+ return "- Node.js 20+\n- Claude-compatible MCP client";
603
+ case "safety_notes":
604
+ return "- Runs user-configured code with local workspace permissions";
605
+ case "privacy_notes":
606
+ return "- Reads local project files and may send selected context to the configured API";
607
+ case "troubleshooting_section":
608
+ return "If setup fails, verify the install command and source URL first.";
609
+ case "installation_order":
610
+ return "Install dependencies, configure the entry, then run the verification step.";
611
+ case "estimated_setup_time":
612
+ return "10 minutes";
613
+ case "difficulty":
614
+ return "intermediate";
615
+ default:
616
+ return `Example ${fieldId.replaceAll("_", " ")}`;
617
+ }
618
+ }
619
+
620
+ function exampleForCategory(spec, category) {
621
+ const model = modelFor(spec, category);
622
+ const fields = Object.fromEntries(
623
+ (model?.fields || []).map((field) => [
624
+ field.id,
625
+ exampleValueForField(field.id, category, model?.label),
626
+ ]),
627
+ );
628
+ fields.category = category;
629
+
630
+ const minimalFields = {};
631
+ for (const field of requiredFields(model)) {
632
+ minimalFields[field] = fields[field];
633
+ }
634
+ minimalFields.category = category;
635
+
636
+ for (const field of [
637
+ "name",
638
+ "description",
639
+ "github_url",
640
+ "docs_url",
641
+ "install_command",
642
+ "usage_snippet",
643
+ "download_url",
644
+ "full_copyable_content",
645
+ "guide_content",
646
+ "items",
647
+ ]) {
648
+ if (fields[field]) minimalFields[field] = fields[field];
649
+ }
650
+
651
+ return {
652
+ category,
653
+ label: model?.label || category,
654
+ template: model?.template || "",
655
+ requiredFields: requiredFields(model),
656
+ minimalFields,
657
+ completeFields: fields,
658
+ notes: [
659
+ "Use canonical source URLs and avoid affiliate/referral links.",
660
+ "The generated PR draft can become a reviewable submission after gates pass, but it is not automatic publication.",
661
+ "Community package archives are quarantine/review material, not public HeyClaude-hosted downloads.",
662
+ ],
663
+ };
664
+ }
665
+
666
+ export function getSubmissionExamplesFromSpec(spec, args = {}) {
667
+ const category = selectedCategory(spec, args.category);
668
+ if (args.category && !category) {
669
+ return {
670
+ ok: false,
671
+ error: {
672
+ code: "not_found",
673
+ message: `No HeyClaude submission examples found for ${args.category}.`,
674
+ },
675
+ };
676
+ }
677
+
678
+ const categories = category ? [category] : categoryKeys(spec);
679
+ return {
680
+ ok: true,
681
+ categories: categories.map((key) => exampleForCategory(spec, key)),
682
+ reviewModel:
683
+ "Examples are draft helpers only; maintainers review generated PRs before merge.",
684
+ };
685
+ }
686
+
687
+ export function prepareSubmissionDraftFromSpec(spec, args = {}) {
688
+ const fields = normalizeSubmissionFields(args.fields || {});
689
+ const validation = validateAgainstSpec(spec, fields);
690
+ const prDraft = validation.category
691
+ ? buildPrDraftFromSpec(spec, validation.normalized)
692
+ : null;
693
+ const urls = buildSubmissionUrlsFromSpec(spec, {
694
+ fields: validation.normalized,
695
+ includePrBody: true,
696
+ });
697
+
698
+ return {
699
+ ok: true,
700
+ valid: validation.valid,
701
+ category: validation.category,
702
+ slug: validation.normalized.slug || "",
703
+ normalizedFields: validation.normalized,
704
+ requiredFields: validation.requiredFields || [],
705
+ missingRequiredFields: validation.missingRequiredFields || [],
706
+ errors: validation.errors,
707
+ warnings: validation.warnings,
708
+ prDraft: prDraft ? { ...prDraft } : null,
709
+ submitUrl: urls.submitUrl,
710
+ reviewUrl: urls.reviewUrl,
711
+ reviewChecklist: [
712
+ "Confirm category fit and required fields before opening the PR.",
713
+ "Check for existing registry entries with the same source, slug, or title.",
714
+ "Verify source URLs, install commands, and copied content before maintainer approval.",
715
+ "Add safety_notes/privacy_notes when a submission runs code, handles credentials, reads local data, writes externally, or uses background workers.",
716
+ "Use source-backed or copyable-content submissions; do not request public /downloads hosting for community ZIPs.",
717
+ "Disclose paid, sponsored, affiliate, or commercial content separately from free community submissions.",
718
+ ],
719
+ submissionPolicy:
720
+ "This tool prepares a PR-first submission draft only. Eligible content-only PRs may be merged automatically after content validation, Superagent, and private maintainer-agent review pass; platform, workflow, package, and generated-artifact changes are never auto-merged by this path.",
721
+ artifactPolicy:
722
+ "Community ZIP/MCPB artifacts are quarantine/review material only. Maintainer-built packages are the only HeyClaude-hosted downloads.",
723
+ };
724
+ }
725
+
726
+ export function reviewSubmissionDraftFromSpec(spec, args = {}, entries = []) {
727
+ const validation = validateAgainstSpec(spec, args.fields || {});
728
+ const duplicateArgs = {
729
+ category: validation.category,
730
+ slug: validation.normalized.slug,
731
+ title: validation.normalized.title || validation.normalized.name,
732
+ name: validation.normalized.name,
733
+ // Forward every candidate URL the submission carries rather than picking a
734
+ // single winner via `||` — see `collectSubmissionCandidateUrls`. A
735
+ // submission with both `github_url` and `docs_url` was previously dropping
736
+ // the second candidate from duplicate detection.
737
+ sourceUrls: [
738
+ validation.normalized.github_url,
739
+ validation.normalized.docs_url,
740
+ validation.normalized.source_url,
741
+ validation.normalized.download_url,
742
+ validation.normalized.website_url,
743
+ ].filter(Boolean),
744
+ brandDomain: validation.normalized.brand_domain,
745
+ limit: args.duplicateLimit || 5,
746
+ };
747
+ const duplicates = searchDuplicateEntries(entries, duplicateArgs);
748
+ const recommendedAction = validation.valid
749
+ ? duplicates.count > 0
750
+ ? "review_possible_duplicate"
751
+ : "open_review_pr"
752
+ : "fix_required_fields";
753
+
754
+ return {
755
+ ok: true,
756
+ valid: validation.valid,
757
+ category: validation.category,
758
+ slug: validation.normalized.slug || "",
759
+ recommendedAction,
760
+ errors: validation.errors,
761
+ warnings: validation.warnings,
762
+ missingRequiredFields: validation.missingRequiredFields || [],
763
+ duplicateReview: duplicates,
764
+ reviewChecklist: [
765
+ "Schema-valid is not publish-valid; maintainer review is still required.",
766
+ "Check category fit against the actual artifact, not only the submitter's selected category.",
767
+ "Verify source availability, install commands, and any credential/payment-sensitive behavior.",
768
+ "Keep packages source-backed unless maintainers explicitly rebuild and verify a first-party artifact.",
769
+ "Reject or request edits for affiliate/referral links, unsupported claims, or unclear paid/sponsored positioning.",
770
+ ],
771
+ nextSteps: validation.valid
772
+ ? [
773
+ "Open or update the canonical single-entry GitHub submission PR.",
774
+ "Let the private gate apply review labels after source, duplicate, and safety checks.",
470
775
  ]
471
- : ["Fix validation errors before opening a public submission issue."],
776
+ : ["Fix required fields and validation errors before opening a PR."],
777
+ artifactPolicy:
778
+ "Community-submitted ZIP/MCPB packages must not be treated as trusted public downloads.",
472
779
  };
473
780
  }
474
781
 
475
- function entrySourceUrls(entry) {
476
- return [
782
+ // Normalize a URL for duplicate-detection matching. Lowercases scheme + host,
783
+ // strips `www.`, drops `#hash`, drops `utm_*` and known affiliate/tracking
784
+ // query params (so links shared with marketing parameters still match the
785
+ // canonical form), and strips a trailing `/` on non-root paths. Returns "" for
786
+ // unparseable input — unparseable URLs simply don't match anything, which is
787
+ // safe (the slug/title/brand_domain matches still run).
788
+ function normalizeSubmissionUrlForMatch(value) {
789
+ const trimmed = normalizeText(value);
790
+ if (!trimmed) return "";
791
+ let url;
792
+ try {
793
+ url = new URL(trimmed);
794
+ } catch {
795
+ return "";
796
+ }
797
+ url.protocol = url.protocol.toLowerCase();
798
+ url.hostname = url.hostname.toLowerCase().replace(/^www\./, "");
799
+ url.hash = "";
800
+ const droppedKeys = [];
801
+ for (const key of url.searchParams.keys()) {
802
+ if (isTrackingQueryKey(key)) droppedKeys.push(key);
803
+ }
804
+ for (const key of droppedKeys) url.searchParams.delete(key);
805
+ // Normalize the pathname before serializing so a trailing `/` is stripped on
806
+ // non-root paths regardless of any query string. Checking the serialized
807
+ // string's `endsWith("/")` misses cases like
808
+ // `https://example.com/foo/?ref=x`, where the slash sits before the query and
809
+ // is never the last character — leaving it would break the match against the
810
+ // canonical `https://example.com/foo?ref=x`.
811
+ if (url.pathname !== "/" && url.pathname.endsWith("/")) {
812
+ url.pathname = url.pathname.slice(0, -1);
813
+ }
814
+ return url.toString();
815
+ }
816
+
817
+ // Every external-publisher URL field the indexed entry may carry. Includes
818
+ // `downloadUrl`, `websiteUrl`, `githubUrl`, and `trustSignals.sourceUrls` —
819
+ // each is a real entry field today and a duplicate that lives only on one of
820
+ // them must still match a submitter's URL. Internal `url`/`canonicalUrl`/
821
+ // `llmsUrl`/`apiUrl` stay in the list because a submitter who pastes the
822
+ // HeyClaude canonical URL of an existing entry is still describing the same
823
+ // resource and should be flagged as a duplicate.
824
+ function collectEntrySourceUrls(entry) {
825
+ const candidates = [
477
826
  entry.documentationUrl,
478
827
  entry.repoUrl,
828
+ entry.githubUrl,
829
+ entry.websiteUrl,
830
+ entry.downloadUrl,
479
831
  entry.url,
480
832
  entry.canonicalUrl,
481
833
  entry.llmsUrl,
482
834
  entry.apiUrl,
483
- ]
484
- .map(normalizeText)
485
- .filter(Boolean);
835
+ ];
836
+ const trustSourceUrls = entry?.trustSignals?.sourceUrls;
837
+ if (Array.isArray(trustSourceUrls)) {
838
+ for (const value of trustSourceUrls) candidates.push(value);
839
+ }
840
+ const normalized = new Set();
841
+ for (const candidate of candidates) {
842
+ const value = normalizeSubmissionUrlForMatch(candidate);
843
+ if (value) normalized.add(value);
844
+ }
845
+ return normalized;
846
+ }
847
+
848
+ // Collect every URL the caller has offered as a duplicate-search candidate.
849
+ // Accepts the legacy scalar `sourceUrl`, the new `sourceUrls` array, and the
850
+ // fielded `githubUrl`/`docsUrl`/`downloadUrl`/`websiteUrl` keys so callers
851
+ // don't have to `||`-chain a single winner and silently drop the rest.
852
+ function collectSubmissionCandidateUrls(args = {}) {
853
+ const candidates = [
854
+ args.sourceUrl,
855
+ args.githubUrl,
856
+ args.docsUrl,
857
+ args.downloadUrl,
858
+ args.websiteUrl,
859
+ ];
860
+ if (Array.isArray(args.sourceUrls)) {
861
+ for (const value of args.sourceUrls) candidates.push(value);
862
+ }
863
+ const normalized = new Set();
864
+ for (const candidate of candidates) {
865
+ const value = normalizeSubmissionUrlForMatch(candidate);
866
+ if (value) normalized.add(value);
867
+ }
868
+ return normalized;
869
+ }
870
+
871
+ // First URL that appears in both Sets, or "" if none. Returning the matched
872
+ // URL (rather than just a boolean) lets callers surface which URL hit if they
873
+ // ever want to extend the `reasons` payload — today the predicate path is
874
+ // boolean-only, but the value is free.
875
+ function findOverlappingSourceUrl(entryUrls, candidateUrls) {
876
+ for (const value of candidateUrls) {
877
+ if (entryUrls.has(value)) return value;
878
+ }
879
+ return "";
486
880
  }
487
881
 
488
882
  export function searchDuplicateEntries(entries = [], args = {}) {
@@ -491,7 +885,7 @@ export function searchDuplicateEntries(entries = [], args = {}) {
491
885
  const slug = normalizeLower(args.slug);
492
886
  const title = normalizeLower(args.title || args.name);
493
887
  const brandDomain = normalizeDomain(args.brandDomain);
494
- const sourceUrl = normalizeText(args.sourceUrl);
888
+ const candidateUrls = collectSubmissionCandidateUrls(args);
495
889
 
496
890
  const matches = [];
497
891
  for (const entry of entries) {
@@ -502,7 +896,10 @@ export function searchDuplicateEntries(entries = [], args = {}) {
502
896
  if (brandDomain && normalizeDomain(entry.brandDomain) === brandDomain) {
503
897
  reasons.push("brand_domain");
504
898
  }
505
- if (sourceUrl && entrySourceUrls(entry).includes(sourceUrl)) {
899
+ if (
900
+ candidateUrls.size > 0 &&
901
+ findOverlappingSourceUrl(collectEntrySourceUrls(entry), candidateUrls)
902
+ ) {
506
903
  reasons.push("source_url");
507
904
  }
508
905
 
@@ -553,7 +950,7 @@ export function getCategorySubmissionGuidanceFromSpec(spec, args = {}) {
553
950
  category: key,
554
951
  label: model?.label || key,
555
952
  description: model?.description || "",
556
- template: model?.template || templateFor(spec, key)?.template || "",
953
+ template: model?.template || "",
557
954
  requiredFields: requiredFields(model),
558
955
  optionalFields: (model?.fields || [])
559
956
  .filter((field) => !field.required)