kaloko 0.5.2 → 0.7.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.
- package/README.md +97 -6
- package/package.json +1 -1
- package/schemas/changelog.v1.json +83 -0
- package/schemas/run.v1.json +19 -0
- package/schemas/scenario.v1.json +6 -1
- package/skills/kaloko/SKILL.md +35 -13
- package/skills/kaloko/reference/design.md +55 -0
- package/skills/kaloko/reference/docs.md +130 -4
- package/skills/kaloko/reference/integrations.md +67 -0
- package/skills/kaloko/reference/mcp.md +8 -1
- package/src/cli.mjs +241 -11
- package/src/lib/changelog.mjs +144 -8
- package/src/lib/ci.mjs +71 -0
- package/src/lib/design-cli.mjs +22 -2
- package/src/lib/design-notes.mjs +191 -0
- package/src/lib/docs-core.mjs +450 -0
- package/src/lib/docs-export.mjs +271 -0
- package/src/lib/docs.mjs +221 -0
- package/src/lib/figma.mjs +584 -0
- package/src/lib/git.mjs +23 -3
- package/src/lib/integrations-cli.mjs +211 -0
- package/src/lib/keyboard.mjs +42 -12
- package/src/lib/onboarding.mjs +3 -1
- package/src/lib/screens-cli.mjs +36 -0
- package/src/lib/service.mjs +20 -1
- package/viewer/index.html +580 -90
package/src/lib/changelog.mjs
CHANGED
|
@@ -259,7 +259,7 @@ export async function releaseProduct ({ root, config, product, version = null, d
|
|
|
259
259
|
const tag = tagFor(src.pattern, v)
|
|
260
260
|
const commit = (await tagExists(root, tag)) ? ((await out('git', ['rev-list', '-n', '1', tag], root)) || '').trim() : ((await out('git', ['rev-parse', 'HEAD'], root)) || '').trim()
|
|
261
261
|
const payload = {
|
|
262
|
-
product:
|
|
262
|
+
product: await productPayload(root, product, src),
|
|
263
263
|
release: { product: product.slug, version: v, date: section?.date || day, changelog: md, source_hash: sha256(md), tag: (await tagExists(root, tag)) ? tag : null, commit: /^[0-9a-f]{40}$/.test(commit) ? commit : null, ...(runs ? { runs } : {}) }
|
|
264
264
|
}
|
|
265
265
|
if (dryRun) return { version: v, date: payload.release.date, file, section: !!section, finalized, source: src, notes, payload, items: parseEntry(md).groups.reduce((n, g) => n + g.items.length, 0), refs: refs.length }
|
|
@@ -268,6 +268,81 @@ export async function releaseProduct ({ root, config, product, version = null, d
|
|
|
268
268
|
const rec = await client.request('POST', `/orgs/${config.org}/releases`, payload.release)
|
|
269
269
|
return { version: v, date: payload.release.date, file, section: !!section, finalized, source: src, notes, release: rec, product: saved.product, items: parseEntry(md).groups.reduce((n, g) => n + g.items.length, 0), refs: refs.length }
|
|
270
270
|
}
|
|
271
|
+
/** The product as the service records it (POST /products): from the config, with the version source found. */
|
|
272
|
+
export async function productPayload (root, product, src) {
|
|
273
|
+
return { slug: product.slug, name: product.name, projects: product.projects, modules: product.modules, reference_env: product.reference_env, default_scenario: product.default_scenario, implicit: product.implicit, version_source: { source: src.source, ...(src.path ? { path: src.path } : {}), pattern: src.pattern, ...(src.tool ? { tool: src.tool } : {}) }, ...(product.publish ? { publish: product.publish } : {}), ...(await repoUrl(root)) }
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// ---------- products on the service ----------
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* `kaloko products sync`: every product of the config (or the one asked for) on Kaloko with its name, projects,
|
|
280
|
+
* modules, version source, reference environment, default scenario and who should read it (publish:, which the
|
|
281
|
+
* service applies only for someone who manages the product), without recording a release. A product the service
|
|
282
|
+
* refuses (a project it does not know or the person may not map) is reported and the others still go.
|
|
283
|
+
*/
|
|
284
|
+
export async function syncProducts ({ root, config, client = null, only = null, dryRun = false }) {
|
|
285
|
+
const all = productsOf(config, root)
|
|
286
|
+
if (typeof only === 'string' && only && !all.some((p) => p.slug === only)) throw new UserError(`No product ${only} in the config (${all.map((x) => x.slug).join(', ')}).`)
|
|
287
|
+
const out = []
|
|
288
|
+
for (const product of all.filter((p) => !only || p.slug === only)) {
|
|
289
|
+
const src = await detectVersionSource(root, product)
|
|
290
|
+
const payload = await productPayload(root, product, src)
|
|
291
|
+
const item = { slug: product.slug, implicit: product.implicit, name: product.name, projects: product.projects, modules: product.modules, version_source: payload.version_source, version: src.version || null, payload }
|
|
292
|
+
if (dryRun) { out.push(item); continue }
|
|
293
|
+
try {
|
|
294
|
+
const saved = await client.request('POST', `/orgs/${config.org}/products`, payload)
|
|
295
|
+
out.push({ ...item, ok: true, created: saved.created ?? null, publish: saved.product?.publish || null, url: saved.page || saved.url || null, changelog: saved.url || null, notes: saved.notes || [] })
|
|
296
|
+
} catch (e) { out.push({ ...item, ok: false, error: String(e.message || e).split('\n')[0] }) }
|
|
297
|
+
}
|
|
298
|
+
return { dry_run: dryRun, from_config: !!Object.keys(config.products || {}).length, products: out }
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** What `kaloko products sync` did, for people. */
|
|
302
|
+
export function formatSync (r) {
|
|
303
|
+
const lines = []
|
|
304
|
+
const n = r.products.length
|
|
305
|
+
lines.push(r.dry_run ? `${n} product(s) would go to Kaloko; dry run, nothing sent.` : `${r.products.filter((p) => p.ok).length} of ${n} product(s) on Kaloko; no release recorded.`)
|
|
306
|
+
if (!r.from_config) lines.push(' No products: in kaloko.config.yml, so the project is the product (its own changelog and docs).')
|
|
307
|
+
for (const p of r.products) {
|
|
308
|
+
const what = [`${p.projects.length ? `projects ${p.projects.join(', ')}` : 'the whole organization'}`, p.modules.length ? `modules ${p.modules.join(', ')}` : null, `version from ${p.version_source.source}${p.version ? ` (${p.version})` : ''}`].filter(Boolean).join(' · ')
|
|
309
|
+
const name = typeof p.name === 'object' && p.name ? Object.values(p.name)[0] : p.name
|
|
310
|
+
lines.push(` ${p.ok === false ? '✗' : r.dry_run ? '·' : p.created ? '+' : '✓'} ${p.slug}${name && name !== p.slug ? ` (${name})` : ''}: ${what}`)
|
|
311
|
+
if (p.url) lines.push(` ${p.url}`)
|
|
312
|
+
if (p.error) lines.push(` ! ${p.error}`)
|
|
313
|
+
for (const x of p.notes || []) lines.push(` ! ${x}`)
|
|
314
|
+
}
|
|
315
|
+
return lines.join('\n')
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* `kaloko products list`: the products the service has that the person reads, and the products of the config the
|
|
320
|
+
* service does not have yet (kaloko products sync puts them there).
|
|
321
|
+
*/
|
|
322
|
+
export async function listProducts ({ root, config, client }) {
|
|
323
|
+
const service = await client.request('GET', `/orgs/${config.org}/products`)
|
|
324
|
+
const local = Object.keys(config.products || {}).length ? productsOf(config, root) : []
|
|
325
|
+
const known = new Set(service.map((p) => p.slug))
|
|
326
|
+
return { products: service, missing: local.filter((p) => !known.has(p.slug)).map((p) => p.slug) }
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** The list for people. */
|
|
330
|
+
export function formatList (r) {
|
|
331
|
+
const lines = []
|
|
332
|
+
if (!r.products.length) lines.push('No product on Kaloko that you read yet. kaloko products sync puts the products of kaloko.config.yml there; kaloko release and kaloko docs publish do it too.')
|
|
333
|
+
for (const p of r.products) {
|
|
334
|
+
const name = typeof p.name === 'object' && p.name ? (p.name.en || Object.values(p.name)[0]) : p.name
|
|
335
|
+
const docs = p.docs ? `docs ${p.docs.versions.join(', ')}${p.docs.languages?.length ? ` (${p.docs.languages.join(', ')})` : ''}` : 'no docs yet'
|
|
336
|
+
const rel = p.latest_release ? `${p.latest_release.version} on ${p.latest_release.released_on}` : 'no release yet'
|
|
337
|
+
lines.push(`${p.slug}${name && name !== p.slug ? ` (${name})` : ''}${p.implicit ? ' · the project\'s own' : ''}: ${rel} · ${docs} · read by ${p.readers}${p.public_url ? ` (${p.public_url})` : ''}`)
|
|
338
|
+
if (p.modules?.length) lines.push(` modules: ${p.modules.join(', ')}`)
|
|
339
|
+
if (p.projects?.length) lines.push(` projects: ${p.projects.join(', ')}`)
|
|
340
|
+
if (p.url) lines.push(` ${p.url}`)
|
|
341
|
+
}
|
|
342
|
+
if (r.missing.length) lines.push(`In kaloko.config.yml but not on Kaloko yet: ${r.missing.join(', ')}. Run kaloko products sync.`)
|
|
343
|
+
return lines.join('\n')
|
|
344
|
+
}
|
|
345
|
+
|
|
271
346
|
async function repoUrl (root) {
|
|
272
347
|
const r = await repoSlug(root)
|
|
273
348
|
return r?.host === 'github' ? { repo: `https://github.com/${r.slug}` } : r?.host === 'gitlab' ? { repo: `https://gitlab.com/${r.slug}` } : {}
|
|
@@ -276,8 +351,9 @@ async function repoUrl (root) {
|
|
|
276
351
|
// ---------- init and doctor ----------
|
|
277
352
|
|
|
278
353
|
/** The config block `kaloko init --docs=changelog|full` adds (comments say what each line does). */
|
|
279
|
-
export function docsConfigBlock ({ products = null } = {}) {
|
|
354
|
+
export function docsConfigBlock ({ products = null, full = false } = {}) {
|
|
280
355
|
const lines = ['', '# Visual changelog: kaloko changelog draft writes the Unreleased entry with step references,', '# kaloko release <version> records the release and its pictures (docs/DOCS-CHANGELOG.md on kaloko.app)', 'docs:', ' changelog: CHANGELOG.md', ' version_source: auto # git-tag | package.json | release-please | changesets | semantic-release']
|
|
356
|
+
if (full) lines.push(' dir: docs # guides with step references; kaloko docs publish sends them, kaloko docs check checks them', ' # languages: [en, cs] # the first is the originals\' language; translations sit next to them (guide.cs.md)')
|
|
281
357
|
if (products?.length) {
|
|
282
358
|
lines.push('# one product per app in this repository; scenarios say which one they show (product: <slug>)', 'products:')
|
|
283
359
|
for (const p of products) lines.push(` ${p.slug}:`, ` name: ${JSON.stringify(p.name)}`, ` version: { source: package.json, path: ${p.path} }`, ` docs: { changelog: ${p.dir}/CHANGELOG.md }`)
|
|
@@ -298,13 +374,73 @@ export async function detectProducts (root) {
|
|
|
298
374
|
return out.length > 1 ? out : []
|
|
299
375
|
}
|
|
300
376
|
|
|
301
|
-
/**
|
|
377
|
+
/**
|
|
378
|
+
* Diátaxis folders and an index for `kaloko init --docs=full` (only what is missing). Each folder's README says what
|
|
379
|
+
* belongs there and carries a template in a comment (comments never show on Kaloko or in exports).
|
|
380
|
+
*/
|
|
302
381
|
export const DOCS_SKELETON = {
|
|
303
|
-
'docs/index.md': '# Documentation\n\nGuides for the people who use the product. Pictures are step references\n(``) that always show the newest accepted screen.\n\n- [Tutorials](tutorials/)
|
|
304
|
-
'docs/tutorials/README.md':
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
382
|
+
'docs/index.md': '# Documentation\n\nGuides for the people who use the product. Pictures are step references\n(``) that always show the newest accepted screen.\n\n- [Tutorials](tutorials/README.md): learn by doing, start to finish\n- [How-to guides](how-to/README.md): one task each\n- [Reference](reference/README.md): the facts to look up\n- [Explanation](explanation/README.md): why it works this way\n',
|
|
383
|
+
'docs/tutorials/README.md': `# Tutorials
|
|
384
|
+
|
|
385
|
+
A tutorial takes a newcomer through a first success, start to finish, on a path that always works.
|
|
386
|
+
|
|
387
|
+
<!-- Template (copy into a new file, e.g. first-order.md):
|
|
388
|
+
---
|
|
389
|
+
title: Your first order
|
|
390
|
+
order: 1
|
|
391
|
+
---
|
|
392
|
+
# Your first order
|
|
393
|
+
|
|
394
|
+
In ten minutes you will have placed an order and seen it in your account.
|
|
395
|
+
|
|
396
|
+
1. Open the shop and put a tea in the cart.
|
|
397
|
+

|
|
398
|
+
2. Choose **Checkout** and pay with a test card.
|
|
399
|
+

|
|
400
|
+
|
|
401
|
+
You have placed your first order. Next: [track it](../how-to/track-an-order.md).
|
|
402
|
+
-->
|
|
403
|
+
`,
|
|
404
|
+
'docs/how-to/README.md': `# How-to guides
|
|
405
|
+
|
|
406
|
+
One task per page, for someone who already knows what they want to do.
|
|
407
|
+
|
|
408
|
+
<!-- Template (copy into a new file, e.g. pay-with-apple-pay.md):
|
|
409
|
+
# Pay with Apple Pay
|
|
410
|
+
|
|
411
|
+
1. Open the cart and choose **Checkout**.
|
|
412
|
+

|
|
413
|
+
2. Choose **Apple Pay** above the card form and confirm on your phone or Mac.
|
|
414
|
+

|
|
415
|
+
|
|
416
|
+
If the button is missing, your browser has no Apple Pay set up.
|
|
417
|
+
-->
|
|
418
|
+
`,
|
|
419
|
+
'docs/reference/README.md': `# Reference
|
|
420
|
+
|
|
421
|
+
Facts to look up: fields, limits, settings, keyboard shortcuts. Tables and lists, no story.
|
|
422
|
+
|
|
423
|
+
<!-- Template (copy into a new file, e.g. order-states.md):
|
|
424
|
+
# Order states
|
|
425
|
+
|
|
426
|
+
| State | What it means |
|
|
427
|
+
|---|---|
|
|
428
|
+
| Paid | The payment went through; the order waits for packing. |
|
|
429
|
+
| Shipped | The parcel left the warehouse. |
|
|
430
|
+
|
|
431
|
+

|
|
432
|
+
-->
|
|
433
|
+
`,
|
|
434
|
+
'docs/explanation/README.md': `# Explanation
|
|
435
|
+
|
|
436
|
+
Why things work the way they do: background, choices, trade-offs.
|
|
437
|
+
|
|
438
|
+
<!-- Template (copy into a new file, e.g. why-we-hold-payments.md):
|
|
439
|
+
# Why a payment waits a day
|
|
440
|
+
|
|
441
|
+
Card payments are confirmed the next morning, so a cancelled order costs the customer nothing.
|
|
442
|
+
-->
|
|
443
|
+
`
|
|
308
444
|
}
|
|
309
445
|
|
|
310
446
|
/**
|
package/src/lib/ci.mjs
CHANGED
|
@@ -209,3 +209,74 @@ export function addPreviewEnvironment (text, name = 'preview') {
|
|
|
209
209
|
const at = m.index + m[0].length
|
|
210
210
|
return text.slice(0, at) + '\n' + block + text.slice(at)
|
|
211
211
|
}
|
|
212
|
+
|
|
213
|
+
// ---------- releases (docs/DOCS-CHANGELOG.md → release-tool integrations) ----------
|
|
214
|
+
|
|
215
|
+
const TOOL_SNIPPETS = {
|
|
216
|
+
'release-please': { after: 'googleapis/release-please-action (id: release)', when: '${{ steps.release.outputs.release_created }}', version: '${{ steps.release.outputs.version }}' },
|
|
217
|
+
changesets: { after: 'changesets/action (id: changesets)', when: "${{ steps.changesets.outputs.published == 'true' }}", version: '' },
|
|
218
|
+
'semantic-release': { after: 'npx semantic-release', when: null, version: '' }
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* .github/workflows/kaloko-release.yml: when a GitHub release is published (release-please, semantic-release with
|
|
223
|
+
* @semantic-release/github and changesets all publish one) or by hand, `kaloko release <version> --no-write` records
|
|
224
|
+
* it on Kaloko: the entry the release tool wrote, the accepted runs that are its pictures, and the docs of that
|
|
225
|
+
* version. `products`: [{ slug, pattern }] (tag patterns such as v* or shop-v*); several products get one step each.
|
|
226
|
+
*/
|
|
227
|
+
export function releaseWorkflow ({ products = [{ slug: null, pattern: 'v*' }], pm = 'npm', dependency = true, version = 'latest', node = 22, tool = null }) {
|
|
228
|
+
const { install, kaloko: k } = runner({ pm, dependency, version })
|
|
229
|
+
const several = products.length > 1
|
|
230
|
+
const steps = products.map((p) => {
|
|
231
|
+
const prefix = String(p.pattern || 'v*').split('*')[0]
|
|
232
|
+
const flag = several || p.slug ? ` --product ${q(p.slug)}` : ''
|
|
233
|
+
return ` - name: Record ${p.slug || 'the release'} on Kaloko
|
|
234
|
+
${several ? ` if: startsWith(env.TAG, '${prefix}')\n` : ''} run: ${k} release "\${TAG#${prefix}}"${flag} --no-write`
|
|
235
|
+
}).join('\n')
|
|
236
|
+
return `# Kaloko: record every release on Kaloko once your release tool has published it: its changelog entry with
|
|
237
|
+
# before/after pictures, the accepted runs that are its reference, and the docs of that version.
|
|
238
|
+
# Written by kaloko ci github --release${tool ? ` (${tool} found)` : ''}. Secret: KALOKO_TOKEN (tester role, upload scope).
|
|
239
|
+
# kaloko release --no-write only reads CHANGELOG.md; the release tool keeps writing it.
|
|
240
|
+
name: Kaloko release
|
|
241
|
+
on:
|
|
242
|
+
release:
|
|
243
|
+
types: [published]
|
|
244
|
+
workflow_dispatch:
|
|
245
|
+
inputs:
|
|
246
|
+
tag:
|
|
247
|
+
description: Tag of the release to record (e.g. v2.4.0)
|
|
248
|
+
required: true
|
|
249
|
+
permissions:
|
|
250
|
+
contents: read
|
|
251
|
+
jobs:
|
|
252
|
+
record:
|
|
253
|
+
runs-on: ubuntu-latest
|
|
254
|
+
timeout-minutes: 10
|
|
255
|
+
env:
|
|
256
|
+
KALOKO_TOKEN: \${{ secrets.KALOKO_TOKEN }}
|
|
257
|
+
GH_TOKEN: \${{ github.token }}
|
|
258
|
+
KALOKO_AGENT: github-actions
|
|
259
|
+
TAG: \${{ github.event.release.tag_name || inputs.tag }}
|
|
260
|
+
steps:
|
|
261
|
+
- uses: actions/checkout@v4
|
|
262
|
+
with:
|
|
263
|
+
ref: \${{ github.event.release.tag_name || inputs.tag }}
|
|
264
|
+
fetch-depth: 0 # the tags and the history since the previous release
|
|
265
|
+
- uses: actions/setup-node@v4
|
|
266
|
+
with:
|
|
267
|
+
node-version: ${node}
|
|
268
|
+
${install ? `${['pnpm', 'yarn'].includes(pm) ? ' - run: corepack enable\n' : ''} - run: ${install}\n` : ''}${steps}
|
|
269
|
+
`
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** The step to paste into an existing release workflow, right after the release tool's own step. */
|
|
273
|
+
export function releaseSnippet ({ tool = null, pm = 'npm', dependency = true, version = 'latest', product = null }) {
|
|
274
|
+
const { kaloko: k } = runner({ pm, dependency, version })
|
|
275
|
+
const s = TOOL_SNIPPETS[tool] || { after: 'the step that publishes the release', when: null, version: '' }
|
|
276
|
+
return `# after ${s.after}:
|
|
277
|
+
- name: Record the release on Kaloko
|
|
278
|
+
${s.when ? ` if: ${s.when}\n` : ''} run: ${k} release${s.version ? ` ${s.version}` : ''}${product ? ` --product ${q(product)}` : ''} --no-write
|
|
279
|
+
env:
|
|
280
|
+
KALOKO_TOKEN: \${{ secrets.KALOKO_TOKEN }}
|
|
281
|
+
`
|
|
282
|
+
}
|
package/src/lib/design-cli.mjs
CHANGED
|
@@ -297,11 +297,31 @@ export async function syncDesign ({ config, root, run, scenario, env, client, ar
|
|
|
297
297
|
return { pushed: toPush.length, pulled: toPull.length, conflicts: conflicts.length ? conflicts : null, seq: after.seq }
|
|
298
298
|
}
|
|
299
299
|
|
|
300
|
-
/**
|
|
300
|
+
/** The notes an agent or person wrote for a version: --notes <file> or --notes - (stdin); null without the option. */
|
|
301
|
+
export async function readNotesArg (value, stdin = process.stdin) {
|
|
302
|
+
if (value == null || value === false) return null
|
|
303
|
+
if (value === true) throw new UserError('--notes takes a file (Markdown, text or JSON) or - for standard input.')
|
|
304
|
+
if (value === '-') { const chunks = []; for await (const c of stdin) chunks.push(c); return Buffer.concat(chunks.map((c) => Buffer.from(c))).toString('utf8') }
|
|
305
|
+
try { return await readFile(value, 'utf8') } catch { throw new UserError(`--notes: cannot read ${value}.`) }
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** What publishing the draft now would write as "what changed" (kaloko share --notes-preview). */
|
|
309
|
+
export async function previewNotes ({ config, scenario, client, args, say }) {
|
|
310
|
+
const branch = typeof args.branch === 'string' ? args.branch : 'main'
|
|
311
|
+
const r = await client.request('GET', `/orgs/${config.org}/scenarios/${enc(scenario.id)}/versions/draft/notes?branch=${enc(branch)}`)
|
|
312
|
+
if (args.json) { say(JSON.stringify(r, null, 2)); return r }
|
|
313
|
+
const { notesText } = await import('./design-notes.mjs')
|
|
314
|
+
say(`What publishing now would say (${r.changed} changed, ${r.unchanged} unchanged${r.notes?.from ? ` since v${r.notes.from.version}` : ''}):\n\n${notesText(r.notes, { lang: args.lang === 'cs' ? 'cs' : 'en' })}\n\nWrite your own in a file (a summary, then "step-id: what changed" lines) and publish with kaloko share --notes <file>.`)
|
|
315
|
+
return r
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** Publishes the draft head as a version (a run of the design environment), with "what changed" notes. */
|
|
301
319
|
export async function publishVersion ({ config, scenario, client, args, say }) {
|
|
302
320
|
const branch = typeof args.branch === 'string' ? args.branch : 'main'
|
|
303
|
-
const
|
|
321
|
+
const notes = await readNotesArg(args.notes)
|
|
322
|
+
const r = await client.request('POST', `/orgs/${config.org}/scenarios/${enc(scenario.id)}/versions`, { branch, ...(typeof args.name === 'string' ? { name: args.name } : {}), ...(notes ? { notes } : {}) })
|
|
304
323
|
say(`Version v${r.version}${r.name ? ` "${r.name}"` : ''} · ${r.changed} changed, ${r.unchanged} unchanged${r.changed_steps.length ? ` (${r.changed_steps.join(', ')})` : ''}\n${r.url}`)
|
|
324
|
+
if (r.notes) { const { notesText } = await import('./design-notes.mjs'); say(`\nWhat changed${notes ? ' (your notes over the generated ones)' : ''}:\n${notesText(r.notes)}`) }
|
|
305
325
|
return r
|
|
306
326
|
}
|
|
307
327
|
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
// "What changed" notes of a design version (batch G, #3): a short plain-language changelog per changed step against
|
|
2
|
+
// the previous version, built from what the service already knows (revision ids, screenshot hashes per viewport,
|
|
3
|
+
// the text the step spec captured from the DOM, the token set of each revision). Pure and dependency-free apart from
|
|
4
|
+
// the token module, so the CLI (preview, --notes) and the Worker (publishing) share it.
|
|
5
|
+
import { flattenTokens } from './tokens.mjs'
|
|
6
|
+
|
|
7
|
+
export const NOTES_SCHEMA = 'qawalk.version-notes.v1'
|
|
8
|
+
export const NOTE_LIMITS = { summary: 2000, step: 1000, texts: 6, tokens: 40 }
|
|
9
|
+
|
|
10
|
+
/** Text in the reader's language: an edited note is one string for everyone, a generated one has en and cs. */
|
|
11
|
+
export const noteText = (t, lang = 'en') => (t == null ? '' : typeof t === 'string' ? t : t[lang] || t.en || '')
|
|
12
|
+
|
|
13
|
+
const clean = (s) => String(s ?? '').replace(/\s+/g, ' ').trim()
|
|
14
|
+
const quote = (s, lang) => (lang === 'cs' ? `„${s}“` : `“${s}”`)
|
|
15
|
+
const short = (s, n = 60) => (s.length > n ? s.slice(0, n - 1) + '…' : s)
|
|
16
|
+
const list = (items, lang) => {
|
|
17
|
+
if (items.length < 2) return items.join('')
|
|
18
|
+
return `${items.slice(0, -1).join(', ')} ${lang === 'cs' ? 'a' : 'and'} ${items[items.length - 1]}`
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The texts a capture shows: accessible names of the spec's elements (from the DOM), the H1 and the page title.
|
|
23
|
+
* `names` may be the spec's elements or a plain list of names (the Worker extracts them in SQL).
|
|
24
|
+
*/
|
|
25
|
+
export function textsOf ({ names = [], h1 = [], title = null } = {}) {
|
|
26
|
+
const out = new Set()
|
|
27
|
+
for (const n of [...[].concat(h1 || []), ...names.map((x) => (x && typeof x === 'object' ? x.name : x))]) {
|
|
28
|
+
const t = clean(n)
|
|
29
|
+
if (t && t.length > 1 && !/^[\d\s.,:/-]+$/.test(t)) out.add(t)
|
|
30
|
+
}
|
|
31
|
+
if (title && clean(title)) out.add(clean(title))
|
|
32
|
+
return out
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Texts that appeared and disappeared; one of each becomes "from → to" when that reads naturally. */
|
|
36
|
+
export function textChanges (before, after) {
|
|
37
|
+
const removed = [...before].filter((t) => !after.has(t))
|
|
38
|
+
const added = [...after].filter((t) => !before.has(t))
|
|
39
|
+
if (!removed.length && !added.length) return null
|
|
40
|
+
const out = { kind: 'text', added: added.slice(0, NOTE_LIMITS.texts), removed: removed.slice(0, NOTE_LIMITS.texts) }
|
|
41
|
+
if (added.length > NOTE_LIMITS.texts) out.more_added = added.length - NOTE_LIMITS.texts
|
|
42
|
+
if (removed.length > NOTE_LIMITS.texts) out.more_removed = removed.length - NOTE_LIMITS.texts
|
|
43
|
+
return out
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Token changes between two DTCG sets (resolved values): changed, added and removed names. */
|
|
47
|
+
export function tokenChanges (before, after) {
|
|
48
|
+
const flat = (set) => { try { return new Map(set ? flattenTokens(set).map((t) => [t.name, JSON.stringify(t.value)]) : []) } catch { return new Map() } }
|
|
49
|
+
const a = flat(before), b = flat(after)
|
|
50
|
+
const show = (v) => { try { const x = JSON.parse(v); return typeof x === 'string' || typeof x === 'number' ? String(x) : v } catch { return v } }
|
|
51
|
+
const changed = [], added = [], removed = []
|
|
52
|
+
for (const [name, v] of b) { if (!a.has(name)) added.push(name); else if (a.get(name) !== v) changed.push({ name, from: show(a.get(name)), to: show(v) }) }
|
|
53
|
+
for (const name of a.keys()) if (!b.has(name)) removed.push(name)
|
|
54
|
+
if (!changed.length && !added.length && !removed.length) return null
|
|
55
|
+
return { changed: changed.slice(0, NOTE_LIMITS.tokens), added: added.slice(0, NOTE_LIMITS.tokens), removed: removed.slice(0, NOTE_LIMITS.tokens), count: changed.length + added.length + removed.length }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** One change as a sentence. */
|
|
59
|
+
function changeText (c, lang) {
|
|
60
|
+
const cs = lang === 'cs'
|
|
61
|
+
switch (c.kind) {
|
|
62
|
+
case 'new': return cs ? 'Nový krok.' : 'New step.'
|
|
63
|
+
case 'removed': return cs ? 'Krok z flow zmizel.' : 'Removed from the flow.'
|
|
64
|
+
case 'locales': return cs ? `Nově i v jazycích ${c.added.join(', ')}.` : `Now also in ${c.added.join(', ')}.`
|
|
65
|
+
case 'text': {
|
|
66
|
+
const parts = []
|
|
67
|
+
const [a] = c.added, [r] = c.removed
|
|
68
|
+
if (c.added.length === 1 && c.removed.length === 1 && !c.more_added && !c.more_removed) parts.push(`${quote(short(r), lang)} → ${quote(short(a), lang)}`)
|
|
69
|
+
else {
|
|
70
|
+
if (c.added.length) parts.push(`${cs ? 'nově' : 'new'} ${c.added.map((t) => quote(short(t, 40), lang)).join(', ')}${c.more_added ? (cs ? ` a ${c.more_added} dalších` : ` and ${c.more_added} more`) : ''}`)
|
|
71
|
+
if (c.removed.length) parts.push(`${cs ? 'zmizelo' : 'gone'} ${c.removed.map((t) => quote(short(t, 40), lang)).join(', ')}${c.more_removed ? (cs ? ` a ${c.more_removed} dalších` : ` and ${c.more_removed} more`) : ''}`)
|
|
72
|
+
}
|
|
73
|
+
return `${cs ? 'Text' : 'Text'}: ${parts.join('; ')}.`
|
|
74
|
+
}
|
|
75
|
+
case 'look': return c.viewports?.length ? (cs ? `Vypadá jinak na ${list(c.viewports, lang)}.` : `Looks different on ${list(c.viewports, lang)}.`) : (cs ? 'Vypadá jinak.' : 'Looks different.')
|
|
76
|
+
case 'same_look': return cs ? 'Vypadá stejně, změnil se jen kód stránky.' : 'Looks the same; only the page code changed.'
|
|
77
|
+
case 'tokens': return cs ? `Používá novou sadu tokenů (${c.count} ${c.count === 1 ? 'změna' : c.count < 5 ? 'změny' : 'změn'}).` : `Uses the new token set (${c.count} change${c.count === 1 ? '' : 's'}).`
|
|
78
|
+
case 'not_rendered': return cs ? 'Zatím bez nového snímku.' : 'Not rendered yet.'
|
|
79
|
+
default: return ''
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export const stepText = (changes, lang) => changes.map((c) => changeText(c, lang)).filter(Boolean).join(' ')
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Builds the notes from the facts of each changed step. `steps`: [{ step, title, locales, reason: new|changed|removed,
|
|
87
|
+
* look: [viewports whose screenshot differs] | null (no captures to compare), texts: { before: Set, after: Set } | null,
|
|
88
|
+
* tokens: boolean (the revision's token set changed), files: boolean }], `tokens`: tokenChanges() of the version or
|
|
89
|
+
* null, `unchanged`: count of unchanged step×locale, `from`: the previous version { version, id } or null.
|
|
90
|
+
*/
|
|
91
|
+
export function buildNotes ({ steps = [], tokens = null, unchanged = 0, from = null }) {
|
|
92
|
+
const out = []
|
|
93
|
+
for (const s of steps) {
|
|
94
|
+
const changes = []
|
|
95
|
+
if (s.reason === 'new') changes.push({ kind: 'new' })
|
|
96
|
+
else if (s.reason === 'removed') changes.push({ kind: 'removed' })
|
|
97
|
+
else {
|
|
98
|
+
if (s.new_locales?.length) changes.push({ kind: 'locales', added: s.new_locales })
|
|
99
|
+
if (s.texts) { const t = textChanges(s.texts.before, s.texts.after); if (t) changes.push(t) }
|
|
100
|
+
if (s.look == null) changes.push({ kind: 'not_rendered' })
|
|
101
|
+
else if (s.look.length) changes.push({ kind: 'look', viewports: s.look })
|
|
102
|
+
if (s.tokens && tokens) changes.push({ kind: 'tokens', count: tokens.count })
|
|
103
|
+
if (s.look && !s.look.length && !changes.some((c) => c.kind === 'text' || c.kind === 'tokens' || c.kind === 'locales')) changes.push({ kind: 'same_look' })
|
|
104
|
+
}
|
|
105
|
+
out.push({ step: s.step, title: s.title || s.step, locales: s.locales || [], reason: s.reason, changes, text: { en: stepText(changes, 'en'), cs: stepText(changes, 'cs') } })
|
|
106
|
+
}
|
|
107
|
+
return { schema: NOTES_SCHEMA, from, summary: summaryOf(out, tokens, unchanged), steps: out, tokens, unchanged }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function summaryOf (steps, tokens, unchanged) {
|
|
111
|
+
const mk = (lang) => {
|
|
112
|
+
const cs = lang === 'cs'
|
|
113
|
+
const changed = steps.filter((s) => s.reason !== 'removed')
|
|
114
|
+
const removed = steps.filter((s) => s.reason === 'removed')
|
|
115
|
+
const parts = []
|
|
116
|
+
if (!steps.length) parts.push(cs ? 'Beze změny proti předchozí verzi.' : 'Nothing changed since the previous version.')
|
|
117
|
+
else {
|
|
118
|
+
const names = (xs) => xs.slice(0, 6).map((s) => s.title).join(', ') + (xs.length > 6 ? (cs ? ` a ${xs.length - 6} dalších` : ` and ${xs.length - 6} more`) : '')
|
|
119
|
+
if (changed.length) parts.push(cs ? `Změněno: ${names(changed)}.` : `Changed: ${names(changed)}.`)
|
|
120
|
+
if (removed.length) parts.push(cs ? `Odebráno: ${names(removed)}.` : `Removed: ${names(removed)}.`)
|
|
121
|
+
if (unchanged) parts.push(cs ? `Beze změny: ${unchanged} (počítáno po jazycích).` : `Unchanged: ${unchanged} (counted per locale).`)
|
|
122
|
+
}
|
|
123
|
+
if (tokens?.changed?.length) {
|
|
124
|
+
const shown = tokens.changed.slice(0, 3)
|
|
125
|
+
const t = shown.map((c) => `${c.name} ${c.from} → ${c.to}`).join(', ')
|
|
126
|
+
const more = tokens.count - shown.length
|
|
127
|
+
parts.push(cs ? `Tokeny: ${t}${more ? ` a ${more} ${more === 1 ? 'další' : 'dalších'}` : ''}.` : `Tokens: ${t}${more ? ` and ${more} more` : ''}.`)
|
|
128
|
+
} else if (tokens?.count) parts.push(cs ? `Sada tokenů: ${tokens.count} ${tokens.count === 1 ? 'změna' : tokens.count < 5 ? 'změny' : 'změn'}.` : `Token set: ${tokens.count} change${tokens.count === 1 ? '' : 's'}.`)
|
|
129
|
+
return parts.join(' ')
|
|
130
|
+
}
|
|
131
|
+
return { en: mk('en'), cs: mk('cs') }
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Notes someone wrote (an agent with --notes, a person in the canvas): JSON { summary, steps: { id: text } } or text
|
|
136
|
+
* where lines "step-id: text" (optionally as a list item) belong to that step and the rest is the summary.
|
|
137
|
+
*/
|
|
138
|
+
export function parseNotes (input, stepIds = []) {
|
|
139
|
+
if (input == null || input === '') return null
|
|
140
|
+
let obj = input
|
|
141
|
+
if (typeof input === 'string') {
|
|
142
|
+
const s = input.trim()
|
|
143
|
+
if (s.startsWith('{')) { try { obj = JSON.parse(s) } catch { obj = s } } else obj = s
|
|
144
|
+
}
|
|
145
|
+
if (obj && typeof obj === 'object' && !Array.isArray(obj)) {
|
|
146
|
+
const steps = {}
|
|
147
|
+
const src = obj.steps && typeof obj.steps === 'object' ? obj.steps : {}
|
|
148
|
+
for (const [k, v] of Array.isArray(src) ? src.map((x) => [x?.step, x?.text]) : Object.entries(src)) { const t = clean(typeof v === 'string' ? v : v?.text ?? v?.en ?? ''); if (k && t) steps[String(k)] = t.slice(0, NOTE_LIMITS.step) }
|
|
149
|
+
const summary = typeof obj.summary === 'string' ? obj.summary.trim().slice(0, NOTE_LIMITS.summary) : null
|
|
150
|
+
return summary || Object.keys(steps).length ? { summary, steps } : null
|
|
151
|
+
}
|
|
152
|
+
const ids = new Set(stepIds)
|
|
153
|
+
const steps = {}
|
|
154
|
+
const rest = []
|
|
155
|
+
let current = null
|
|
156
|
+
for (const line of String(obj).split(/\r?\n/)) {
|
|
157
|
+
const m = line.match(/^\s*(?:[-*]\s+)?(?:\*\*|`)?([A-Za-z0-9][A-Za-z0-9._-]*)(?:\*\*|`)?\s*:\s+(.*)$/)
|
|
158
|
+
if (m && ids.has(m[1])) { current = m[1]; steps[current] = clean(m[2]); continue }
|
|
159
|
+
if (current && /^\s{2,}\S/.test(line)) { steps[current] = clean(`${steps[current]} ${line}`); continue }
|
|
160
|
+
current = null
|
|
161
|
+
rest.push(line)
|
|
162
|
+
}
|
|
163
|
+
for (const k of Object.keys(steps)) steps[k] = steps[k].slice(0, NOTE_LIMITS.step)
|
|
164
|
+
const summary = rest.join('\n').replace(/^\s*#+\s.*$/gm, '').trim().slice(0, NOTE_LIMITS.summary) || null
|
|
165
|
+
return summary || Object.keys(steps).length ? { summary, steps } : null
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Generated notes with the edits applied: an edited summary or step text replaces the generated one. */
|
|
169
|
+
export function mergeNotes (auto, edited, { by = null, at = null, titles = {} } = {}) {
|
|
170
|
+
const base = auto ? JSON.parse(JSON.stringify(auto)) : { schema: NOTES_SCHEMA, from: null, summary: null, steps: [], tokens: null, unchanged: 0 }
|
|
171
|
+
if (!edited) return base
|
|
172
|
+
if (edited.summary) { base.summary = edited.summary; base.summary_edited = true }
|
|
173
|
+
for (const [id, text] of Object.entries(edited.steps || {})) {
|
|
174
|
+
const s = base.steps.find((x) => x.step === id)
|
|
175
|
+
if (s) { s.text = text; s.edited = true } else base.steps.push({ step: id, title: titles[id] || id, locales: [], reason: 'note', changes: [], text, edited: true })
|
|
176
|
+
}
|
|
177
|
+
if (by) base.edited_by = by
|
|
178
|
+
if (at) base.edited_at = at
|
|
179
|
+
return base
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** The notes as plain text for the terminal and MCP. */
|
|
183
|
+
export function notesText (notes, { lang = 'en', title = '' } = {}) {
|
|
184
|
+
if (!notes) return 'No notes.'
|
|
185
|
+
const lines = []
|
|
186
|
+
if (title) lines.push(title)
|
|
187
|
+
const sum = noteText(notes.summary, lang)
|
|
188
|
+
if (sum) lines.push(sum)
|
|
189
|
+
for (const s of notes.steps || []) lines.push(`- ${s.step}${s.title && s.title !== s.step ? ` (${s.title})` : ''}${s.locales?.length ? ` [${s.locales.join(', ')}]` : ''}: ${noteText(s.text, lang)}${s.edited ? '' : ''}`)
|
|
190
|
+
return lines.join('\n')
|
|
191
|
+
}
|