dsh-plugin-discover 0.0.0-stage → 1.0.1

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.
@@ -0,0 +1,369 @@
1
+ /**
2
+ * Turn the two directory sources into one card per installable target.
3
+ *
4
+ * dsh.so wins every field it supplies; the catalog fills title, repository,
5
+ * `engines.dsh`, publish time and npm versions. Ecosystem apps without an
6
+ * install command are not installable targets and are left out.
7
+ */
8
+ import { createHash } from 'node:crypto'
9
+ import { qualityPrior } from './quality.js'
10
+
11
+ export const DSH_SO_URL = 'https://www.dsh.so/plugins-index.json'
12
+ export const CATALOG_URL = 'https://cdn.jsdelivr.net/npm/dsh-plugin-catalog/index.json'
13
+ export const ATTRIBUTION = 'Powered by dsh.so — DSH Developer Hub'
14
+
15
+ const NPM_TARGET_RE = /^(@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*(@[a-z0-9][a-z0-9._+-]*)?$/i
16
+ const GITHUB_TARGET_RE = /^github:[a-z0-9][a-z0-9._-]*\/[a-z0-9._-]+(#([^\s;|&$`<>()*?\\]+))?$/iu
17
+ const INSTALL_RE = /^dsh\s+plugin\s+--profile\s+[a-z0-9._-]+\s+add\s+(.+)$/i
18
+
19
+ function asString(value) {
20
+ return typeof value === 'string' ? value.trim() : ''
21
+ }
22
+
23
+ function asNumber(value) {
24
+ return typeof value === 'number' && Number.isFinite(value) ? value : null
25
+ }
26
+
27
+ function asStringArray(value, limit = 32) {
28
+ if (!Array.isArray(value)) return []
29
+ const seen = new Set()
30
+ for (const item of value) {
31
+ if (typeof item !== 'string') continue
32
+ const text = item.trim().toLowerCase()
33
+ if (text === '' || seen.has(text)) continue
34
+ seen.add(text)
35
+ if (seen.size >= limit) break
36
+ }
37
+ return [...seen]
38
+ }
39
+
40
+ /** Normalize a repository URL so `git+https`, `.git`, case, and trailing slash compare equal. */
41
+ export function normalizeRepo(url) {
42
+ if (typeof url !== 'string') return ''
43
+ let value = url.trim().toLowerCase()
44
+ value = value.replace(/^git\+/, '').replace(/^git@([^:]+):/, '$1/')
45
+ value = value.replace(/^[a-z][a-z0-9+.-]*:\/\//, '')
46
+ value = value.replace(/^www\./, '')
47
+ value = value.split(/[?#]/)[0]
48
+ value = value.replace(/\.git$/, '').replace(/\/+$/, '')
49
+ return value
50
+ }
51
+
52
+ /** Whether a target is a plain npm name or a GitHub spec, never a shell fragment. */
53
+ export function isSafeInstallTarget(target) {
54
+ if (typeof target !== 'string') return false
55
+ const text = target.trim()
56
+ if (text === '' || text.length > 200) return false
57
+ return NPM_TARGET_RE.test(text) || GITHUB_TARGET_RE.test(text)
58
+ }
59
+
60
+ /** Parse the dsh.so `install` command into its exact target. */
61
+ export function parseInstallTarget(install) {
62
+ if (typeof install !== 'string') return null
63
+ const match = INSTALL_RE.exec(install.trim())
64
+ if (!match) return null
65
+ const target = match[1].trim()
66
+ if (!isSafeInstallTarget(target)) return null
67
+ return { target }
68
+ }
69
+
70
+ /** Package name inside an npm or GitHub target (version and ref removed). */
71
+ export function packageNameOf(target) {
72
+ if (typeof target !== 'string') return ''
73
+ if (target.toLowerCase().startsWith('github:')) return target.split('#')[0]
74
+ if (target.startsWith('@')) {
75
+ const slash = target.indexOf('/')
76
+ if (slash === -1) return target
77
+ const at = target.indexOf('@', slash)
78
+ return at === -1 ? target : target.slice(0, at)
79
+ }
80
+ const at = target.indexOf('@')
81
+ return at === -1 ? target : target.slice(0, at)
82
+ }
83
+
84
+ function latestCatalogVersion(entry) {
85
+ const versions = Array.isArray(entry?.versions) ? entry.versions : []
86
+ let best = null
87
+ for (const version of versions) {
88
+ if (!version || typeof version !== 'object') continue
89
+ const time = asNumber(version.publishTimestamp) ?? -1
90
+ if (!best || time > best.time) best = { value: version, time }
91
+ }
92
+ return best?.value ?? null
93
+ }
94
+
95
+ function catalogText(value) {
96
+ if (typeof value === 'string') return value.trim()
97
+ if (value && typeof value === 'object') {
98
+ return asString(value.en) || asString(value.zh) || ''
99
+ }
100
+ return ''
101
+ }
102
+
103
+ function catalogTopics(entry) {
104
+ const metadata = entry?.metadata && typeof entry.metadata === 'object' ? entry.metadata : {}
105
+ const values = []
106
+ if (typeof metadata.category === 'string') values.push(metadata.category)
107
+ if (Array.isArray(metadata.keywords)) values.push(...metadata.keywords)
108
+ if (Array.isArray(metadata.labels)) values.push(...metadata.labels)
109
+ return asStringArray(values, 16)
110
+ }
111
+
112
+ function fitsVerification(value) {
113
+ if (!value || typeof value !== 'object') return null
114
+ const level = asNumber(value.level)
115
+ if (level === null || level < 1 || level > 5) return null
116
+ return {
117
+ level,
118
+ label: asString(value.label) || `L${level}`,
119
+ lastVerifiedAt: asString(value.lastVerifiedAt) || null,
120
+ }
121
+ }
122
+
123
+ function fitsSecurity(value) {
124
+ if (!value || typeof value !== 'object') return null
125
+ const status = asString(value.status) || 'unknown'
126
+ const riskLevel = asString(value.riskLevel) || 'unknown'
127
+ const counts = value.counts && typeof value.counts === 'object'
128
+ ? {
129
+ critical: asNumber(value.counts.critical) ?? 0,
130
+ warning: asNumber(value.counts.warning) ?? 0,
131
+ info: asNumber(value.counts.info) ?? 0,
132
+ }
133
+ : null
134
+ return {
135
+ status,
136
+ riskLevel,
137
+ scannedAt: asString(value.scannedAt) || null,
138
+ ...(counts ? { counts } : {}),
139
+ }
140
+ }
141
+
142
+ function contentHash(record) {
143
+ const payload = JSON.stringify([
144
+ record.name,
145
+ record.title ?? '',
146
+ record.description ?? '',
147
+ record.topics,
148
+ record.capability,
149
+ record.install?.target ?? '',
150
+ record.repo ?? '',
151
+ ])
152
+ return createHash('sha256').update(payload).digest('hex').slice(0, 16)
153
+ }
154
+
155
+ function baseRecord(record) {
156
+ return {
157
+ id: record.id,
158
+ name: record.name,
159
+ alias: record.alias ?? '',
160
+ title: record.title ?? '',
161
+ description: record.description ?? '',
162
+ topics: record.topics ?? [],
163
+ capability: record.capability ?? [],
164
+ stars: record.stars ?? null,
165
+ verification: record.verification ?? null,
166
+ security: record.security ?? null,
167
+ repo: record.repo ?? '',
168
+ repoKey: record.repoKey ?? '',
169
+ url: record.url ?? '',
170
+ install: record.install ?? null,
171
+ dshRange: record.dshRange ?? null,
172
+ updatedAt: record.updatedAt ?? null,
173
+ kind: record.kind ?? 'plugin',
174
+ sources: record.sources ?? [],
175
+ aliases: record.aliases ?? [],
176
+ alternatives: [],
177
+ }
178
+ }
179
+
180
+ /**
181
+ * Build the card set.
182
+ * @param {unknown} dshSo parsed dsh.so index
183
+ * @param {unknown} catalog parsed format-1 catalog, or null when unavailable
184
+ */
185
+ export function buildRecords(dshSo, catalog) {
186
+ if (!dshSo || typeof dshSo !== 'object' || !Array.isArray(dshSo.plugins)) {
187
+ throw new Error('dsh.so index is missing the plugins array')
188
+ }
189
+ const catalogPlugins = catalog && typeof catalog === 'object' && Array.isArray(catalog.plugins)
190
+ ? catalog.plugins
191
+ : []
192
+ const catalogByRepo = new Map()
193
+ const catalogByName = new Map()
194
+ for (const entry of catalogPlugins) {
195
+ if (!entry || typeof entry !== 'object' || typeof entry.name !== 'string') continue
196
+ const repoKey = normalizeRepo(entry.repository?.url)
197
+ if (repoKey && !catalogByRepo.has(repoKey)) catalogByRepo.set(repoKey, entry)
198
+ const name = entry.name.trim().toLowerCase()
199
+ if (name && !catalogByName.has(name)) catalogByName.set(name, entry)
200
+ }
201
+
202
+ const usedCatalog = new Set()
203
+ const usedNames = new Map()
204
+ const records = []
205
+ let skippedNoInstall = 0
206
+ let skippedBadInstall = 0
207
+
208
+ for (const plugin of dshSo.plugins) {
209
+ if (!plugin || typeof plugin !== 'object') continue
210
+ const install = parseInstallTarget(plugin.install)
211
+ if (plugin.install == null || String(plugin.install).trim() === '') {
212
+ skippedNoInstall += 1
213
+ continue
214
+ }
215
+ if (!install) {
216
+ skippedBadInstall += 1
217
+ continue
218
+ }
219
+ const repo = asString(plugin.source)
220
+ const repoKey = normalizeRepo(repo)
221
+ const alias = asString(plugin.alias)
222
+ const name = asString(plugin.name) || alias || asString(plugin.id)
223
+ if (name === '') continue
224
+ const catalogEntry = catalogByRepo.get(repoKey)
225
+ ?? catalogByName.get(alias.toLowerCase())
226
+ ?? catalogByName.get(name.toLowerCase())
227
+ if (catalogEntry) usedCatalog.add(catalogEntry)
228
+
229
+ const catalogVersion = catalogEntry ? latestCatalogVersion(catalogEntry) : null
230
+ const engines = catalogVersion?.engines && typeof catalogVersion.engines === 'object'
231
+ ? asString(catalogVersion.engines.dsh) : ''
232
+ const publishedAt = asNumber(catalogVersion?.publishTimestamp)
233
+ const record = baseRecord({
234
+ id: asString(plugin.id) || name,
235
+ name,
236
+ alias,
237
+ title: catalogEntry ? asString(catalogEntry.title) : '',
238
+ description: asString(plugin.description),
239
+ topics: asStringArray(plugin.topics),
240
+ capability: asStringArray(plugin.capability?.samples),
241
+ stars: asNumber(plugin.stars) ?? 0,
242
+ verification: fitsVerification(plugin.verification),
243
+ security: fitsSecurity(plugin.security),
244
+ repo,
245
+ repoKey,
246
+ url: asString(plugin.url) || asString(catalogEntry?.homepage) || repo,
247
+ install,
248
+ dshRange: engines || null,
249
+ updatedAt: publishedAt && publishedAt > 0 ? new Date(publishedAt * 1000).toISOString() : null,
250
+ kind: asString(plugin.kind) || 'plugin',
251
+ sources: catalogEntry ? ['dsh.so', 'dsh-plugin-catalog'] : ['dsh.so'],
252
+ aliases: [...new Set([name, alias].filter(Boolean))],
253
+ })
254
+ records.push(record)
255
+ }
256
+
257
+ const dshSoInstallable = records.length
258
+
259
+ for (const entry of catalogPlugins) {
260
+ if (usedCatalog.has(entry)) continue
261
+ if (!entry || typeof entry !== 'object') continue
262
+ const name = asString(entry.name)
263
+ if (!name) continue
264
+ const repo = asString(entry.repository?.url)
265
+ const repoKey = normalizeRepo(repo)
266
+ if (repoKey && catalogByRepo.has(repoKey) && usedCatalog.has(catalogByRepo.get(repoKey))) continue
267
+ if (catalogByName.has(name.toLowerCase()) && usedCatalog.has(catalogByName.get(name.toLowerCase()))) continue
268
+ const version = latestCatalogVersion(entry)
269
+ const versionText = asString(version?.version)
270
+ const target = versionText ? `${name}@${versionText}` : name
271
+ if (!isSafeInstallTarget(target)) continue
272
+ const engines = version?.engines && typeof version.engines === 'object' ? asString(version.engines.dsh) : ''
273
+ const publishedAt = asNumber(version?.publishTimestamp)
274
+ const record = baseRecord({
275
+ id: `catalog:${name}`,
276
+ name,
277
+ alias: name,
278
+ title: asString(entry.title),
279
+ description: catalogText(entry.description),
280
+ topics: catalogTopics(entry),
281
+ capability: [],
282
+ stars: null,
283
+ verification: null,
284
+ security: null,
285
+ repo,
286
+ repoKey,
287
+ url: asString(entry.homepage) || repo,
288
+ install: { target },
289
+ dshRange: engines || null,
290
+ updatedAt: publishedAt && publishedAt > 0 ? new Date(publishedAt * 1000).toISOString() : null,
291
+ kind: 'catalog',
292
+ sources: ['dsh-plugin-catalog'],
293
+ aliases: [name],
294
+ })
295
+ records.push(record)
296
+ }
297
+
298
+ const groups = new Map()
299
+ for (const record of records) {
300
+ const key = record.repoKey || `name:${record.name.toLowerCase()}`
301
+ const group = groups.get(key)
302
+ if (group) group.push(record)
303
+ else groups.set(key, [record])
304
+ }
305
+
306
+ const cards = []
307
+ let mergedCards = 0
308
+ for (const group of groups.values()) {
309
+ group.sort((a, b) => qualityPrior(b) - qualityPrior(a) || b.stars - a.stars)
310
+ const main = group[0]
311
+ if (group.length > 1) {
312
+ mergedCards += 1
313
+ main.alternatives = group.slice(1)
314
+ .map(item => ({ name: item.name, target: item.install?.target ?? '' }))
315
+ .filter(item => item.target !== '' && item.name !== main.name)
316
+ main.topics = [...new Set(group.flatMap(item => item.topics))]
317
+ main.capability = [...new Set(group.flatMap(item => item.capability))]
318
+ main.aliases = [...new Set(group.flatMap(item => item.aliases))]
319
+ const numericStars = group
320
+ .map(item => item.stars)
321
+ .filter(stars => typeof stars === 'number' && Number.isFinite(stars))
322
+ main.stars = numericStars.length > 0 ? Math.max(...numericStars) : null
323
+ const timestamps = group.map(item => item.updatedAt).filter(Boolean).sort()
324
+ main.updatedAt = timestamps.at(-1) ?? main.updatedAt
325
+ main.dshRange = main.dshRange ?? group.find(item => item.dshRange)?.dshRange ?? null
326
+ }
327
+ if (usedNames.has(main.id)) {
328
+ const next = usedNames.get(main.id) + 1
329
+ usedNames.set(main.id, next)
330
+ main.id = `${main.id}#${next}`
331
+ } else {
332
+ usedNames.set(main.id, 0)
333
+ }
334
+ main.contentHash = contentHash(main)
335
+ cards.push(main)
336
+ }
337
+ cards.sort((a, b) => a.id.localeCompare(b.id))
338
+
339
+ const generatedAt = asString(dshSo.generatedAt) || asString(dshSo.generated) || null
340
+ const catalogGeneratedAt = asString(catalog?.generatedAt) || asString(catalog?.metadata?.generatedAt) || null
341
+ return {
342
+ records: cards,
343
+ stats: {
344
+ dshSoTotal: dshSo.plugins.length,
345
+ dshSoInstallable,
346
+ skippedNoInstall,
347
+ skippedBadInstall,
348
+ catalogTotal: catalogPlugins.length,
349
+ catalogMerged: usedCatalog.size,
350
+ catalogOnly: cards.filter(record => record.sources.length === 1 && record.sources[0] === 'dsh-plugin-catalog').length,
351
+ cards: cards.length,
352
+ mergedCards,
353
+ },
354
+ sources: {
355
+ dshSo: {
356
+ url: DSH_SO_URL,
357
+ generatedAt,
358
+ count: dshSo.plugins.length,
359
+ ok: true,
360
+ },
361
+ catalog: {
362
+ url: CATALOG_URL,
363
+ generatedAt: catalogGeneratedAt,
364
+ count: catalogPlugins.length,
365
+ ok: catalogPlugins.length > 0,
366
+ },
367
+ },
368
+ }
369
+ }
package/lib/paths.js ADDED
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Where the shared, per-user index lives. The directory name is a stable
3
+ * identifier: it never follows a package rename and is shared by every DSH
4
+ * profile of the same OS user.
5
+ */
6
+ import { homedir } from 'node:os'
7
+ import { join } from 'node:path'
8
+
9
+ /** Absolute default index directory for the current platform. */
10
+ export function defaultIndexDir(env = process.env, platform = process.platform) {
11
+ if (platform === 'win32') {
12
+ return join(env.LOCALAPPDATA || join(homedir(), 'AppData', 'Local'), 'dsh-plugin-discover')
13
+ }
14
+ if (platform === 'darwin') {
15
+ return join(homedir(), 'Library', 'Caches', 'dsh-plugin-discover')
16
+ }
17
+ return join(env.XDG_CACHE_HOME || join(homedir(), '.cache'), 'dsh-plugin-discover')
18
+ }
19
+
20
+ /** The pointer file naming the published index directory. */
21
+ export function currentPath(indexDir) {
22
+ return join(indexDir, 'current')
23
+ }
24
+
25
+ /** The cross-process build lock. */
26
+ export function lockPath(indexDir) {
27
+ return join(indexDir, 'build.lock')
28
+ }
29
+
30
+ /** Human-readable build log written by the child process. */
31
+ export function buildLogPath(indexDir) {
32
+ return join(indexDir, 'build.log')
33
+ }
34
+
35
+ /** Reject any pointer value that would escape the index directory. */
36
+ export function isIndexDirName(name) {
37
+ return typeof name === 'string' && /^index-[0-9A-Za-zT._-]+$/.test(name)
38
+ }
39
+
40
+ /** Resolve an index directory name below its parent. */
41
+ export function resolveIndexDir(indexDir, name) {
42
+ if (!isIndexDirName(name)) return null
43
+ return join(indexDir, name)
44
+ }
45
+
46
+ /** New sortable directory name, e.g. `index-20261022T120000000Z`. */
47
+ export function newIndexDirName(now = new Date()) {
48
+ return `index-${now.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}Z$/, 'Z')}`
49
+ }
package/lib/quality.js ADDED
@@ -0,0 +1,61 @@
1
+ /**
2
+ * The quality prior. It may only lower a result by 20%: relevance stays the
3
+ * primary key, and stars can never silently become the sort key.
4
+ */
5
+
6
+ const VERIFICATION_SCORE = { 5: 1, 4: 0.8, 3: 0.6, 2: 0.45, 1: 0.3 }
7
+
8
+ /** Verification level as a 0..1 signal; missing is neutral. */
9
+ export function verificationScore(record) {
10
+ const level = record?.verification?.level
11
+ return VERIFICATION_SCORE[level] ?? 0.5
12
+ }
13
+
14
+ /** Security status/risk as a 0..1 signal; missing and pending are neutral. */
15
+ export function securityScore(record) {
16
+ const security = record?.security
17
+ if (!security || typeof security !== 'object') return 0.5
18
+ const status = security.status
19
+ const risk = security.riskLevel
20
+ if (status === 'failed') return 0.2
21
+ if (status === 'audited') {
22
+ if (risk === 'low') return 1
23
+ if (risk === 'medium') return 0.7
24
+ if (risk === 'high') return 0.25
25
+ if (risk === 'critical') return 0
26
+ return 0.5
27
+ }
28
+ if (status === 'pending') return 0.45
29
+ return 0.4
30
+ }
31
+
32
+ /** Popularity from stars, capped at 1000; unknown stars are neutral. */
33
+ export function popularityScore(record) {
34
+ const stars = record?.stars
35
+ if (typeof stars !== 'number' || !Number.isFinite(stars)) return 0.5
36
+ if (stars <= 0) return 0
37
+ return Math.min(1, Math.log1p(stars) / Math.log1p(1000))
38
+ }
39
+
40
+ /** Recency with a 90-day half-life; missing timestamps are neutral. */
41
+ export function recencyScore(record, now = Date.now()) {
42
+ const updatedAt = record?.updatedAt
43
+ if (!updatedAt) return 0.5
44
+ const time = Date.parse(updatedAt)
45
+ if (!Number.isFinite(time)) return 0.5
46
+ const ageDays = Math.max(0, (now - time) / 86_400_000)
47
+ return 0.5 ** (ageDays / 90)
48
+ }
49
+
50
+ /** Combined quality prior in 0..1. */
51
+ export function qualityPrior(record, now = Date.now()) {
52
+ return 0.55 * verificationScore(record)
53
+ + 0.20 * securityScore(record)
54
+ + 0.15 * popularityScore(record)
55
+ + 0.10 * recencyScore(record, now)
56
+ }
57
+
58
+ /** The bounded multiplier applied to a relevance score. */
59
+ export function qualityFactor(record, now = Date.now()) {
60
+ return 0.8 + 0.2 * qualityPrior(record, now)
61
+ }
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Strip README presentation and boilerplate that do not describe what the
3
+ * plugin does: badges, images, HTML, code, front matter, link-only lines, and
4
+ * license/install/contributing/changelog sections. The remaining text is then
5
+ * token-truncated by the model client.
6
+ */
7
+
8
+ export const README_CLEAN_VERSION = 1
9
+
10
+ const DROP_SECTIONS = new Set([
11
+ 'license',
12
+ 'licence',
13
+ 'licensing',
14
+ 'contributing',
15
+ 'contribution',
16
+ 'code of conduct',
17
+ 'security',
18
+ 'changelog',
19
+ 'release notes',
20
+ 'releases',
21
+ 'table of contents',
22
+ 'contents',
23
+ 'installation',
24
+ 'install',
25
+ 'getting started',
26
+ 'setup',
27
+ 'development',
28
+ 'developing',
29
+ 'testing',
30
+ 'tests',
31
+ 'sponsors',
32
+ 'sponsor',
33
+ 'support',
34
+ 'acknowledgements',
35
+ 'acknowledgments',
36
+ 'credits',
37
+ 'authors',
38
+ 'star history',
39
+ 'changelog and versioning',
40
+ ])
41
+
42
+ function normalizeHeading(value) {
43
+ return value
44
+ .toLowerCase()
45
+ .replace(/[`*_~]/g, '')
46
+ .replace(/[^\p{L}\p{N}\s/-]/gu, ' ')
47
+ .replace(/\s+/g, ' ')
48
+ .trim()
49
+ }
50
+
51
+ function isDropHeading(value) {
52
+ return DROP_SECTIONS.has(normalizeHeading(value))
53
+ }
54
+
55
+ function stripBoilerplateSections(text) {
56
+ const lines = text.split('\n')
57
+ const kept = []
58
+ let skipLevel = 0
59
+ for (const line of lines) {
60
+ const heading = line.match(/^\s{0,3}(#{1,6})\s+(.+?)\s*#*\s*$/)
61
+ if (heading) {
62
+ const level = heading[1].length
63
+ if (skipLevel && level <= skipLevel) skipLevel = 0
64
+ if (!skipLevel && isDropHeading(heading[2])) {
65
+ skipLevel = level
66
+ continue
67
+ }
68
+ }
69
+ if (skipLevel) continue
70
+ kept.push(line)
71
+ }
72
+ return kept.join('\n')
73
+ }
74
+
75
+ function stripBadges(text) {
76
+ return text
77
+ // Markdown image, including the common linked-badge form.
78
+ .replace(/!\[[^\]]*\]\([^)]*\)/g, '')
79
+ .replace(/!\[[^\]]*\]\[[^\]]*\]/g, '')
80
+ .replace(/\[\s*\]\([^)]*\)/g, '')
81
+ // Reference-style link definitions.
82
+ .replace(/^\s*\[[^\]]+\]:\s*\S+.*$/gm, '')
83
+ // Remaining markdown links keep only their label.
84
+ .replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
85
+ .replace(/\[([^\]]+)\]\[[^\]]*\]/g, '$1')
86
+ }
87
+
88
+ export function cleanReadmeText(input) {
89
+ let text = String(input ?? '').replace(/\r\n?/g, '\n')
90
+ // YAML front matter.
91
+ text = text.replace(/^---\n[\s\S]*?\n---\n?/, '')
92
+ // HTML comments.
93
+ text = text.replace(/<!--[\s\S]*?-->/g, '')
94
+ // Fenced code and indented code blocks.
95
+ text = text.replace(/```[\s\S]*?```/g, '\n')
96
+ text = text.replace(/~~~[\s\S]*?~~~/g, '\n')
97
+ text = text.replace(/^(?: {4}|\t).*$/gm, '')
98
+ // HTML tags; keep line breaks.
99
+ text = text.replace(/<br\s*\/?>/gi, '\n')
100
+ text = text.replace(/<[^>]*>/g, '')
101
+ // Badges, images, and link labels.
102
+ text = stripBadges(text)
103
+ // Drop badges/shields and link-only lines that survived as plain URLs.
104
+ text = text.split('\n').filter(line => {
105
+ if (/shields\.io|badgen|img\.shields|badge/i.test(line) && !/[.!?]/.test(line)) return false
106
+ const withoutUrls = line.replace(/https?:\/\/\S+/gi, '').replace(/[\s\-|:]/g, '')
107
+ return withoutUrls !== ''
108
+ }).join('\n')
109
+ // Drop boilerplate sections before removing heading markers.
110
+ text = stripBoilerplateSections(text)
111
+ // Heading/list/blockquote markers, keeping the text.
112
+ text = text.replace(/^\s{0,3}#{1,6}\s+/gm, '')
113
+ text = text.replace(/^\s{0,3}>\s?/gm, '')
114
+ text = text.replace(/^\s{0,3}(?:[-*+]|\d+[.)])\s+/gm, '')
115
+ // Table separators, pipes, emphasis, inline code markup.
116
+ text = text.replace(/^\s*\|?[\s:|-]+\|[\s:|-]*\|?\s*$/gm, '')
117
+ text = text.replace(/\|/g, ' ')
118
+ text = text.replace(/`([^`]+)`/g, '$1')
119
+ text = text.replace(/(\*\*|__)(.*?)\1/g, '$2')
120
+ text = text.replace(/[*_]{1,2}/g, '')
121
+ text = text.replace(/&(?:nbsp|amp|lt|gt|quot|#39);/gi, ' ')
122
+ // Horizontal rules and empty/punctuation-only lines.
123
+ text = text.split('\n')
124
+ .map(line => line.replace(/[ \t]+/g, ' ').trim())
125
+ .filter(line => line !== '' && !/^[-=*_]{3,}$/.test(line) && /[\p{L}\p{N}]/u.test(line))
126
+ .join('\n')
127
+ return text.replace(/\n{3,}/g, '\n\n').trim()
128
+ }