@growth-labs/cms 0.5.29 → 0.5.30

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 (95) hide show
  1. package/dist/engine/index.d.ts +5 -0
  2. package/dist/engine/index.d.ts.map +1 -1
  3. package/dist/engine/index.js +5 -0
  4. package/dist/engine/index.js.map +1 -1
  5. package/dist/engine/publication-continuity.d.ts +77 -0
  6. package/dist/engine/publication-continuity.d.ts.map +1 -0
  7. package/dist/engine/publication-continuity.js +94 -0
  8. package/dist/engine/publication-continuity.js.map +1 -0
  9. package/dist/engine/publication-receipt/canonical-json.d.ts +34 -0
  10. package/dist/engine/publication-receipt/canonical-json.d.ts.map +1 -0
  11. package/dist/engine/publication-receipt/canonical-json.js +100 -0
  12. package/dist/engine/publication-receipt/canonical-json.js.map +1 -0
  13. package/dist/engine/publication-receipt/receipt.d.ts +316 -0
  14. package/dist/engine/publication-receipt/receipt.d.ts.map +1 -0
  15. package/dist/engine/publication-receipt/receipt.js +131 -0
  16. package/dist/engine/publication-receipt/receipt.js.map +1 -0
  17. package/dist/engine/publication-receipt/signature.d.ts +36 -0
  18. package/dist/engine/publication-receipt/signature.d.ts.map +1 -0
  19. package/dist/engine/publication-receipt/signature.js +79 -0
  20. package/dist/engine/publication-receipt/signature.js.map +1 -0
  21. package/dist/engine/publication.d.ts.map +1 -1
  22. package/dist/engine/publication.js +2 -0
  23. package/dist/engine/publication.js.map +1 -1
  24. package/dist/engine/publisher.d.ts.map +1 -1
  25. package/dist/engine/publisher.js +2 -0
  26. package/dist/engine/publisher.js.map +1 -1
  27. package/dist/engine/source-sections.d.ts +20 -0
  28. package/dist/engine/source-sections.d.ts.map +1 -0
  29. package/dist/engine/source-sections.js +130 -0
  30. package/dist/engine/source-sections.js.map +1 -0
  31. package/dist/engine/validator/checks/source-section-diagnostics.d.ts +7 -0
  32. package/dist/engine/validator/checks/source-section-diagnostics.d.ts.map +1 -0
  33. package/dist/engine/validator/checks/source-section-diagnostics.js +120 -0
  34. package/dist/engine/validator/checks/source-section-diagnostics.js.map +1 -0
  35. package/dist/engine/validator/index.d.ts +10 -0
  36. package/dist/engine/validator/index.d.ts.map +1 -1
  37. package/dist/engine/validator/index.js +4 -0
  38. package/dist/engine/validator/index.js.map +1 -1
  39. package/dist/engine/validator/types.d.ts +1 -1
  40. package/dist/engine/validator/types.d.ts.map +1 -1
  41. package/dist/engine/validator/types.js +10 -0
  42. package/dist/engine/validator/types.js.map +1 -1
  43. package/dist/providers/types.d.ts +1 -0
  44. package/dist/providers/types.d.ts.map +1 -1
  45. package/dist/routes/content.d.ts.map +1 -1
  46. package/dist/routes/content.js +22 -2
  47. package/dist/routes/content.js.map +1 -1
  48. package/dist/routes/surveys.d.ts.map +1 -1
  49. package/dist/routes/surveys.js +13 -1
  50. package/dist/routes/surveys.js.map +1 -1
  51. package/dist/schema/insights-ingest.d.ts +28 -28
  52. package/dist/surveys/access.d.ts +2 -0
  53. package/dist/surveys/access.d.ts.map +1 -1
  54. package/dist/surveys/access.js +7 -0
  55. package/dist/surveys/access.js.map +1 -1
  56. package/dist/surveys/errors.d.ts +8 -0
  57. package/dist/surveys/errors.d.ts.map +1 -1
  58. package/dist/surveys/errors.js +12 -0
  59. package/dist/surveys/errors.js.map +1 -1
  60. package/dist/surveys/schema.d.ts +75 -0
  61. package/dist/surveys/schema.d.ts.map +1 -1
  62. package/dist/surveys/schema.js +45 -0
  63. package/dist/surveys/schema.js.map +1 -1
  64. package/dist/ui/editor/ContentForm.d.ts.map +1 -1
  65. package/dist/ui/editor/ContentForm.js +52 -1
  66. package/dist/ui/editor/ContentForm.js.map +1 -1
  67. package/dist/ui/editor/body-warnings.d.ts +27 -0
  68. package/dist/ui/editor/body-warnings.d.ts.map +1 -0
  69. package/dist/ui/editor/body-warnings.js +58 -0
  70. package/dist/ui/editor/body-warnings.js.map +1 -0
  71. package/dist/ui/screens/SurveyResponsesScreen.d.ts +7 -1
  72. package/dist/ui/screens/SurveyResponsesScreen.d.ts.map +1 -1
  73. package/dist/ui/screens/SurveyResponsesScreen.js +141 -32
  74. package/dist/ui/screens/SurveyResponsesScreen.js.map +1 -1
  75. package/package.json +1 -1
  76. package/src/engine/index.ts +33 -0
  77. package/src/engine/publication-continuity.ts +140 -0
  78. package/src/engine/publication-receipt/canonical-json.ts +123 -0
  79. package/src/engine/publication-receipt/receipt.ts +160 -0
  80. package/src/engine/publication-receipt/signature.ts +112 -0
  81. package/src/engine/publication.ts +2 -0
  82. package/src/engine/publisher.ts +2 -0
  83. package/src/engine/source-sections.ts +153 -0
  84. package/src/engine/validator/checks/source-section-diagnostics.ts +187 -0
  85. package/src/engine/validator/index.ts +12 -0
  86. package/src/engine/validator/types.ts +19 -0
  87. package/src/providers/types.ts +1 -1
  88. package/src/routes/content.ts +23 -2
  89. package/src/routes/surveys.ts +12 -1
  90. package/src/surveys/access.ts +9 -0
  91. package/src/surveys/errors.ts +12 -0
  92. package/src/surveys/schema.ts +47 -0
  93. package/src/ui/editor/ContentForm.tsx +93 -0
  94. package/src/ui/editor/body-warnings.ts +78 -0
  95. package/src/ui/screens/SurveyResponsesScreen.tsx +369 -121
