ssrwire 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.
Files changed (51) hide show
  1. package/CHANGELOG.md +24 -1
  2. package/CONTRIBUTING.md +1 -0
  3. package/PUBLISHING.md +15 -15
  4. package/README.md +70 -23
  5. package/dist/analyze.d.ts.map +1 -1
  6. package/dist/analyze.js +162 -6
  7. package/dist/analyze.js.map +1 -1
  8. package/dist/cli.d.ts.map +1 -1
  9. package/dist/cli.js +3 -1
  10. package/dist/cli.js.map +1 -1
  11. package/dist/config.d.ts.map +1 -1
  12. package/dist/config.js +4 -0
  13. package/dist/config.js.map +1 -1
  14. package/dist/http-probe.d.ts.map +1 -1
  15. package/dist/http-probe.js +4 -0
  16. package/dist/http-probe.js.map +1 -1
  17. package/dist/index.d.ts +1 -1
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js.map +1 -1
  20. package/dist/redact.d.ts.map +1 -1
  21. package/dist/redact.js +5 -0
  22. package/dist/redact.js.map +1 -1
  23. package/dist/reporters.d.ts.map +1 -1
  24. package/dist/reporters.js +27 -0
  25. package/dist/reporters.js.map +1 -1
  26. package/dist/social.d.ts +14 -0
  27. package/dist/social.d.ts.map +1 -0
  28. package/dist/social.js +88 -0
  29. package/dist/social.js.map +1 -0
  30. package/dist/stability.d.ts.map +1 -1
  31. package/dist/stability.js +84 -4
  32. package/dist/stability.js.map +1 -1
  33. package/dist/stream-parser.d.ts.map +1 -1
  34. package/dist/stream-parser.js +21 -1
  35. package/dist/stream-parser.js.map +1 -1
  36. package/dist/types.d.ts +10 -0
  37. package/dist/types.d.ts.map +1 -1
  38. package/examples/github-actions.yml +2 -2
  39. package/examples/ssrwire.config.yml +4 -0
  40. package/package.json +5 -2
  41. package/src/analyze.ts +208 -7
  42. package/src/cli.ts +3 -1
  43. package/src/config.ts +4 -0
  44. package/src/http-probe.ts +4 -0
  45. package/src/index.ts +2 -0
  46. package/src/redact.ts +5 -0
  47. package/src/reporters.ts +46 -0
  48. package/src/social.ts +116 -0
  49. package/src/stability.ts +123 -4
  50. package/src/stream-parser.ts +31 -1
  51. package/src/types.ts +21 -0
package/src/analyze.ts CHANGED
@@ -1,3 +1,14 @@
1
+ import {
2
+ effectiveTwitterCardSignal,
3
+ firstSocialSignal,
4
+ isAbsoluteHttpSocialUrl,
5
+ normalizeSocialValue,
6
+ OPEN_GRAPH_PROPERTIES,
7
+ OPEN_GRAPH_REQUIRED_PROPERTIES,
8
+ socialSignals,
9
+ TWITTER_CARD_REQUIRED_FIELDS,
10
+ TWITTER_PROPERTIES,
11
+ } from "./social.js";
1
12
  import type {
2
13
  AuditSummary,
3
14
  AuditTarget,
@@ -7,6 +18,8 @@ import type {
7
18
  RobotsAudience,
8
19
  RobotsSignal,
9
20
  Severity,
21
+ SocialMetadataProperty,
22
+ SocialMetadataSignal,
10
23
  TargetAuditResult,
11
24
  } from "./types.js";
12
25
 
@@ -264,6 +277,146 @@ function checkRepeatedRobots(findings: Finding[], targetUrl: string, probe: Prob
264
277
  }
265
278
  }
266
279
 
