kaloko 0.5.1 → 0.6.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.
@@ -0,0 +1,323 @@
1
+ // The visual changelog (docs/DOCS-CHANGELOG.md, phase 1), the parts the CLI and the service share: step references
2
+ // in Markdown, Keep a Changelog parsing and editing, commit classification and the draft skeleton. No I/O here: the
3
+ // worker imports this file too (worker/src/changelog.js), so it stays plain JavaScript without Node built-ins.
4
+
5
+ // ---------- step references ----------
6
+
7
+ /** Scenario and step ids as the service accepts them (design.js SCENARIO_ID). */
8
+ const ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,119}$/
9
+ const PARAMS = {
10
+ locale: /^[A-Za-z0-9_-]{1,20}$/,
11
+ viewport: /^[A-Za-z0-9._-]{1,80}$/,
12
+ version: /^[A-Za-z0-9._+-]{1,80}$/,
13
+ state: /^[A-Za-z0-9._-]{1,60}$/
14
+ }
15
+ export const REF_SCHEME = 'kaloko:'
16
+
17
+ /**
18
+ * A step reference `kaloko:<scenario>/<step>[?locale=&viewport=&version=&state=]`, or the short `kaloko:<step>` that
19
+ * means a step of the product's default scenario. `version` is a released version, `latest` (default) or `prev` (the
20
+ * release before the one the text belongs to); `state` picks a variant of the capture (dark, webkit, reduce: the
21
+ * dimension values in the capture key). Returns null for anything else.
22
+ */
23
+ export function parseStepRef (ref) {
24
+ const s = String(ref || '').trim()
25
+ if (!s.startsWith(REF_SCHEME)) return null
26
+ const rest = s.slice(REF_SCHEME.length)
27
+ const qi = rest.indexOf('?')
28
+ const path = qi < 0 ? rest : rest.slice(0, qi)
29
+ let parts
30
+ try { parts = path.split('/').map((p) => decodeURIComponent(p)) } catch { return null }
31
+ if (!parts.length || parts.length > 2 || parts.some((p) => !ID.test(p))) return null
32
+ const out = { scenario: parts.length === 2 ? parts[0] : null, step: parts[parts.length - 1], locale: null, viewport: null, version: null, state: null }
33
+ const q = new URLSearchParams(qi < 0 ? '' : rest.slice(qi + 1))
34
+ for (const [k, v] of q) {
35
+ if (!(k in PARAMS)) return null
36
+ if (!PARAMS[k].test(v)) return null
37
+ out[k] = v
38
+ }
39
+ if (out.version === 'latest') out.version = null
40
+ return out
41
+ }
42
+
43
+ /** The reference text of a parsed (or hand-built) reference; parameters in a fixed order. */
44
+ export function formatStepRef ({ scenario = null, step, locale = null, viewport = null, version = null, state = null }) {
45
+ const path = `${scenario ? `${encodeURIComponent(scenario)}/` : ''}${encodeURIComponent(step)}`
46
+ const q = Object.entries({ locale, viewport, version, state }).filter(([, v]) => v != null && v !== '')
47
+ return `${REF_SCHEME}${path}${q.length ? `?${q.map(([k, v]) => `${k}=${encodeURIComponent(v)}`).join('&')}` : ''}`
48
+ }
49
+
50
+ /** Every image with a step reference in a Markdown text: { alt, ref, parsed, index }; broken ones have parsed null. */
51
+ export function findStepRefs (markdown) {
52
+ const out = []
53
+ const re = /!\[([^\]]*)\]\((kaloko:[^)\s]+)\)/g
54
+ let m
55
+ while ((m = re.exec(String(markdown || '')))) out.push({ alt: m[1], ref: m[2], parsed: parseStepRef(m[2]), index: m.index })
56
+ return out
57
+ }
58
+
59
+ // ---------- versions ----------
60
+
61
+ /** "v2.4.0" → "2.4.0"; anything else stays as it is (trimmed). */
62
+ export const cleanVersion = (v) => String(v ?? '').trim().replace(/^v(?=\d)/, '')
63
+
64
+ /** Semantic Versioning order (prereleases before their release); versions that are not semver sort by text. */
65
+ export function compareVersions (a, b) {
66
+ const p = (v) => { const m = cleanVersion(v).match(/^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?/); return m ? { n: [Number(m[1]), Number(m[2]), Number(m[3])], pre: m[4] || null } : null }
67
+ const x = p(a); const y = p(b)
68
+ if (!x || !y) return String(a).localeCompare(String(b))
69
+ for (let i = 0; i < 3; i++) if (x.n[i] !== y.n[i]) return x.n[i] - y.n[i]
70
+ if (x.pre === y.pre) return 0
71
+ if (!x.pre) return 1
72
+ if (!y.pre) return -1
73
+ return x.pre.localeCompare(y.pre, 'en', { numeric: true })
74
+ }
75
+
76
+ /** The git tag of a version for a tag pattern ("v*" → "v2.4.0", "shop-v*" → "shop-v2.4.0"). */
77
+ export const tagFor = (pattern, version) => String(pattern || 'v*').replace('*', cleanVersion(version))
78
+ /** The version a tag names under a pattern, or null when the tag does not match it. */
79
+ export function versionOfTag (pattern, tag) {
80
+ const [pre, post = ''] = String(pattern || 'v*').split('*')
81
+ const t = String(tag || '')
82
+ if (!t.startsWith(pre) || !t.endsWith(post) || t.length <= pre.length + post.length) return null
83
+ return cleanVersion(t.slice(pre.length, t.length - post.length))
84
+ }
85
+
86
+ // ---------- Keep a Changelog ----------
87
+
88
+ export const GROUPS = ['Added', 'Changed', 'Deprecated', 'Removed', 'Fixed', 'Security']
89
+ const GROUP_ALIASES = { added: 'Added', new: 'Added', features: 'Added', 'minor changes': 'Added', changed: 'Changed', 'patch changes': 'Fixed', 'major changes': 'Changed', improvements: 'Changed', deprecated: 'Deprecated', removed: 'Removed', fixed: 'Fixed', 'bug fixes': 'Fixed', fixes: 'Fixed', security: 'Security', 'performance improvements': 'Changed' }
90
+ /** A group heading as written by people or release tools ("Bug Fixes", "Minor Changes") in Keep a Changelog terms. */
91
+ export const groupName = (h) => GROUP_ALIASES[String(h || '').trim().toLowerCase()] || String(h || '').trim()
92
+
93
+ export const CHANGELOG_HEAD = `# Changelog
94
+
95
+ All notable changes for the people who use this product. The format follows
96
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and versions follow
97
+ [Semantic Versioning](https://semver.org/). Pictures are step references
98
+ (\`![](kaloko:<scenario>/<step>)\`) that Kaloko shows from the accepted runs.
99
+ `
100
+ export const CHANGELOG_SKELETON = `${CHANGELOG_HEAD}
101
+ ## [Unreleased]
102
+ `
103
+
104
+ const RELEASE_HEADING = /^##\s+(.+?)\s*$/
105
+ const DATE = /(\d{4}-\d{2}-\d{2})/
106
+
107
+ /** "## [2.4.0] - 2026-10-12", "## [1.2.0](https://…compare…) (2026-10-12)", "## 1.2.0", "## [Unreleased]". */
108
+ export function parseHeading (line) {
109
+ const m = String(line).match(RELEASE_HEADING)
110
+ if (!m || /^#/.test(m[1])) return null
111
+ const text = m[1]
112
+ const bracket = text.match(/^\[([^\]]+)\]/)
113
+ const label = (bracket ? bracket[1] : text.split(/\s+/)[0]).trim()
114
+ if (/^unreleased$/i.test(label)) return { unreleased: true, version: null, date: null }
115
+ const version = cleanVersion(label)
116
+ if (!/^\d/.test(version)) return null
117
+ const rest = text.slice(bracket ? bracket[0].length : label.length)
118
+ return { unreleased: false, version, date: (rest.match(DATE) || [])[1] || null }
119
+ }
120
+
121
+ /** List items of a group body: each item keeps its continuation lines (pictures under it), without the "- ". */
122
+ export function listItems (body) {
123
+ const items = []
124
+ let cur = null
125
+ for (const line of String(body || '').split('\n')) {
126
+ const m = line.match(/^[-*+]\s+(.*)$/)
127
+ if (m) { if (cur) items.push(cur.trimEnd()); cur = m[1]; continue }
128
+ if (cur != null && (/^\s+\S/.test(line) || line.trim() === '')) { cur += `\n${line.replace(/^ {1,4}/, '')}`; continue }
129
+ if (cur != null) { items.push(cur.trimEnd()); cur = null }
130
+ }
131
+ if (cur) items.push(cur.trimEnd())
132
+ return items.map((x) => x.replace(/\n+$/, '')).filter(Boolean)
133
+ }
134
+
135
+ /** Groups of a release body: [{ name, heading, items }] plus text before the first group (notes). */
136
+ export function parseEntry (body) {
137
+ const groups = []; const notes = []
138
+ let cur = null
139
+ for (const line of String(body || '').replace(/\r/g, '').split('\n')) {
140
+ const h = line.match(/^###\s+(.+?)\s*$/)
141
+ if (h) { cur = { name: groupName(h[1]), heading: h[1].trim(), lines: [] }; groups.push(cur); continue }
142
+ if (cur) cur.lines.push(line); else notes.push(line)
143
+ }
144
+ return { notes: notes.join('\n').trim(), groups: groups.map((g) => ({ name: g.name, heading: g.heading, items: listItems(g.lines.join('\n')) })).filter((g) => g.items.length || g.name) }
145
+ }
146
+
147
+ /**
148
+ * A changelog file: { head (text before the first release heading), sections: [{ unreleased, version, date, heading,
149
+ * body, start, end }] (line numbers, end exclusive), links (the reference definitions at the bottom, by lower-case
150
+ * label), linksStart }.
151
+ */
152
+ export function parseChangelog (text) {
153
+ const lines = String(text || '').replace(/\r/g, '').split('\n')
154
+ let linksStart = lines.length
155
+ while (linksStart > 0 && (/^\[[^\]]+\]:\s*\S+/.test(lines[linksStart - 1]) || lines[linksStart - 1].trim() === '')) linksStart--
156
+ if (!lines.slice(linksStart).some((l) => /^\[[^\]]+\]:/.test(l))) linksStart = lines.length
157
+ const links = {}
158
+ for (const l of lines.slice(linksStart)) { const m = l.match(/^\[([^\]]+)\]:\s*(\S+)/); if (m) links[m[1].toLowerCase()] = m[2] }
159
+ const sections = []
160
+ for (let i = 0; i < linksStart; i++) {
161
+ const h = /^##\s/.test(lines[i]) ? parseHeading(lines[i]) : null
162
+ if (!h) continue
163
+ if (sections.length) sections[sections.length - 1].end = i
164
+ sections.push({ ...h, heading: lines[i], start: i, end: linksStart })
165
+ }
166
+ for (const s of sections) s.body = lines.slice(s.start + 1, s.end).join('\n').trim()
167
+ const head = lines.slice(0, sections.length ? sections[0].start : linksStart).join('\n').trimEnd()
168
+ return { head, sections, links, linksStart, lines }
169
+ }
170
+
171
+ /** The section of a version (or the Unreleased one for null), or null. */
172
+ export function sectionOf (text, version) {
173
+ const { sections } = parseChangelog(text)
174
+ const v = version == null ? null : cleanVersion(version)
175
+ return sections.find((s) => v == null ? s.unreleased : !s.unreleased && s.version === v) || null
176
+ }
177
+
178
+ /** The newest released version written in the file (null when none). */
179
+ export function latestVersionIn (text) {
180
+ const v = parseChangelog(text).sections.filter((s) => !s.unreleased).map((s) => s.version)
181
+ return v.sort(compareVersions).pop() || null
182
+ }
183
+
184
+ const norm = (item) => String(item).split('\n')[0].replace(/<!--[\s\S]*?-->/g, '').replace(/\s+/g, ' ').trim().toLowerCase()
185
+
186
+ /** Markdown of groups ({ Added: [items] } in Keep a Changelog order; other names after them). */
187
+ export function groupsMarkdown (groups) {
188
+ const names = [...GROUPS.filter((g) => groups[g]?.length), ...Object.keys(groups).filter((g) => !GROUPS.includes(g) && groups[g]?.length)]
189
+ return names.map((g) => `### ${g}\n\n${groups[g].map((it) => `- ${String(it).split('\n').map((l, i) => i && l ? ` ${l}` : l).join('\n')}`).join('\n')}`).join('\n\n')
190
+ }
191
+
192
+ /**
193
+ * Puts draft items into the Unreleased section of a changelog: items whose first line is already there are left
194
+ * out, groups that exist get the new items at their end, missing groups are added in Keep a Changelog order. A file
195
+ * without an Unreleased section gets one above the newest release; an empty file gets the skeleton.
196
+ * Returns { text, added } (the number of new items).
197
+ */
198
+ export function applyDraft (text, groups) {
199
+ let src = String(text || '')
200
+ if (!src.trim()) src = CHANGELOG_SKELETON
201
+ let parsed = parseChangelog(src)
202
+ let un = parsed.sections.find((s) => s.unreleased)
203
+ if (!un) {
204
+ const lines = parsed.lines
205
+ const at = parsed.sections.length ? parsed.sections[0].start : parsed.linksStart
206
+ const before = lines.slice(0, at); while (before.length && before[before.length - 1].trim() === '') before.pop()
207
+ src = [...before, '', '## [Unreleased]', '', ...lines.slice(at)].join('\n')
208
+ parsed = parseChangelog(src)
209
+ un = parsed.sections.find((s) => s.unreleased)
210
+ }
211
+ const existing = parseEntry(un.body)
212
+ const seen = new Set(parsed.sections.flatMap((s) => parseEntry(s.body).groups.flatMap((g) => g.items.map(norm))))
213
+ const merged = new Map(existing.groups.map((g) => [g.name, { heading: g.heading, items: [...g.items] }]))
214
+ let added = 0
215
+ for (const [name, items] of Object.entries(groups)) {
216
+ for (const it of items || []) {
217
+ if (seen.has(norm(it))) continue
218
+ seen.add(norm(it))
219
+ if (!merged.has(name)) merged.set(name, { heading: name, items: [] })
220
+ merged.get(name).items.push(it); added++
221
+ }
222
+ }
223
+ const order = [...GROUPS.filter((g) => merged.has(g)), ...[...merged.keys()].filter((g) => !GROUPS.includes(g))]
224
+ const body = [existing.notes, ...order.map((g) => { const x = merged.get(g); return groupsMarkdown({ [g]: x.items }).replace(/^### [^\n]+/, `### ${x.heading}`) })].filter(Boolean).join('\n\n')
225
+ const lines = parsed.lines
226
+ const out = [...lines.slice(0, un.start + 1), '', ...(body ? [body, ''] : []), ...lines.slice(un.end)]
227
+ return { text: out.join('\n').replace(/\n{3,}/g, '\n\n').replace(/\n*$/, '\n'), added }
228
+ }
229
+
230
+ /**
231
+ * Turns the Unreleased section into the release `version` of `date` and opens a new, empty Unreleased section above
232
+ * it. Compare links at the bottom ([unreleased]: …/compare/v1...HEAD) move along. Returns { text, changed, empty }.
233
+ */
234
+ export function finalizeRelease (text, version, date, { tagPattern = 'v*' } = {}) {
235
+ const v = cleanVersion(version)
236
+ const parsed = parseChangelog(text)
237
+ if (parsed.sections.some((s) => !s.unreleased && s.version === v)) return { text, changed: false, empty: false }
238
+ const un = parsed.sections.find((s) => s.unreleased)
239
+ if (!un) return { text, changed: false, empty: true }
240
+ const lines = [...parsed.lines]
241
+ lines.splice(un.start, 1, '## [Unreleased]', '', `## [${v}] - ${date}`)
242
+ let out = lines.join('\n')
243
+ const link = parsed.links.unreleased
244
+ const m = link && link.match(/^(.*\/compare\/)(.+)\.\.\.HEAD$/)
245
+ if (m) {
246
+ const tag = tagFor(tagPattern, v)
247
+ out = out.replace(/^\[unreleased\]:\s*\S+$/im, `[unreleased]: ${m[1]}${tag}...HEAD\n[${v}]: ${m[1]}${m[2]}...${tag}`)
248
+ }
249
+ return { text: out, changed: true, empty: !un.body.trim() }
250
+ }
251
+
252
+ // ---------- commits and the draft ----------
253
+
254
+ const CONVENTIONAL = /^(\w+)(?:\(([^)]*)\))?(!)?:\s*(.+)$/
255
+ const INTERNAL = new Set(['docs', 'test', 'tests', 'chore', 'ci', 'build', 'style', 'refactor', 'release', 'wip', 'deps'])
256
+ const TYPE_GROUP = { feat: 'Added', feature: 'Added', add: 'Added', fix: 'Fixed', bugfix: 'Fixed', hotfix: 'Fixed', perf: 'Changed', revert: 'Changed', remove: 'Removed', deprecate: 'Deprecated', security: 'Security', sec: 'Security' }
257
+
258
+ /**
259
+ * Where a commit or pull request title goes: { group, text, internal, breaking, pr }. Conventional Commits when the
260
+ * title uses them; otherwise the first verb decides (add → Added, fix → Fixed, remove → Removed, else Changed).
261
+ * Documentation, tests, chores, CI, refactors, merges and releases are internal.
262
+ */
263
+ export function classifyCommit (subject, body = '') {
264
+ let s = String(subject || '').trim()
265
+ let pr = null
266
+ const sq = s.match(/\s*\(#(\d+)\)\s*$/)
267
+ if (sq) { pr = Number(sq[1]); s = s.slice(0, sq.index).trim() }
268
+ const breaking = /BREAKING[ -]CHANGE/.test(body)
269
+ if (/^Merge\b|^Revert "|^v?\d+\.\d+\.\d+$|\[skip changelog\]|\[no changelog\]/i.test(s)) return { group: null, text: s, internal: true, breaking, pr }
270
+ const m = s.match(CONVENTIONAL)
271
+ if (m) {
272
+ const type = m[1].toLowerCase()
273
+ const text = sentence(m[4])
274
+ if (INTERNAL.has(type) && !m[3] && !breaking) return { group: null, text, internal: true, breaking: false, pr, type, scope: m[2] || null }
275
+ const group = TYPE_GROUP[type] || (/^(remove|drop|delete)\b/i.test(m[4]) ? 'Removed' : 'Changed')
276
+ return { group: m[3] || breaking ? (group === 'Added' ? 'Added' : 'Changed') : group, text, internal: false, breaking: !!m[3] || breaking, pr, type, scope: m[2] || null }
277
+ }
278
+ const verb = s.toLowerCase()
279
+ const group = /^(add|adds|added|new|introduce|support|allow|enable)\b/.test(verb) ? 'Added'
280
+ : /^(fix|fixes|fixed|correct|repair|resolve|prevent)\b/.test(verb) ? 'Fixed'
281
+ : /^(remove|removes|removed|drop|delete)\b/.test(verb) ? 'Removed'
282
+ : /^(deprecate)\b/.test(verb) ? 'Deprecated' : 'Changed'
283
+ return { group, text: sentence(s), internal: false, breaking, pr }
284
+ }
285
+ const sentence = (t) => { const x = String(t || '').trim().replace(/\.$/, ''); return x ? x[0].toUpperCase() + x.slice(1) : x }
286
+
287
+ /**
288
+ * The draft entry: items grouped Added / Changed / Fixed / Removed (Deprecated, Security when present).
289
+ *
290
+ * commits [{ subject, body, pr: { number, url } | null }] — merged pull requests or commits since the last release
291
+ * changes [{ scenario, title, steps: [{ step, title, reason: new|changed|removed, pr: number|null }] }] — what the
292
+ * accepted runs since the last release show differently (from the service)
293
+ * Pictures are step references: a changed step gets before and after (`?version=prev` is the previous release), a
294
+ * new step one picture. A step whose run came from a pull request goes under that pull request's item.
295
+ * Returns { groups, internal (titles left out), markdown }.
296
+ */
297
+ export function draftEntry ({ commits = [], changes = [], scenarioLabel = (s) => s.title || s.scenario } = {}) {
298
+ const groups = {}
299
+ const internal = []
300
+ const byPr = new Map()
301
+ const add = (g, item) => { (groups[g] ||= []).push(item); return groups[g].length - 1 }
302
+ for (const c of commits) {
303
+ const k = classifyCommit(c.subject, c.body)
304
+ if (k.internal) { internal.push(c.subject); continue }
305
+ const number = c.pr?.number || k.pr
306
+ const url = c.pr?.url || null
307
+ const text = `${k.text}${k.breaking ? ' (breaking: say what people have to do)' : ''}${number ? ` (${url ? `[#${number}](${url})` : `#${number}`})` : ''}`
308
+ const i = add(k.group, text)
309
+ if (number && !byPr.has(number)) byPr.set(number, { group: k.group, i })
310
+ }
311
+ for (const sc of changes) {
312
+ for (const st of sc.steps || []) {
313
+ const ref = (version) => formatStepRef({ scenario: sc.scenario, step: st.step, version })
314
+ const title = st.title || st.step
315
+ const pics = st.reason === 'new' ? `![${title}](${ref(null)})` : st.reason === 'removed' ? `![${title} before](${ref('prev')})` : `![${title} before](${ref('prev')}) ![${title} after](${ref(null)})`
316
+ const home = st.pr && byPr.get(st.pr)
317
+ if (home) { groups[home.group][home.i] += `\n${pics}`; continue }
318
+ const g = st.reason === 'new' ? 'Added' : st.reason === 'removed' ? 'Removed' : 'Changed'
319
+ add(g, `**${title}** in ${scenarioLabel(sc)} <!-- say what a person notices here -->\n${pics}`)
320
+ }
321
+ }
322
+ return { groups, internal, markdown: groupsMarkdown(groups) }
323
+ }