@@ -0,0 +1,123 @@
1
+ // RFC 8785 JSON Canonicalization Scheme (#342).
2
+ //
3
+ // A publication receipt is hashed and signed, and the same bytes must be
4
+ // reproducible by Foundry from the same object. Canonicalization is what makes
5
+ // that true: `JSON.stringify` alone is not stable, because object key order
6
+ // follows insertion order rather than a defined sort.
7
+ //
8
+ // Deliberately implemented rather than pulled from a dependency. The whole
9
+ // value here is that both sides agree byte-for-byte, so the algorithm should be
10
+ // readable, pinned, and covered by the RFC's own vectors rather than tracking
11
+ // someone else's semver.
12
+
13
+ export class CanonicalJsonError extends Error {
14
+ readonly code: 'non_finite_number' | 'unsupported_type' | 'circular_reference'
15
+
16
+ constructor(code: CanonicalJsonError['code'], message: string) {
17
+ super(message)
18
+ this.name = 'CanonicalJsonError'
19
+ this.code = code
20
+ }
21
+ }
22
+
23
+ type JsonPrimitive = string | number | boolean | null
24
+ export type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue }
25
+
26
+ /**
27
+ * Serialize `value` per RFC 8785.
28
+ *
29
+ * Three things do the work, and each is load-bearing:
30
+ *
31
+ * 1. Object keys sort by UTF-16 code unit. JavaScript's default string sort is
32
+ * already a UTF-16 code-unit comparison, which is exactly what §3.2.3
33
+ * requires — so `.sort()` here is the specified behaviour, not a shortcut.
34
+ * 2. Numbers use ECMAScript `Number::toString`, which `JSON.stringify` applies.
35
+ * §3.2.2.3 defers to that same algorithm, so the platform is authoritative
36
+ * and reimplementing it would only introduce drift.
37
+ * 3. Strings use `JSON.stringify`'s escaping, which since ES2019 emits
38
+ * well-formed output (lone surrogates escaped rather than passed through) —
39
+ * §3.2.2.2's requirement.
40
+ *
41
+ * Non-finite numbers throw rather than becoming `null` as `JSON.stringify`
42
+ * would. A receipt containing `NaN` is a bug, and silently signing `null` in
43
+ * its place would make that bug undetectable downstream.
44
+ */
45
+ export function canonicalJson(value: JsonValue): string {
46
+ return write(value, new Set())
47
+ }
48
+
49
+ function write(value: JsonValue, seen: Set<object>): string {
50
+ if (value === null) return 'null'
51
+
52
+ switch (typeof value) {
53
+ case 'boolean':
54
+ return value ? 'true' : 'false'
55
+
56
+ case 'number': {
57
+ if (!Number.isFinite(value)) {
58
+ throw new CanonicalJsonError(
59
+ 'non_finite_number',
60
+ `Cannot canonicalize non-finite number: ${String(value)}`,
61
+ )
62
+ }
63
+ // -0 serializes as "0" per RFC 8785 §3.2.2.3; JSON.stringify agrees.
64
+ return JSON.stringify(value)
65
+ }
66
+
67
+ case 'string':
68
+ return JSON.stringify(value)
69
+
70
+ case 'object':
71
+ break
72
+
73
+ default:
74
+ throw new CanonicalJsonError(
75
+ 'unsupported_type',
76
+ `Cannot canonicalize value of type ${typeof value}`,
77
+ )
78
+ }
79
+
80
+ const object = value as object
81
+ if (seen.has(object)) {
82
+ throw new CanonicalJsonError('circular_reference', 'Cannot canonicalize a circular structure')
83
+ }
84
+ seen.add(object)
85
+
86
+ try {
87
+ if (Array.isArray(value)) {
88
+ return `[${value.map((entry) => write(entry, seen)).join(',')}]`
89
+ }
90
+
91
+ const record = value as { [key: string]: JsonValue }
92
+ // Undefined-valued keys are omitted, matching JSON.stringify. RFC 8785
93
+ // has no representation for them.
94
+ const keys = Object.keys(record)
95
+ .filter((key) => record[key] !== undefined)
96
+ .sort()
97
+
98
+ const members = keys.map(
99
+ (key) => `${JSON.stringify(key)}:${write(record[key] as JsonValue, seen)}`,
100
+ )
101
+ return `{${members.join(',')}}`
102
+ } finally {
103
+ seen.delete(object)
104
+ }
105
+ }
106
+
107
+ /** Canonical UTF-8 bytes, which are what gets hashed and signed. */
108
+ export function canonicalJsonBytes(value: JsonValue): Uint8Array {
109
+ return new TextEncoder().encode(canonicalJson(value))
110
+ }
111
+
112
+ /** Lowercase hex SHA-256 of the canonical bytes. */
113
+ export async function canonicalJsonSha256(value: JsonValue): Promise<string> {
114
+ // `Uint8Array.from` yields an ArrayBuffer-backed copy. TextEncoder returns
115
+ // `Uint8Array<ArrayBufferLike>`, which may be SharedArrayBuffer-backed and
116
+ // so is not a `BufferSource`. Same idiom as sha256Hex in
117
+ // @growth-labs/monitoring's site-agent signature helper.
118
+ const copy = Uint8Array.from(canonicalJsonBytes(value))
119
+ const digest = await globalThis.crypto.subtle.digest('SHA-256', copy)
120
+ return Array.from(new Uint8Array(digest))
121
+ .map((byte) => byte.toString(16).padStart(2, '0'))
122
+ .join('')
123
+ }
@@ -0,0 +1,160 @@
1
+ // `site.publication.receipt.v1` — the signed handoff receipt (#342).
2
+ //
3
+ // A receipt is the site's terminal, non-repudiable statement about one
4
+ // publication attempt. Foundry must be able to verify it without trusting the
5
+ // transport and without re-querying the site, so everything it needs to judge
6
+ // the claim is inside the signed body.
7
+ //
8
+ // The site persists the canonical bytes and signature exactly once BEFORE
9
+ // network delivery; a retry resends those stored bytes verbatim. That is why
10
+ // nothing in the body is derived at sign time — the nonce and timestamps are
11
+ // inputs, not defaults. A schema that generated its own nonce would produce a
12
+ // different receipt on every retry and break replay-safety by construction.
13
+
14
+ import { z } from 'zod'
15
+ import { canonicalJson, type JsonValue } from './canonical-json.js'
16
+
17
+ export const SITE_PUBLICATION_RECEIPT_SCHEMA = 'site.publication.receipt.v1' as const
18
+ export const SITE_PUBLICATION_RECEIPT_AUDIENCE = 'foundry:site-publication-receipt' as const
19
+
20
+ /** Lowercase hex SHA-256. */
21
+ const sha256Hex = z.string().regex(/^[0-9a-f]{64}$/, 'expected lowercase hex sha-256')
22
+
23
+ /** A bounded identifier: printable ASCII, no whitespace, capped. */
24
+ const identifier = z
25
+ .string()
26
+ .min(1)
27
+ .max(256)
28
+ .regex(/^[\x21-\x7e]+$/, 'expected a bounded printable identifier')
29
+
30
+ /** RFC 3339 UTC instant, seconds precision, `Z` only — no local offsets. */
31
+ const instant = z
32
+ .string()
33
+ .regex(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/, 'expected an RFC 3339 UTC instant')
34
+
35
+ /** A count that must stay bounded so a receipt cannot carry unbounded claims. */
36
+ const boundedCount = z.number().int().min(0).max(1_000_000)
37
+
38
+ export const publicationProbeSchema = z
39
+ .object({
40
+ /** What was probed, e.g. `html` or `markdown`. */
41
+ kind: z.enum(['html', 'markdown']),
42
+ url: z.string().url().max(2048),
43
+ status: z.number().int().min(100).max(599),
44
+ /** SHA-256 of the exact bytes observed, so Foundry can compare. */
45
+ digest: sha256Hex,
46
+ })
47
+ .strict()
48
+
49
+ export const publicationProjectionCountsSchema = z
50
+ .object({
51
+ seo: boundedCount,
52
+ search: boundedCount,
53
+ sitemap: boundedCount,
54
+ feed: boundedCount,
55
+ })
56
+ .strict()
57
+
58
+ export const sitePublicationReceiptBodySchema = z
59
+ .object({
60
+ schema: z.literal(SITE_PUBLICATION_RECEIPT_SCHEMA),
61
+ audience: z.literal(SITE_PUBLICATION_RECEIPT_AUDIENCE),
62
+
63
+ /** Success and failure are both signed; a silent failure is unverifiable. */
64
+ outcome: z.enum(['success', 'failure']),
65
+
66
+ /** Identity of the publishing property (the site). */
67
+ property: z.object({ id: identifier, origin: z.string().url().max(2048) }).strict(),
68
+
69
+ /** Identity of the thing published, stable across revisions. */
70
+ content: z
71
+ .object({
72
+ stableId: identifier,
73
+ slug: identifier,
74
+ revisionId: identifier,
75
+ })
76
+ .strict(),
77
+
78
+ /** What produced it. */
79
+ source: z
80
+ .object({
81
+ commit: z.string().regex(/^[0-9a-f]{40}$/, 'expected a full git sha'),
82
+ foundryHandoffId: identifier,
83
+ foundryRunId: identifier,
84
+ })
85
+ .strict(),
86
+
87
+ /** Logical database identity — never a connection string or credential. */
88
+ database: z.object({ logicalId: identifier }).strict(),
89
+
90
+ /** Hash of the public media manifest, not the media itself. */
91
+ publicMediaManifestHash: sha256Hex,
92
+
93
+ projections: publicationProjectionCountsSchema,
94
+ probes: z.array(publicationProbeSchema).max(16),
95
+
96
+ /** How to undo this publication. */
97
+ rollback: z.object({ pointer: identifier }).strict(),
98
+
99
+ /** Replay identity. Supplied by the caller and persisted, never generated here. */
100
+ nonce: identifier,
101
+ issuedAt: instant,
102
+
103
+ /** Present only when `outcome` is `failure`, and bounded. */
104
+ failureReason: z.string().min(1).max(512).optional(),
105
+ })
106
+ .strict()
107
+ .superRefine((body, ctx) => {
108
+ if (body.outcome === 'failure' && body.failureReason === undefined) {
109
+ ctx.addIssue({
110
+ code: z.ZodIssueCode.custom,
111
+ path: ['failureReason'],
112
+ message: 'a failure receipt must state its reason',
113
+ })
114
+ }
115
+ if (body.outcome === 'success' && body.failureReason !== undefined) {
116
+ ctx.addIssue({
117
+ code: z.ZodIssueCode.custom,
118
+ path: ['failureReason'],
119
+ message: 'a success receipt must not carry a failure reason',
120
+ })
121
+ }
122
+ })
123
+
124
+ export type SitePublicationReceiptBody = z.infer<typeof sitePublicationReceiptBodySchema>
125
+
126
+ export class PublicationReceiptError extends Error {
127
+ readonly code: 'invalid_body' | 'invalid_key'
128
+
129
+ constructor(code: PublicationReceiptError['code'], message: string) {
130
+ super(message)
131
+ this.name = 'PublicationReceiptError'
132
+ this.code = code
133
+ }
134
+ }
135
+
136
+ /**
137
+ * Validate a receipt body and return its canonical RFC 8785 form.
138
+ *
139
+ * Validation happens before canonicalization on purpose: signing an unvalidated
140
+ * body would produce a verifiable signature over a malformed claim, which is
141
+ * strictly worse than refusing, because downstream would trust it.
142
+ */
143
+ export function canonicalizeReceiptBody(body: unknown): {
144
+ body: SitePublicationReceiptBody
145
+ canonical: string
146
+ } {
147
+ const parsed = sitePublicationReceiptBodySchema.safeParse(body)
148
+ if (!parsed.success) {
149
+ throw new PublicationReceiptError(
150
+ 'invalid_body',
151
+ `Receipt body failed validation: ${parsed.error.issues
152
+ .map((issue) => `${issue.path.join('.')}: ${issue.message}`)
153
+ .join('; ')}`,
154
+ )
155
+ }
156
+ return {
157
+ body: parsed.data,
158
+ canonical: canonicalJson(parsed.data as unknown as JsonValue),
159
+ }
160
+ }
@@ -0,0 +1,112 @@
1
+ // Ed25519 signature envelope for `site.publication.receipt.v1` (#342).
2
+ //
3
+ // Signs the RFC 8785 canonical bytes of the receipt body, so the signature is
4
+ // over exactly what Foundry will re-canonicalize and compare — not over a
5
+ // re-serialization that might differ in key order.
6
+ //
7
+ // crypto.subtle only; never node:crypto. The package runs in Workers.
8
+
9
+ import { canonicalJsonSha256 } from './canonical-json.js'
10
+ import {
11
+ canonicalizeReceiptBody,
12
+ PublicationReceiptError,
13
+ SITE_PUBLICATION_RECEIPT_AUDIENCE,
14
+ type SitePublicationReceiptBody,
15
+ sitePublicationReceiptBodySchema,
16
+ } from './receipt.js'
17
+
18
+ export interface SignedPublicationReceipt {
19
+ body: SitePublicationReceiptBody
20
+ /** The exact bytes that were signed. Persist these, not a re-serialization. */
21
+ canonical: string
22
+ /** Lowercase hex SHA-256 of `canonical`. */
23
+ bodyHash: string
24
+ /** Base64url, unpadded. */
25
+ signature: string
26
+ /** Key id the verifier should select. */
27
+ kid: string
28
+ audience: typeof SITE_PUBLICATION_RECEIPT_AUDIENCE
29
+ }
30
+
31
+ function toBase64Url(bytes: Uint8Array): string {
32
+ let binary = ''
33
+ for (const byte of bytes) binary += String.fromCharCode(byte)
34
+ return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
35
+ }
36
+
37
+ function fromBase64Url(value: string): Uint8Array {
38
+ if (!/^[A-Za-z0-9_-]+$/.test(value)) {
39
+ throw new PublicationReceiptError('invalid_body', 'Signature is not unpadded base64url')
40
+ }
41
+ const padded = value.replace(/-/g, '+').replace(/_/g, '/')
42
+ const binary = atob(padded + '='.repeat((4 - (padded.length % 4)) % 4))
43
+ const bytes = new Uint8Array(binary.length)
44
+ for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index)
45
+ return bytes
46
+ }
47
+
48
+ /** ArrayBuffer-backed copy — TextEncoder output is not a `BufferSource`. */
49
+ function encode(value: string): Uint8Array<ArrayBuffer> {
50
+ return Uint8Array.from(new TextEncoder().encode(value))
51
+ }
52
+
53
+ /**
54
+ * Validate, canonicalize and sign a receipt body.
55
+ *
56
+ * Deterministic: the same body and key always produce the same bytes and the
57
+ * same signature, which is what makes "retries resend byte-identical bytes"
58
+ * achievable. Nothing is generated here — the nonce and `issuedAt` come from
59
+ * the caller, who is responsible for persisting them once.
60
+ */
61
+ export async function signPublicationReceipt(
62
+ body: unknown,
63
+ key: CryptoKey,
64
+ kid: string,
65
+ ): Promise<SignedPublicationReceipt> {
66
+ if (key.type !== 'private') {
67
+ throw new PublicationReceiptError('invalid_key', 'Signing requires an Ed25519 private key')
68
+ }
69
+ const { body: validated, canonical } = canonicalizeReceiptBody(body)
70
+ const signature = await globalThis.crypto.subtle.sign({ name: 'Ed25519' }, key, encode(canonical))
71
+ return {
72
+ body: validated,
73
+ canonical,
74
+ bodyHash: await canonicalJsonSha256(validated as never),
75
+ signature: toBase64Url(new Uint8Array(signature)),
76
+ kid,
77
+ audience: SITE_PUBLICATION_RECEIPT_AUDIENCE,
78
+ }
79
+ }
80
+
81
+ /**
82
+ * Verify a receipt as Foundry would: re-canonicalize the body from scratch and
83
+ * check the signature over THOSE bytes.
84
+ *
85
+ * Deliberately ignores any `canonical` string supplied alongside the receipt. A
86
+ * verifier that trusts the sender's canonical form would accept a signature
87
+ * over bytes that do not match the body it then acts on — which is the whole
88
+ * attack canonicalization exists to prevent.
89
+ */
90
+ export async function verifyPublicationReceipt(
91
+ receipt: { body: unknown; signature: string },
92
+ key: CryptoKey,
93
+ ): Promise<boolean> {
94
+ const parsed = sitePublicationReceiptBodySchema.safeParse(receipt.body)
95
+ if (!parsed.success) return false
96
+ if (parsed.data.audience !== SITE_PUBLICATION_RECEIPT_AUDIENCE) return false
97
+
98
+ let signatureBytes: Uint8Array
99
+ try {
100
+ signatureBytes = fromBase64Url(receipt.signature)
101
+ } catch {
102
+ return false
103
+ }
104
+
105
+ const { canonical } = canonicalizeReceiptBody(parsed.data)
106
+ return await globalThis.crypto.subtle.verify(
107
+ { name: 'Ed25519' },
108
+ key,
109
+ Uint8Array.from(signatureBytes),
110
+ encode(canonical),
111
+ )
112
+ }
@@ -819,6 +819,8 @@ function assertPublicationSnapshotBodyValid(
819
819
  },