280
+ function auditedSocialProperties(
281
+ target: AuditTarget,
282
+ probe: ProbeResult,
283
+ ): readonly SocialMetadataProperty[] {
284
+ const properties = new Set<SocialMetadataProperty>();
285
+ if (target.expectations.requireOpenGraph === true) {
286
+ for (const property of OPEN_GRAPH_PROPERTIES) properties.add(property);
287
+ }
288
+ if (target.expectations.requireTwitterCard === true) {
289
+ for (const property of TWITTER_PROPERTIES) properties.add(property);
290
+ if (firstSocialSignal(probe.signals, "twitter:title") === undefined) {
291
+ properties.add("og:title");
292
+ }
293
+ if (firstSocialSignal(probe.signals, "twitter:description") === undefined) {
294
+ properties.add("og:description");
295
+ }
296
+ if (firstSocialSignal(probe.signals, "twitter:image") === undefined) {
297
+ properties.add("og:image");
298
+ }
299
+ }
300
+ return [...properties];
301
+ }
302
+
303
+ function normalizedSocialValues(
304
+ property: SocialMetadataProperty,
305
+ signals: readonly SocialMetadataSignal[],
306
+ baseUrl: string,
307
+ ): readonly string[] {
308
+ return signals
309
+ .map((signal) => normalizeSocialValue(property, signal.value, baseUrl))
310
+ .filter((value) => value.length > 0);
311
+ }
312
+
313
+ function checkRepeatedSocialMetadata(
314
+ findings: Finding[],
315
+ target: AuditTarget,
316
+ probe: ProbeResult,
317
+ ): void {
318
+ for (const property of auditedSocialProperties(target, probe)) {
319
+ // Open Graph explicitly permits multiple images and gives the first one precedence.
320
+ if (property === "og:image") continue;
321
+ const values = normalizedSocialValues(
322
+ property,
323
+ socialSignals(probe.signals, property),
324
+ probe.finalUrl,
325
+ );
326
+ if (values.length < 2) continue;
327
+
328
+ const distinct = new Set(values);
329
+ const conflicting = distinct.size > 1;
330
+ addFinding(findings, target.url, {
331
+ code: conflicting ? "conflicting-social-metadata" : "duplicate-social-metadata",
332
+ severity: "warning",
333
+ message: conflicting
334
+ ? `${probe.agent.label} received conflicting ${property} values.`
335
+ : `${probe.agent.label} received duplicate ${property} values.`,
336
+ agent: probe.agent.key,
337
+ evidence: {
338
+ property,
339
+ count: values.length,
340
+ distinctValues: distinct.size,
341
+ values: [...distinct].join(" | "),
342
+ },
343
+ });
344
+ }
345
+ }
346
+
347
+ function checkSocialUrls(findings: Finding[], target: AuditTarget, probe: ProbeResult): void {
348
+ const candidates = new Set<SocialMetadataSignal>();
349
+ if (target.expectations.requireOpenGraph === true) {
350
+ for (const signal of socialSignals(probe.signals, "og:url")) candidates.add(signal);
351
+ for (const signal of socialSignals(probe.signals, "og:image")) candidates.add(signal);
352
+ }
353
+ if (target.expectations.requireTwitterCard === true) {
354
+ const image = effectiveTwitterCardSignal(probe.signals, "image");
355
+ if (image !== undefined) candidates.add(image);
356
+ }
357
+
358
+ for (const signal of candidates) {
359
+ if (signal.value.trim().length === 0 || isAbsoluteHttpSocialUrl(signal.value)) continue;
360
+ addFinding(findings, target.url, {
361
+ code: "invalid-social-metadata-url",
362
+ severity: "warning",
363
+ message: `${probe.agent.label} received a non-absolute HTTP(S) ${signal.property} URL.`,
364
+ agent: probe.agent.key,
365
+ evidence: { property: signal.property, value: signal.value },
366
+ });
367
+ }
368
+ }
369
+
370
+ function checkRequiredSocialMetadata(
371
+ findings: Finding[],
372
+ target: AuditTarget,
373
+ probe: ProbeResult,
374
+ ): void {
375
+ if (probe.completion !== "complete") return;
376
+
377
+ if (target.expectations.requireOpenGraph === true) {
378
+ const missing = OPEN_GRAPH_REQUIRED_PROPERTIES.filter(
379
+ (property) => firstSocialSignal(probe.signals, property) === undefined,
380
+ );
381
+ if (missing.length > 0) {
382
+ addFinding(findings, target.url, {
383
+ code: "missing-open-graph-metadata",
384
+ severity: "warning",
385
+ message: `${probe.agent.label} received an incomplete Open Graph metadata set.`,
386
+ agent: probe.agent.key,
387
+ evidence: { fields: missing.join(", ") },
388
+ });
389
+ }
390
+ }
391
+
392
+ if (target.expectations.requireTwitterCard === true) {
393
+ const missing = TWITTER_CARD_REQUIRED_FIELDS.filter(
394
+ (field) => effectiveTwitterCardSignal(probe.signals, field) === undefined,
395
+ );
396
+ if (missing.length > 0) {
397
+ addFinding(findings, target.url, {
398
+ code: "missing-twitter-card-metadata",
399
+ severity: "warning",
400
+ message: `${probe.agent.label} received an incomplete Twitter Card metadata set.`,
401
+ agent: probe.agent.key,
402
+ evidence: { fields: missing.join(", ") },
403
+ });
404
+ }
405
+ }
406
+ }
407
+
408
+ function checkSocialMetadata(findings: Finding[], target: AuditTarget, probe: ProbeResult): void {
409
+ if (
410
+ target.expectations.requireOpenGraph !== true &&
411
+ target.expectations.requireTwitterCard !== true
412
+ ) {
413
+ return;
414
+ }
415
+ checkRequiredSocialMetadata(findings, target, probe);
416
+ checkRepeatedSocialMetadata(findings, target, probe);
417
+ checkSocialUrls(findings, target, probe);
418
+ }
419
+
267
420
  function criticalArrivalMs(target: AuditTarget, probe: ProbeResult): number | undefined {
268
421
  const marks: number[] = [];
269
422
  const { expectations } = target;
@@ -299,6 +452,20 @@ function criticalArrivalMs(target: AuditTarget, probe: ProbeResult): number | un
299
452
  ) {
300
453
  marks.push(probe.signals.firstMainText?.atMs ?? 0);
301
454
  }
