@heyclaude/mcp 0.2.0 → 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,16 +515,21 @@ 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.",
470
529
  ]
471
- : ["Fix validation errors before opening a public submission issue."],
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.",
472
533
  };
473
534
  }
474
535
 
@@ -539,6 +600,10 @@ function exampleValueForField(fieldId, category, label) {
539
600
  return "Claude Code, Codex, Cursor";
540
601
  case "prerequisites":
541
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";
542
607
  case "troubleshooting_section":
543
608
  return "If setup fails, verify the install command and source URL first.";
544
609
  case "installation_order":
@@ -576,6 +641,7 @@ function exampleForCategory(spec, category) {
576
641
  "install_command",
577
642
  "usage_snippet",
578
643
  "download_url",
644
+ "full_copyable_content",
579
645
  "guide_content",
580
646
  "items",
581
647
  ]) {
@@ -585,13 +651,14 @@ function exampleForCategory(spec, category) {
585
651
  return {
586
652
  category,
587
653
  label: model?.label || category,
588
- template: model?.template || templateFor(spec, category)?.template || "",
654
+ template: model?.template || "",
589
655
  requiredFields: requiredFields(model),
590
656
  minimalFields,
591
657
  completeFields: fields,
592
658
  notes: [
593
659
  "Use canonical source URLs and avoid affiliate/referral links.",
594
- "The generated issue draft is a maintainer-reviewed submission, not automatic publication.",
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.",
595
662
  ],
596
663
  };
597
664
  }
@@ -613,19 +680,19 @@ export function getSubmissionExamplesFromSpec(spec, args = {}) {
613
680
  ok: true,
614
681
  categories: categories.map((key) => exampleForCategory(spec, key)),
615
682
  reviewModel:
616
- "Examples are draft helpers only; maintainers review accepted submissions before import.",
683
+ "Examples are draft helpers only; maintainers review generated PRs before merge.",
617
684
  };
618
685
  }
619
686
 
620
687
  export function prepareSubmissionDraftFromSpec(spec, args = {}) {
621
688
  const fields = normalizeSubmissionFields(args.fields || {});
622
689
  const validation = validateAgainstSpec(spec, fields);
623
- const issueDraft = validation.category
624
- ? buildIssueDraftFromSpec(spec, validation.normalized)
690
+ const prDraft = validation.category
691
+ ? buildPrDraftFromSpec(spec, validation.normalized)
625
692
  : null;
626
693
  const urls = buildSubmissionUrlsFromSpec(spec, {
627
694
  fields: validation.normalized,
628
- includeIssueBody: true,
695
+ includePrBody: true,
629
696
  });
630
697
 
631
698
  return {
@@ -638,17 +705,21 @@ export function prepareSubmissionDraftFromSpec(spec, args = {}) {
638
705
  missingRequiredFields: validation.missingRequiredFields || [],
639
706
  errors: validation.errors,
640
707
  warnings: validation.warnings,
641
- issueDraft,
708
+ prDraft: prDraft ? { ...prDraft } : null,
642
709
  submitUrl: urls.submitUrl,
643
- githubIssueUrl: urls.githubIssueUrl,
710
+ reviewUrl: urls.reviewUrl,
644
711
  reviewChecklist: [
645
- "Confirm category fit and required fields before opening the issue.",
712
+ "Confirm category fit and required fields before opening the PR.",
646
713
  "Check for existing registry entries with the same source, slug, or title.",
647
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.",
648
717
  "Disclose paid, sponsored, affiliate, or commercial content separately from free community submissions.",
649
718
  ],
650
719
  submissionPolicy:
651
- "This tool prepares a review issue only. HeyClaude does not auto-publish MCP-submitted content.",
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.",
652
723
  };
653
724
  }
654
725
 
@@ -659,10 +730,17 @@ export function reviewSubmissionDraftFromSpec(spec, args = {}, entries = []) {
659
730
  slug: validation.normalized.slug,
660
731
  title: validation.normalized.title || validation.normalized.name,
661
732
  name: validation.normalized.name,
662
- sourceUrl:
663
- validation.normalized.github_url ||
664
- validation.normalized.docs_url ||
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,
665
740
  validation.normalized.source_url,
741
+ validation.normalized.download_url,
742
+ validation.normalized.website_url,
743
+ ].filter(Boolean),
666
744
  brandDomain: validation.normalized.brand_domain,
667
745
  limit: args.duplicateLimit || 5,
668
746
  };