820
820
  {
821
821
  source: 'publish-one',
822
+ // #329: source-section defects block at publish, warn on save.
823
+ blockSourceSectionDefects: true,
822
824
  contentId,
823
825
  requireBody: true,
824
826
  },
@@ -1210,6 +1210,8 @@ export async function evaluateContentBodyForPublish(
1210
1210
  source,
1211
1211
  contentId: id,
1212
1212
  requireBody: true,
1213
+ // #329: source-section defects block at publish, warn on save.
1214
+ blockSourceSectionDefects: true,
1213
1215
  },
1214
1216
  )
1215
1217
  }
@@ -0,0 +1,153 @@
1
+ // Canonical normalization for article "Sources" / "References" sections
2
+ // (packages#329).
3
+ //
4
+ // Editors paste these sections out of Word and Google Docs, which produces
5
+ // Markdown that renders wrongly rather than not at all: manual `●` bullets
6
+ // become separate paragraphs, a bold line used as a heading becomes an empty
7
+ // `h2`, and `[**title**](url)` keeps its `**` inside the anchor text because
8
+ // renderers protect generated links before evaluating inline emphasis.
9
+ //
10
+ // This lives in the package, not in a site, so the CMS save path, preview
11
+ // path, import path and every site integration share one behaviour.
12
+
13
+ const NBSP = ' '
14
+ const SECTION_WORDS = ['sources', 'references'] as const
15
+
16
+ export interface SourceSectionNormalization {
17
+ markdown: string
18
+ /** True when normalization altered the document. Callers use this to decide
19
+ * whether a cached `body_html` render must be invalidated. */
20
+ changed: boolean
21
+ }
22
+
23
+ /** `## Sources`, `### References:` — returns its level and canonical word. */
24
+ function matchHeadingBoundary(line: string): { level: number; word: string } | null {
25
+ const match = /^(#{1,6})\s+(.*)$/.exec(line)
26
+ if (!match) return null
27
+ const level = match[1]?.length ?? 0
28
+ const word = canonicalWord(stripTrailingColon(match[2] ?? ''))
29
+ return word ? { level, word } : null
30
+ }
31
+
32
+ /** A standalone bold line used as a heading: `**Sources:**`, `__References__`. */
33
+ function matchBoldBoundary(line: string): { word: string } | null {
34
+ const match = /^\s*(\*\*|__)(.+?)\1\s*$/.exec(line)
35
+ if (!match) return null
36
+ const word = canonicalWord(stripTrailingColon(match[2] ?? ''))
37
+ return word ? { word } : null
38
+ }
39
+
40
+ function stripTrailingColon(value: string): string {
41
+ return value.replace(/\s*:\s*$/, '')
42
+ }
43
+
44
+ /** Returns the canonical capitalisation, or null when this is not a boundary. */
45
+ function canonicalWord(rawText: string): string | null {
46
+ const visible = rawText.replaceAll(NBSP, ' ').replace(/[*_]/g, '').trim().toLowerCase()
47
+ const found = SECTION_WORDS.find((candidate) => candidate === visible)
48
+ return found ? `${found[0]?.toUpperCase()}${found.slice(1)}` : null
49
+ }
50
+
51
+ /** A line whose visible content is empty once emphasis and spacing are removed. */
52
+ function isFormattingOnly(line: string): boolean {
53
+ if (line.trim() === '') return false // a real blank line is structural
54
+ return (
55
+ line
56
+ .replaceAll(NBSP, ' ')
57
+ .replace(/[*_~`]/g, '')
58
+ .trim() === ''
59
+ )
60
+ }
61
+
62
+ function headingLevel(line: string): number | null {
63
+ const match = /^(#{1,6})\s+\S/.exec(line)
64
+ return match ? (match[1]?.length ?? null) : null
65
+ }
66
+
67
+ /**
68
+ * Unwrap emphasis that covers an ENTIRE link label: `[**t**](u)` -> `[t](u)`.
69
+ *
70
+ * Partial emphasis (`[a **b** c](u)`) is intentional and preserved, and an
71
+ * unbalanced marker (`[**a](u)`) is ambiguous — repairing it would be guessing
72
+ * at intent. The URL is never touched.
73
+ */
74
+ function unwrapWholeLabelEmphasis(line: string): string {
75
+ return line.replace(/\[([^\]]*)\]\(/g, (whole, label: string) => {
76
+ const match = /^(\*\*|__|\*|_)([\s\S]+)\1$/.exec(label.trim())
77
+ if (!match) return whole
78
+ const inner = match[2] ?? ''
79
+ // Only unwrap when the delimiter genuinely wraps the whole label rather
80
+ // than being two adjacent runs, e.g. `**a** and **b**`.
81
+ if (inner.includes(match[1] as string)) return whole
82
+ return `[${inner}](`
83
+ })
84
+ }
85
+
86
+ function normalizeSectionLine(line: string): string {
87
+ let next = line
88
+
89
+ // Manual Unicode bullets become real list markers.
90
+ next = next.replace(/^(\s*)[●•▪]\s*/u, '$1- ')
91
+
92
+ // Non-breaking-space indentation after a list marker collapses to one space.
93
+ next = next.replace(
94
+ new RegExp(`^(\\s*[-*+])[ ${NBSP}]+`, 'u'),
95
+ (_m, marker: string) => `${marker} `,
96
+ )
97
+
98
+ next = unwrapWholeLabelEmphasis(next)
99
+ return next
100
+ }
101
+
102
+ /**
103
+ * Rewrite `Sources` / `References` sections to the canonical shape.
104
+ *
105
+ * Only lines inside a bounded section are touched. A section starts at an ATX
106
+ * heading or a standalone bold line whose visible text is `Sources` or
107
+ * `References` (with an optional trailing colon), and ends at the next heading
108
+ * of the same or higher level, or at end of document.
109
+ *
110
+ * Deliberately does NOT rewrite URLs, invent titles for bare URLs, repair
111
+ * unbalanced emphasis, or alter anything outside a section — each of those
112
+ * requires guessing at editorial intent.
113
+ */
114
+ export function normalizeSourceSections(markdown: string): SourceSectionNormalization {
115
+ const usesCrlf = markdown.includes('\r\n')
116
+ const lines = markdown.replace(/\r\n/g, '\n').split('\n')
117
+ const out: string[] = []
118
+
119
+ let sectionLevel: number | null = null
120
+
121
+ for (const line of lines) {
122
+ if (sectionLevel !== null) {
123
+ const level = headingLevel(line)
124
+ if (level !== null && level <= sectionLevel) {
125
+ sectionLevel = null // section closed; fall through to boundary checks
126
+ } else {
127
+ if (isFormattingOnly(line)) continue
128
+ out.push(normalizeSectionLine(line))
129
+ continue
130
+ }
131
+ }
132
+
133
+ const heading = matchHeadingBoundary(line)
134
+ if (heading) {
135
+ sectionLevel = heading.level
136
+ out.push(`${'#'.repeat(heading.level)} ${heading.word}`)
137
+ continue
138
+ }
139
+
140
+ const bold = matchBoldBoundary(line)
141
+ if (bold) {
142
+ sectionLevel = 2
143
+ out.push(`## ${bold.word}`)
144
+ continue
145
+ }
146
+
147
+ out.push(line)
148
+ }
149
+
150
+ const joined = out.join('\n')
151
+ const result = usesCrlf ? joined.replace(/\n/g, '\r\n') : joined
152
+ return { markdown: result, changed: result !== markdown }
153
+ }