455
+ if (expectations.requireOpenGraph === true) {
456
+ const signals = OPEN_GRAPH_REQUIRED_PROPERTIES.map((property) =>
457
+ firstSocialSignal(probe.signals, property),
458
+ );
459
+ if (!signals.every((signal) => signal !== undefined)) return undefined;
460
+ marks.push(...signals.map((signal) => signal.atMs));
461
+ }
462
+ if (expectations.requireTwitterCard === true) {
463
+ const signals = TWITTER_CARD_REQUIRED_FIELDS.map((field) =>
464
+ effectiveTwitterCardSignal(probe.signals, field),
465
+ );
466
+ if (!signals.every((signal) => signal !== undefined)) return undefined;
467
+ marks.push(...signals.map((signal) => signal.atMs));
468
+ }
302
469
 
303
470
  return marks.length === 0 ? undefined : Math.max(...marks);
304
471
  }
@@ -365,7 +532,7 @@ function checkRequiredSignals(findings: Finding[], target: AuditTarget, probe: P
365
532
  }
366
533
  }
367
534
 
368
- function checkHeadRequirements(findings: Finding[], targetUrl: string, probe: ProbeResult): void {
535
+ function checkHeadRequirements(findings: Finding[], target: AuditTarget, probe: ProbeResult): void {
369
536
  if (!probe.agent.requiresHeadMetadata) {
370
537
  return;
371
538
  }
@@ -384,12 +551,17 @@ function checkHeadRequirements(findings: Finding[], targetUrl: string, probe: Pr
384
551
  if (effectiveRobotsSignals(probe).some((signal) => signal.location === "body")) {
385
552
  bodyFields.add("robots");
386
553
  }
554
+ for (const property of auditedSocialProperties(target, probe)) {
555
+ if (socialSignals(probe.signals, property).some((signal) => signal.location === "body")) {
556
+ bodyFields.add(property);
557
+ }
558
+ }
387
559
 
388
560
  if (bodyFields.size === 0) {
389
561
  return;
390
562
  }
391
563
 
392
- addFinding(findings, targetUrl, {
564
+ addFinding(findings, target.url, {
393
565
  code: "head-metadata-in-body",
394
566
  severity: "error",
395
567
  message: `${probe.agent.label} requires head metadata but received ${[...bodyFields].join(
@@ -440,9 +612,31 @@ function agentValueSummary(
440
612
  return probes.map((probe) => `${probe.agent.key}=${valueFor(probe)}`).join("; ");
441
613
  }
442
614
 
615
+ function openGraphValue(probe: ProbeResult): string {
616
+ return OPEN_GRAPH_PROPERTIES.map((property) => {
617
+ const values = [
618
+ ...new Set(
619
+ normalizedSocialValues(property, socialSignals(probe.signals, property), probe.finalUrl),
620
+ ),
621
+ ];
622
+ return `${property}=${values.join(" | ") || "<missing>"}`;
623
+ }).join(", ");
624
+ }
625
+
626
+ function twitterCardValue(probe: ProbeResult): string {
627
+ return TWITTER_CARD_REQUIRED_FIELDS.map((field) => {
628
+ const signal = effectiveTwitterCardSignal(probe.signals, field);
629
+ const value =
630
+ signal === undefined
631
+ ? "<missing>"
632
+ : normalizeSocialValue(signal.property, signal.value, probe.finalUrl);
633
+ return `${field}=${value || "<missing>"}`;
634
+ }).join(", ");
635
+ }
636
+
443
637
  function checkAgentDrift(
444
638
  findings: Finding[],
445
- targetUrl: string,
639
+ target: AuditTarget,
446
640
  probes: readonly ProbeResult[],
447
641
  ): void {
448
642
  const complete = probes.filter((probe) => probe.completion === "complete");
@@ -450,7 +644,7 @@ function checkAgentDrift(
450
644
  return;
451
645
  }
452
646
 
453
- const comparisons: readonly {
647
+ const comparisons: {
454
648
  readonly field: string;
455
649
  readonly valueFor: (probe: ProbeResult) => string;
456
650
  }[] = [
@@ -472,6 +666,12 @@ function checkAgentDrift(
472
666
  valueFor: effectiveRobotsValue,
473
667
  },
474
668
  ];
669
+ if (target.expectations.requireOpenGraph === true) {
670
+ comparisons.push({ field: "open-graph", valueFor: openGraphValue });
671
+ }
672
+ if (target.expectations.requireTwitterCard === true) {
673
+ comparisons.push({ field: "twitter-card", valueFor: twitterCardValue });
674
+ }
475
675
 
476
676
  for (const comparison of comparisons) {
477
677
  const values = complete.map(comparison.valueFor);
@@ -479,7 +679,7 @@ function checkAgentDrift(
479
679
  continue;
480
680
  }
481
681
 
482
- addFinding(findings, targetUrl, {
682
+ addFinding(findings, target.url, {
483
683
  code: `agent-${comparison.field}-drift`,
484
684
  severity: "warning",
485
685
  message: `Crawler profiles received different ${comparison.field.replace("-", " ")} values.`,
@@ -556,6 +756,7 @@ export function analyzeTarget(
556
756
  checkRepeatedMetadata(findings, target.url, probe, "description", probe.signals.descriptions);
557
757
  checkRepeatedMetadata(findings, target.url, probe, "canonical", probe.signals.canonicals);
558
758
  checkRepeatedRobots(findings, target.url, probe);
759
+ checkSocialMetadata(findings, target, probe);
559
760
 
560
761
  const invalidJsonLd = probe.signals.jsonLd.filter((signal) => signal.valid === false);
561
762
  if (probe.completion === "complete" && invalidJsonLd.length > 0) {
@@ -587,11 +788,11 @@ export function analyzeTarget(
587
788
  });
588
789
  }
589
790
 
590
- checkHeadRequirements(findings, target.url, probe);
791
+ checkHeadRequirements(findings, target, probe);
591
792
  checkTimings(findings, target, probe);
592
793
  }
593
794
 
594
- checkAgentDrift(findings, target.url, probes);
795
+ checkAgentDrift(findings, target, probes);
595
796
 
596
797
  return findings;
597
798
  }
package/src/cli.ts CHANGED
@@ -31,6 +31,8 @@ targets:
31
31
  canonical: true
32
32
  h1: true
33
33
  mainText: true
34
+ openGraph: false
35
+ twitterCard: false
34
36
 
35
37
  agents:
36
38
  - browser
@@ -168,7 +170,7 @@ export async function main(argv: readonly string[] = process.argv): Promise<void
168
170
  const program = new Command();
169
171
  program
170
172
  .name("ssrwire")
171
- .description("Inspect streamed SSR HTML and crawler-specific metadata delivery.")
173
+ .description("Inspect streamed SSR HTML, SEO, and social metadata delivery.")
172
174
  .version(VERSION)
173
175
  .exitOverride()
174
176
  .showHelpAfterError();
package/src/config.ts CHANGED
@@ -23,6 +23,8 @@ const requireSchema = z
23
23
  canonical: z.boolean().optional(),
24
24
  h1: z.boolean().optional(),
25
25
  mainText: z.boolean().optional(),
26
+ openGraph: z.boolean().optional(),
27
+ twitterCard: z.boolean().optional(),
26
28
  })
27
29
  .strict();
28
30
 
@@ -129,6 +131,8 @@ function normalizeTarget(value: string | z.infer<typeof targetObjectSchema>): Au
129
131
  requireCanonical: required?.canonical ?? true,
130
132
  requireH1: required?.h1 ?? true,
131
133
  requireMainText: required?.mainText ?? true,
134
+ requireOpenGraph: required?.openGraph ?? false,
135
+ requireTwitterCard: required?.twitterCard ?? false,
132
136
  ...(item.maxFirstByteMs === undefined ? {} : { maxFirstByteMs: item.maxFirstByteMs }),
133
137
  ...(item.maxCriticalMs === undefined ? {} : { maxCriticalMs: item.maxCriticalMs }),
134
138
  };
package/src/http-probe.ts CHANGED
@@ -55,6 +55,7 @@ function emptySignals(): DocumentSignals {
55
55
  descriptions: [],
56
56
  canonicals: [],
57
57
  robots: [],
58
+ socialMetadata: [],
58
59
  h1s: [],
59
60
  jsonLd: [],
60
61
  };
@@ -91,6 +92,9 @@ function redactSignals(signals: DocumentSignals, redaction: RedactionPlan): Docu
91
92
  descriptions: signals.descriptions.map(element),
92
93
  canonicals: signals.canonicals.map(element),
93
94
  robots: signals.robots.map(element),
95
+ ...(signals.socialMetadata === undefined
96
+ ? {}
97
+ : { socialMetadata: signals.socialMetadata.map(element) }),
94
98
  h1s: signals.h1s.map(element),
95
99
  ...(signals.firstMainText === undefined
96
100
  ? {}
package/src/index.ts CHANGED
@@ -27,6 +27,8 @@ export type {
27
27
  RobotsAudience,
28
28
  RobotsSignal,
29
29
  Severity,
30
+ SocialMetadataProperty,
31
+ SocialMetadataSignal,
30
32
  SsrWireConfig,
31
33
  StabilityTimings,
32
34
  StabilityVariants,
package/src/redact.ts CHANGED
@@ -90,6 +90,11 @@ function redactSignals(signals: DocumentSignals, plan: RedactionPlan): DocumentS
90
90
  descriptions: signals.descriptions.map((signal) => redactElement(signal, plan)),
91
91
  canonicals: signals.canonicals.map((signal) => redactElement(signal, plan)),
92
92
  robots: signals.robots.map((signal) => redactElement(signal, plan)),
93
+ ...(signals.socialMetadata === undefined
94
+ ? {}
95
+ : {
96
+ socialMetadata: signals.socialMetadata.map((signal) => redactElement(signal, plan)),
97
+ }),
93
98
  h1s: signals.h1s.map((signal) => redactElement(signal, plan)),
94
99
  ...(signals.firstMainText === undefined
95
100
  ? {}
package/src/reporters.ts CHANGED
@@ -1,3 +1,9 @@
1
+ import {
2
+ effectiveTwitterCardSignal,
3
+ firstSocialSignal,
4
+ OPEN_GRAPH_REQUIRED_PROPERTIES,
5
+ TWITTER_CARD_REQUIRED_FIELDS,
6
+ } from "./social.js";
1
7
  import type {
2
8
  AgentStability,
3
9
  AuditResult,
@@ -86,6 +92,31 @@ function probeRow(probe: ProbeResult, showSample: boolean): readonly string[] {
86
92
  ];
87
93
  }
88
94
 
95
+ function formatSignalSet(signals: readonly (ElementSignal | undefined)[]): string {
96
+ const present = signals.filter((signal): signal is ElementSignal => signal !== undefined);
97
+ if (present.length < signals.length) return `${present.length}/${signals.length}`;
98
+ const arrivalMs = Math.max(...present.map((signal) => signal.atMs));
99
+ const location = present.some((signal) => signal.location === "body")
100
+ ? "body"
101
+ : present.some((signal) => signal.location === "document")
102
+ ? "document"
103
+ : "head";
104
+ return `${Math.round(arrivalMs)} ms/${location}`;
105
+ }
106
+
107
+ function socialRow(probe: ProbeResult, showSample: boolean): readonly string[] {
108
+ return [
109
+ ...(showSample ? [String(probe.sample ?? "—")] : []),
110
+ truncate(probe.agent.label, 24),
111
+ formatSignalSet(
112
+ OPEN_GRAPH_REQUIRED_PROPERTIES.map((property) => firstSocialSignal(probe.signals, property)),
113
+ ),
114
+ formatSignalSet(
115
+ TWITTER_CARD_REQUIRED_FIELDS.map((field) => effectiveTwitterCardSignal(probe.signals, field)),
116
+ ),
117
+ ];
118
+ }
119
+
89
120
  function stabilityRows(stability: readonly AgentStability[]): readonly (readonly string[])[] {
90
121
  const labels: Readonly<Record<keyof AgentStability["timings"], string>> = {
91
122
  headers: "Headers",
@@ -171,6 +202,21 @@ export function renderTerminal(audit: AuditResult, options: ReporterOptions = {}
171
202
  ),
172
203
  );
173
204
 
205
+ const showSocial =
206
+ result.target.expectations.requireOpenGraph === true ||
207
+ result.target.expectations.requireTwitterCard === true ||
208
+ result.probes.some((probe) => (probe.signals.socialMetadata?.length ?? 0) > 0);
209
+ if (showSocial) {
210
+ lines.push(
211
+ "",
212
+ "Social preview readiness",
213
+ renderTable(
214
+ [...(showSample ? ["Sample"] : []), "Agent", "Open Graph", "Twitter Card"],
215
+ result.probes.map((probe) => socialRow(probe, showSample)),
216
+ ),
217
+ );
218
+ }
219
+
174
220
  if (result.stability !== undefined) {
175
221
  const rows = stabilityRows(result.stability);
176
222
  if (rows.length > 0) {
package/src/social.ts ADDED
@@ -0,0 +1,116 @@
1
+ import type { DocumentSignals, SocialMetadataProperty, SocialMetadataSignal } from "./types.js";
2
+
3
+ export const SOCIAL_METADATA_PROPERTIES = [
4
+ "og:title",
5
+ "og:type",
6
+ "og:url",
7
+ "og:image",
8
+ "og:description",
9
+ "twitter:card",
10
+ "twitter:title",
11
+ "twitter:description",
12
+ "twitter:image",
13
+ ] as const satisfies readonly SocialMetadataProperty[];
14
+
15
+ export const OPEN_GRAPH_PROPERTIES = [
16
+ "og:title",
17
+ "og:type",
18
+ "og:url",
19
+ "og:image",
20
+ "og:description",
21
+ ] as const satisfies readonly SocialMetadataProperty[];
22
+
23
+ export const OPEN_GRAPH_REQUIRED_PROPERTIES = [
24
+ "og:title",
25
+ "og:type",
26
+ "og:url",
27
+ "og:image",
28
+ ] as const satisfies readonly SocialMetadataProperty[];
29
+
30
+ export const TWITTER_PROPERTIES = [
31
+ "twitter:card",
32
+ "twitter:title",
33
+ "twitter:description",
34
+ "twitter:image",
35
+ ] as const satisfies readonly SocialMetadataProperty[];
36
+
37
+ export const TWITTER_CARD_REQUIRED_FIELDS = ["card", "title", "description", "image"] as const;
38
+ export type TwitterCardField = (typeof TWITTER_CARD_REQUIRED_FIELDS)[number];
39
+
40
+ const SOCIAL_METADATA_PROPERTY_SET = new Set<string>(SOCIAL_METADATA_PROPERTIES);
41
+ const SOCIAL_URL_PROPERTY_SET = new Set<SocialMetadataProperty>([
42
+ "og:url",
43
+ "og:image",
44
+ "twitter:image",
45
+ ]);
46
+
47
+ export function isSocialMetadataProperty(
48
+ value: string | undefined,
49
+ ): value is SocialMetadataProperty {
50
+ return value !== undefined && SOCIAL_METADATA_PROPERTY_SET.has(value);
51
+ }
52
+
53
+ export function socialSignals(
54
+ signals: DocumentSignals,
55
+ property: SocialMetadataProperty,
56
+ ): readonly SocialMetadataSignal[] {
57
+ return (signals.socialMetadata ?? []).filter((signal) => signal.property === property);
58
+ }
59
+
60
+ export function firstSocialSignal(
61
+ signals: DocumentSignals,
62
+ property: SocialMetadataProperty,
63
+ ): SocialMetadataSignal | undefined {
64
+ return socialSignals(signals, property).find((signal) => signal.value.trim().length > 0);
65
+ }
66
+
67
+ export function effectiveTwitterCardSignal(
68
+ signals: DocumentSignals,
69
+ field: TwitterCardField,
70
+ ): SocialMetadataSignal | undefined {
71
+ if (field === "card") return firstSocialSignal(signals, "twitter:card");
72
+ if (field === "title") {
73
+ return firstSocialSignal(signals, "twitter:title") ?? firstSocialSignal(signals, "og:title");
74
+ }
75
+ if (field === "description") {
76
+ return (
77
+ firstSocialSignal(signals, "twitter:description") ??
78
+ firstSocialSignal(signals, "og:description")
79
+ );
80
+ }
81
+ return firstSocialSignal(signals, "twitter:image") ?? firstSocialSignal(signals, "og:image");
82
+ }
83
+
84
+ export function normalizeSocialValue(
85
+ property: SocialMetadataProperty,
86
+ value: string,
87
+ baseUrl: string,
88
+ ): string {
89
+ const normalized = value.trim().replace(/\s+/gu, " ");
90
+ if (normalized.length === 0) return "";
91
+ if (SOCIAL_URL_PROPERTY_SET.has(property)) {
92
+ try {
93
+ const url = new URL(normalized, baseUrl);
94
+ url.hash = "";
95
+ return url.href;
96
+ } catch {
97
+ return normalized;
98
+ }
99
+ }
100
+ return property === "og:type" || property === "twitter:card"
101
+ ? normalized.toLowerCase()
102
+ : normalized;
103
+ }
104
+
105
+ export function isAbsoluteHttpSocialUrl(value: string): boolean {
106
+ try {
107
+ const url = new URL(value.trim());
108
+ return (
109
+ (url.protocol === "http:" || url.protocol === "https:") &&
110
+ url.username.length === 0 &&
111
+ url.password.length === 0
112
+ );
113
+ } catch {
114
+ return false;
115
+ }
116
+ }