@@ -670,7 +748,7 @@ export function reviewSubmissionDraftFromSpec(spec, args = {}, entries = []) {
670
748
  const recommendedAction = validation.valid
671
749
  ? duplicates.count > 0
672
750
  ? "review_possible_duplicate"
673
- : "open_review_issue"
751
+ : "open_review_pr"
674
752
  : "fix_required_fields";
675
753
 
676
754
  return {
@@ -687,28 +765,118 @@ export function reviewSubmissionDraftFromSpec(spec, args = {}, entries = []) {
687
765
  "Schema-valid is not publish-valid; maintainer review is still required.",
688
766
  "Check category fit against the actual artifact, not only the submitter's selected category.",
689
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.",
690
769
  "Reject or request edits for affiliate/referral links, unsupported claims, or unclear paid/sponsored positioning.",
691
770
  ],
692
771
  nextSteps: validation.valid
693
772
  ? [
694
- "Open or update the canonical GitHub submission issue.",
695
- "Apply maintainer review labels only after source and duplicate checks.",
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.",
696
775
  ]
697
- : ["Fix required fields and validation errors before opening an 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.",
698
779
  };
699
780
  }
700
781
 
701
- function entrySourceUrls(entry) {
702
- 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 = [
703
826
  entry.documentationUrl,
704
827
  entry.repoUrl,
828
+ entry.githubUrl,
829
+ entry.websiteUrl,
830
+ entry.downloadUrl,
705
831
  entry.url,
706
832
  entry.canonicalUrl,
707
833
  entry.llmsUrl,
708
834
  entry.apiUrl,
709
- ]
710
- .map(normalizeText)
711
- .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 "";
712
880
  }
713
881
 
714
882
  export function searchDuplicateEntries(entries = [], args = {}) {
@@ -717,7 +885,7 @@ export function searchDuplicateEntries(entries = [], args = {}) {
717
885
  const slug = normalizeLower(args.slug);
718
886
  const title = normalizeLower(args.title || args.name);
719
887
  const brandDomain = normalizeDomain(args.brandDomain);
720
- const sourceUrl = normalizeText(args.sourceUrl);
888
+ const candidateUrls = collectSubmissionCandidateUrls(args);
721
889
 
722
890
  const matches = [];
723
891
  for (const entry of entries) {
@@ -728,7 +896,10 @@ export function searchDuplicateEntries(entries = [], args = {}) {
728
896
  if (brandDomain && normalizeDomain(entry.brandDomain) === brandDomain) {
729
897
  reasons.push("brand_domain");
730
898
  }
731
- if (sourceUrl && entrySourceUrls(entry).includes(sourceUrl)) {
899
+ if (
900
+ candidateUrls.size > 0 &&
901
+ findOverlappingSourceUrl(collectEntrySourceUrls(entry), candidateUrls)
902
+ ) {
732
903
  reasons.push("source_url");
733
904
  }
734
905
 
@@ -779,7 +950,7 @@ export function getCategorySubmissionGuidanceFromSpec(spec, args = {}) {
779
950
  category: key,
780
951
  label: model?.label || key,
781
952
  description: model?.description || "",
782
- template: model?.template || templateFor(spec, key)?.template || "",
953
+ template: model?.template || "",
783
954
  requiredFields: requiredFields(model),
784
955
  optionalFields: (model?.fields || [])
785
956
  .filter((field) => !field.required)