kaloko 0.5.2 → 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.
- package/README.md +75 -0
- 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 +28 -7
- package/skills/kaloko/reference/design.md +55 -0
- package/skills/kaloko/reference/docs.md +122 -3
- package/skills/kaloko/reference/integrations.md +67 -0
- package/skills/kaloko/reference/mcp.md +7 -1
- package/src/cli.mjs +213 -10
- package/src/lib/changelog.mjs +74 -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/screens-cli.mjs +36 -0
- package/src/lib/service.mjs +20 -1
- package/viewer/index.html +266 -30
|
@@ -0,0 +1,584 @@
|
|
|
1
|
+
// Figma import (batch G, docs/BACKLOG-IDEAS.md idea 1): frames of a Figma file become the steps of a design folder,
|
|
2
|
+
// so a design version made from them is the design reference implementation runs are checked against (pixels with
|
|
3
|
+
// design_pixels, styles and tokens with kaloko drift). One way: Figma → Kaloko; prototype links, components and
|
|
4
|
+
// auto layout are not carried over. Each step page shows the frame's PNG (1× and 2×) and, under it, the frame's text
|
|
5
|
+
// layers as real text with Figma's typography and colors (var(--token) where the layer uses a variable or style),
|
|
6
|
+
// so the step spec has text, roles and tokens to compare. Variables (or, without them, the color and text styles the
|
|
7
|
+
// frames use) become tokens.json (W3C DTCG). Teams without an API token import a folder exported from Figma (PNG/SVG).
|
|
8
|
+
import { readFile, writeFile, mkdir, readdir, rm, stat } from 'node:fs/promises'
|
|
9
|
+
import { join, relative, extname, basename } from 'node:path'
|
|
10
|
+
import yaml from 'js-yaml'
|
|
11
|
+
import { UserError } from './util.mjs'
|
|
12
|
+
import { validateTokens, flattenTokens } from './tokens.mjs'
|
|
13
|
+
import { TOKENS_FILE } from './design-core.mjs'
|
|
14
|
+
|
|
15
|
+
export const GENERATOR = 'kaloko figma import'
|
|
16
|
+
export const figmaApi = () => (process.env.KALOKO_FIGMA_API || 'https://api.figma.com').replace(/\/+$/, '')
|
|
17
|
+
const FRAME_TYPES = new Set(['FRAME', 'COMPONENT', 'COMPONENT_SET', 'INSTANCE', 'GROUP'])
|
|
18
|
+
const IMAGE_RE = /^figma(-[A-Za-z0-9_-]+)?(\.[23]x)?\.(png|svg|jpg|jpeg|webp)$/
|
|
19
|
+
|
|
20
|
+
// ---------------------------------------------------------------- references and names
|
|
21
|
+
|
|
22
|
+
/** A Figma file reference: { key, nodeIds } from a file URL (design/file/proto, ?node-id=1-2) or a bare key. */
|
|
23
|
+
export function parseFigmaRef (input) {
|
|
24
|
+
const s = String(input || '').trim()
|
|
25
|
+
let m = s.match(/^https?:\/\/(?:www\.)?figma\.com\/(?:file|design|proto|board)\/([A-Za-z0-9]{10,})(?:\/[^?#]*)?(?:\?([^#]*))?/)
|
|
26
|
+
if (m) {
|
|
27
|
+
const params = new URLSearchParams(m[2] || '')
|
|
28
|
+
const node = params.get('node-id')
|
|
29
|
+
return { key: m[1], nodeIds: node ? node.split(',').map((n) => n.replace(/-/g, ':')) : [] }
|
|
30
|
+
}
|
|
31
|
+
m = s.match(/^[A-Za-z0-9]{10,}$/)
|
|
32
|
+
return m ? { key: s, nodeIds: [] } : null
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** A name as a slug: lower case, diacritics folded, words joined by dashes. */
|
|
36
|
+
export const slug = (s) => String(s || '').normalize('NFKD').replace(/[\u0300-\u036f]/g, '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '')
|
|
37
|
+
/** A token path segment: as in the name, without the characters DTCG names may not have. */
|
|
38
|
+
const tokenSeg = (s) => String(s).trim().toLowerCase().replace(/[{}.$]/g, '').replace(/\s+/g, '-') || '_'
|
|
39
|
+
const segmentsOf = (name) => String(name || '').split(/\s*[/|@·—–]\s*|\s+-\s+|\s*[[\]()]\s*/).map(slug).filter(Boolean)
|
|
40
|
+
|
|
41
|
+
// ---------------------------------------------------------------- the REST API
|
|
42
|
+
|
|
43
|
+
/** A small client of the Figma REST API (X-Figma-Token); `fetch` can be replaced in tests. */
|
|
44
|
+
export function figmaClient ({ token, fetch: f = globalThis.fetch, base = figmaApi(), sleep = (ms) => new Promise((r) => setTimeout(r, ms)) } = {}) {
|
|
45
|
+
if (!token) throw new UserError('Figma needs a personal access token: create one in Figma (Settings → Security → Personal access tokens, scope file content read-only) and put it in FIGMA_TOKEN, or name the variable with --token-env. Without a token, export the frames as PNG and import the folder.')
|
|
46
|
+
const get = async (path, { optional = false } = {}) => {
|
|
47
|
+
for (let attempt = 0; ; attempt++) {
|
|
48
|
+
const res = await f(`${base}${path}`, { headers: { 'X-Figma-Token': token } })
|
|
49
|
+
if (res.status === 429 && attempt < 3) { await sleep(Math.min(30, Number(res.headers.get('retry-after')) || 2 ** attempt) * 1000); continue }
|
|
50
|
+
if (res.ok) return res.json()
|
|
51
|
+
if (optional && (res.status === 403 || res.status === 404)) return null
|
|
52
|
+
const body = await res.text().catch(() => '')
|
|
53
|
+
if (res.status === 403) throw new UserError(`Figma refused the token (403)${/scope|permission/i.test(body) ? `: ${body.slice(0, 160)}` : ''}. Check that it may read this file.`)
|
|
54
|
+
if (res.status === 404) throw new UserError('Figma file not found (404): check the link or key and that the token\'s account can open it.')
|
|
55
|
+
throw new UserError(`Figma API ${path.split('?')[0]}: HTTP ${res.status} ${body.slice(0, 160)}`)
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
const ids = (list) => list.map(encodeURIComponent).join(',')
|
|
59
|
+
return {
|
|
60
|
+
file: (key, depth = 3) => get(`/v1/files/${key}?depth=${depth}`),
|
|
61
|
+
nodes: (key, list) => get(`/v1/files/${key}/nodes?ids=${ids(list)}`),
|
|
62
|
+
images: (key, list, scale = 1, format = 'png') => get(`/v1/images/${key}?ids=${ids(list)}&scale=${scale}&format=${format}`),
|
|
63
|
+
variables: (key) => get(`/v1/files/${key}/variables/local`, { optional: true }),
|
|
64
|
+
async download (url) {
|
|
65
|
+
const res = await f(url)
|
|
66
|
+
if (!res.ok) throw new UserError(`Downloading a frame image failed: HTTP ${res.status}.`)
|
|
67
|
+
return Buffer.from(await res.arrayBuffer())
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ---------------------------------------------------------------- frames and steps
|
|
73
|
+
|
|
74
|
+
/** Top-level frames of the file's pages (frames inside sections too): { id, name, page, width, height }. */
|
|
75
|
+
export function framesOf (document, { page = null } = {}) {
|
|
76
|
+
const out = []
|
|
77
|
+
const box = (n) => n.absoluteBoundingBox || n.absoluteRenderBounds || { width: n.size?.x, height: n.size?.y }
|
|
78
|
+
const add = (n, pageName) => {
|
|
79
|
+
if (n.visible === false) return
|
|
80
|
+
if (n.type === 'SECTION') { for (const c of n.children || []) add(c, pageName); return }
|
|
81
|
+
if (!FRAME_TYPES.has(n.type) || n.type === 'GROUP') return
|
|
82
|
+
const b = box(n)
|
|
83
|
+
out.push({ id: n.id, name: n.name, page: pageName, width: Math.round(b?.width || 0), height: Math.round(b?.height || 0) })
|
|
84
|
+
}
|
|
85
|
+
for (const p of document?.children || []) {
|
|
86
|
+
if (p.type !== 'CANVAS') { add(p, null); continue }
|
|
87
|
+
if (page && slug(p.name) !== slug(page) && p.id !== page) continue
|
|
88
|
+
for (const n of p.children || []) add(n, p.name)
|
|
89
|
+
}
|
|
90
|
+
return out
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Which frame is which step: an explicit map (frame name or id → step, or { step, viewport, locale }) first, then the
|
|
95
|
+
* name: a segment of it ("Checkout / Payment — mobile", "payment@mobile", "Payment [cs]") that is a step id or title,
|
|
96
|
+
* a viewport name and a locale. A step with several frames and no viewport in their names takes them by width.
|
|
97
|
+
* Returns { assigned: [{ frame, step, viewport, locale }], unmatched: [frame], missing: [step id], duplicates: [frame] }.
|
|
98
|
+
*/
|
|
99
|
+
export function mapFrames (frames, { steps, viewports = {}, locales = [], map = null }) {
|
|
100
|
+
const bySlug = new Map()
|
|
101
|
+
for (const s of steps) { bySlug.set(slug(s.id), s.id); const t = typeof s.title === 'string' ? s.title : s.title && typeof s.title === 'object' ? Object.values(s.title) : []; for (const x of [].concat(t)) if (x && !bySlug.has(slug(x))) bySlug.set(slug(x), s.id) }
|
|
102
|
+
const vpNames = Object.keys(viewports)
|
|
103
|
+
const assigned = [], unmatched = [], duplicates = []
|
|
104
|
+
const explicit = (f) => { const e = map?.[f.id] ?? map?.[f.name]; return e == null ? null : typeof e === 'string' ? { step: e } : e }
|
|
105
|
+
for (const f of frames) {
|
|
106
|
+
const e = explicit(f)
|
|
107
|
+
let step = null, viewport = null, locale = null
|
|
108
|
+
if (e) {
|
|
109
|
+
if (e.skip || e.step === null || e.step === false) continue
|
|
110
|
+
step = steps.some((s) => s.id === e.step) ? e.step : null
|
|
111
|
+
viewport = e.viewport || null
|
|
112
|
+
locale = e.locale || null
|
|
113
|
+
if (!step) { unmatched.push({ ...f, reason: `map names step ${e.step}, which the scenario does not have` }); continue }
|
|
114
|
+
} else {
|
|
115
|
+
const segs = segmentsOf(f.name)
|
|
116
|
+
const words = slug(f.name).split('-')
|
|
117
|
+
step = bySlug.get(slug(f.name)) || segs.map((s) => bySlug.get(s)).find(Boolean) || null
|
|
118
|
+
if (!step) { const hit = [...bySlug.keys()].filter((k) => slug(f.name).startsWith(`${k}-`) || slug(f.name).endsWith(`-${k}`)).sort((a, b) => b.length - a.length)[0]; step = hit ? bySlug.get(hit) : null }
|
|
119
|
+
viewport = vpNames.find((v) => segs.includes(slug(v)) || words.includes(slug(v))) || null
|
|
120
|
+
locale = locales.find((l) => l !== 'default' && (segs.includes(slug(l)) || words.includes(slug(l)))) || null
|
|
121
|
+
if (!step) { unmatched.push(f); continue }
|
|
122
|
+
}
|
|
123
|
+
if (viewport && !viewports[viewport]) viewport = null
|
|
124
|
+
if (locale && !locales.includes(locale)) locale = null
|
|
125
|
+
assigned.push({ frame: f, step, viewport, locale })
|
|
126
|
+
}
|
|
127
|
+
// several frames of one step without a viewport in their names: each takes the free viewport of its width
|
|
128
|
+
const groups = new Map()
|
|
129
|
+
for (const a of assigned) { const k = `${a.step}|${a.locale || ''}`; groups.set(k, [...(groups.get(k) || []), a]) }
|
|
130
|
+
for (const list of groups.values()) {
|
|
131
|
+
if (list.length < 2) continue
|
|
132
|
+
for (const a of list.filter((x) => !x.viewport)) {
|
|
133
|
+
const taken = new Set(list.map((x) => x.viewport).filter(Boolean))
|
|
134
|
+
const fit = vpNames.filter((v) => !taken.has(v)).sort((x, y) => Math.abs(viewports[x][0] - a.frame.width) - Math.abs(viewports[y][0] - a.frame.width))[0]
|
|
135
|
+
if (fit && Math.abs(viewports[fit][0] - a.frame.width) <= Math.max(8, viewports[fit][0] * 0.05)) a.viewport = fit
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
// one frame per step, locale and viewport: later ones are left out
|
|
139
|
+
const seen = new Set()
|
|
140
|
+
const out = []
|
|
141
|
+
for (const a of assigned) { const k = `${a.step}|${a.locale || ''}|${a.viewport || ''}`; if (seen.has(k)) { duplicates.push(a.frame); continue } seen.add(k); out.push(a) }
|
|
142
|
+
const missing = steps.map((s) => s.id).filter((id) => !out.some((a) => a.step === id))
|
|
143
|
+
return { assigned: out, unmatched, missing, duplicates }
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ---------------------------------------------------------------- colors, texts, tokens
|
|
147
|
+
|
|
148
|
+
const hex2 = (n) => Math.max(0, Math.min(255, Math.round(n * 255))).toString(16).padStart(2, '0')
|
|
149
|
+
/** A Figma color ({ r, g, b, a } in 0..1, times the paint's opacity) as #rrggbb or #rrggbbaa. */
|
|
150
|
+
export function colorHex (c, opacity = 1) {
|
|
151
|
+
if (!c) return null
|
|
152
|
+
const a = (c.a ?? 1) * (opacity ?? 1)
|
|
153
|
+
return `#${hex2(c.r)}${hex2(c.g)}${hex2(c.b)}${a < 0.999 ? hex2(a) : ''}`
|
|
154
|
+
}
|
|
155
|
+
const solid = (paints) => (paints || []).find((p) => p.type === 'SOLID' && p.visible !== false)
|
|
156
|
+
export const fillOf = (node) => { const p = solid(node?.fills); return p ? colorHex(p.color, p.opacity) : null }
|
|
157
|
+
|
|
158
|
+
const WEIGHT_NAMES = { thin: 100, hairline: 100, extralight: 200, ultralight: 200, light: 300, regular: 400, normal: 400, book: 400, medium: 500, semibold: 600, demibold: 600, bold: 700, extrabold: 800, ultrabold: 800, black: 900, heavy: 900 }
|
|
159
|
+
const weightOf = (st) => st?.fontWeight || WEIGHT_NAMES[String(st?.fontPostScriptName || st?.fontStyle || '').toLowerCase().replace(/[^a-z]/g, '').replace(/italic$/, '')] || 400
|
|
160
|
+
const px = (n) => `${Math.round(n * 100) / 100}px`
|
|
161
|
+
|
|
162
|
+
/** A text style as DTCG typography: family, size, weight, line height (a ratio) and letter spacing. */
|
|
163
|
+
function typographyOf (st) {
|
|
164
|
+
if (!st?.fontFamily) return null
|
|
165
|
+
const size = st.fontSize || 16
|
|
166
|
+
const lh = st.lineHeightPx ? Math.round((st.lineHeightPx / size) * 1000) / 1000 : st.lineHeightPercentFontSize ? Math.round(st.lineHeightPercentFontSize * 10) / 1000 : null
|
|
167
|
+
return { fontFamily: st.fontFamily, fontSize: px(size), fontWeight: weightOf(st), ...(lh ? { lineHeight: lh } : {}), letterSpacing: px(st.letterSpacing || 0) }
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Where a token lives in tokens.json: the variable or style name split on "/", a kind group for styles. */
|
|
171
|
+
function tokenPath (name, kind = null) {
|
|
172
|
+
const segs = String(name).split('/').map(tokenSeg).filter(Boolean)
|
|
173
|
+
if (kind && segs.length && !(kind === 'color' ? ['color', 'colors', 'colour'] : ['typography', 'text', 'type', 'font', 'fonts']).includes(segs[0])) segs.unshift(kind)
|
|
174
|
+
else if (kind && segs.length) segs[0] = kind
|
|
175
|
+
return segs
|
|
176
|
+
}
|
|
177
|
+
const setAt = (doc, path, token) => { let o = doc; for (const k of path.slice(0, -1)) { if (!o[k] || typeof o[k] !== 'object' || '$value' in o[k]) o[k] = {}; o = o[k] } o[path[path.length - 1]] = token }
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* DTCG tokens from the file's local variables: colors, numbers as px dimensions (sizes, radii, spacing, font sizes)
|
|
181
|
+
* or plain numbers, font families and weights; aliases as {references}. A collection with several modes adds a group
|
|
182
|
+
* per extra mode (dark, …) named in $extensions.qawalk.modes. Returns { doc, names: Map variable id → token name }.
|
|
183
|
+
*/
|
|
184
|
+
export function tokensFromVariables (meta) {
|
|
185
|
+
const vars = meta?.meta?.variables || meta?.variables || {}
|
|
186
|
+
const cols = meta?.meta?.variableCollections || meta?.variableCollections || {}
|
|
187
|
+
const doc = {}
|
|
188
|
+
const names = new Map()
|
|
189
|
+
const extra = new Map() // mode name → overrides
|
|
190
|
+
for (const v of Object.values(vars)) names.set(v.id, tokenPath(v.name).join('.'))
|
|
191
|
+
const valueOf = (v, raw) => {
|
|
192
|
+
if (raw && typeof raw === 'object' && raw.type === 'VARIABLE_ALIAS') return names.has(raw.id) ? `{${names.get(raw.id)}}` : null
|
|
193
|
+
const scopes = v.scopes || []
|
|
194
|
+
if (v.resolvedType === 'COLOR') return { $type: 'color', $value: colorHex(raw) }
|
|
195
|
+
if (v.resolvedType === 'FLOAT') {
|
|
196
|
+
if (scopes.includes('FONT_WEIGHT')) return { $type: 'fontWeight', $value: raw }
|
|
197
|
+
if (scopes.some((s) => /RADIUS|GAP|WIDTH_HEIGHT|FONT_SIZE|LINE_HEIGHT|LETTER_SPACING|STROKE|EFFECT_FLOAT|PARAGRAPH/.test(s)) || scopes.includes('ALL_SCOPES') || /space|spacing|size|radius|gap|width|height|padding|margin/i.test(v.name)) return { $type: 'dimension', $value: px(raw) }
|
|
198
|
+
return { $type: 'number', $value: raw }
|
|
199
|
+
}
|
|
200
|
+
if (v.resolvedType === 'STRING' && (scopes.includes('FONT_FAMILY') || /font|family/i.test(v.name))) return { $type: 'fontFamily', $value: raw }
|
|
201
|
+
return null
|
|
202
|
+
}
|
|
203
|
+
for (const v of Object.values(vars)) {
|
|
204
|
+
const col = cols[v.variableCollectionId]
|
|
205
|
+
const modes = col?.modes || []
|
|
206
|
+
const def = col?.defaultModeId || modes[0]?.modeId || Object.keys(v.valuesByMode || {})[0]
|
|
207
|
+
const path = names.get(v.id).split('.')
|
|
208
|
+
const base = v.valuesByMode?.[def]
|
|
209
|
+
const t = valueOf(v, base)
|
|
210
|
+
if (!t) continue
|
|
211
|
+
const tok = typeof t === 'string' ? { $type: { COLOR: 'color', FLOAT: 'dimension', STRING: 'fontFamily' }[v.resolvedType] || undefined, $value: t } : t
|
|
212
|
+
if (v.description) tok.$description = v.description
|
|
213
|
+
setAt(doc, path, tok)
|
|
214
|
+
for (const m of modes) {
|
|
215
|
+
if (m.modeId === def || !(m.modeId in (v.valuesByMode || {}))) continue
|
|
216
|
+
const mt = valueOf(v, v.valuesByMode[m.modeId])
|
|
217
|
+
if (!mt) continue
|
|
218
|
+
const mname = tokenSeg(m.name)
|
|
219
|
+
const group = extra.get(mname) || extra.set(mname, {}).get(mname)
|
|
220
|
+
setAt(group, path, typeof mt === 'string' ? { $value: mt } : mt)
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
if (!Object.keys(doc).length) return null
|
|
224
|
+
const modeNames = [...extra.keys()].filter((m) => !(m in doc))
|
|
225
|
+
if (modeNames.length) {
|
|
226
|
+
const defName = tokenSeg(Object.values(cols).find((c) => (c.modes || []).length > 1)?.modes?.find((m) => m.modeId === Object.values(cols).find((c) => (c.modes || []).length > 1)?.defaultModeId)?.name || 'default')
|
|
227
|
+
for (const m of modeNames) doc[m] = extra.get(m)
|
|
228
|
+
if (!doc[defName] && !modeNames.includes(defName)) doc[defName] = {}
|
|
229
|
+
doc.$extensions = { 'qawalk.modes': [defName, ...modeNames].filter((m) => doc[m]) }
|
|
230
|
+
}
|
|
231
|
+
return { doc, names }
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Tokens from the color and text styles the imported frames use (files without variables, or plans without the
|
|
236
|
+
* variables API): a style's value is read from a node that uses it. Returns { doc, names: Map style id → token name }.
|
|
237
|
+
*/
|
|
238
|
+
export function tokensFromStyles (styles, roots) {
|
|
239
|
+
const doc = {}
|
|
240
|
+
const names = new Map()
|
|
241
|
+
const seen = new Set()
|
|
242
|
+
const visit = (n) => {
|
|
243
|
+
for (const [kind, id] of Object.entries(n.styles || {})) {
|
|
244
|
+
const st = styles?.[id]
|
|
245
|
+
if (!st || seen.has(id)) continue
|
|
246
|
+
if ((kind === 'fill' || kind === 'fills') && st.styleType === 'FILL') { const c = fillOf(n); if (c) { const p = tokenPath(st.name, 'color'); setAt(doc, p, { $type: 'color', $value: c, ...(st.description ? { $description: st.description } : {}) }); names.set(id, p.join('.')); seen.add(id) } }
|
|
247
|
+
if (kind === 'text' && st.styleType === 'TEXT') { const t = typographyOf(n.style); if (t) { const p = tokenPath(st.name, 'typography'); setAt(doc, p, { $type: 'typography', $value: t, ...(st.description ? { $description: st.description } : {}) }); names.set(id, p.join('.')); seen.add(id) } }
|
|
248
|
+
}
|
|
249
|
+
for (const c of n.children || []) visit(c)
|
|
250
|
+
}
|
|
251
|
+
for (const r of roots) visit(r)
|
|
252
|
+
return Object.keys(doc).length ? { doc, names } : null
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const HEADING = /^h([1-6])\b|heading|headline|title/i
|
|
256
|
+
/**
|
|
257
|
+
* The visible text layers of a frame, relative to it: text, box, typography, color, and the role to give them
|
|
258
|
+
* (heading, button, link or paragraph) from the layer and its parents' names and the sizes on the frame.
|
|
259
|
+
*/
|
|
260
|
+
export function textsOfFrame (frame, { tokenNames = new Map() } = {}) {
|
|
261
|
+
const origin = frame.absoluteBoundingBox || { x: 0, y: 0 }
|
|
262
|
+
const out = []
|
|
263
|
+
const walk = (n, trail) => {
|
|
264
|
+
if (n.visible === false || n.opacity === 0) return
|
|
265
|
+
const names = [...trail, n.name || '']
|
|
266
|
+
if (n.type === 'TEXT' && String(n.characters || '').trim()) {
|
|
267
|
+
const b = n.absoluteBoundingBox || { x: origin.x, y: origin.y, width: 0, height: 0 }
|
|
268
|
+
const st = n.style || {}
|
|
269
|
+
const fillPaint = solid(n.fills)
|
|
270
|
+
const varId = n.boundVariables?.fills?.[0]?.id
|
|
271
|
+
const colorToken = (varId && tokenNames.get(varId)) || (n.styles?.fill && tokenNames.get(n.styles.fill)) || null
|
|
272
|
+
const typeToken = (n.styles?.text && tokenNames.get(n.styles.text)) || null
|
|
273
|
+
const sizeVar = n.boundVariables?.fontSize?.[0]?.id || n.boundVariables?.fontSize?.id
|
|
274
|
+
const context = names.slice(-3).join(' ')
|
|
275
|
+
const role = /\b(button|btn|cta)\b/i.test(context) ? 'button' : /\blink\b/i.test(context) ? 'link' : (n.name || '').match(/^h([1-6])\b/i) ? `h${(n.name.match(/^h([1-6])\b/i))[1]}` : null
|
|
276
|
+
out.push({
|
|
277
|
+
text: String(n.characters).slice(0, 2000), x: Math.round(b.x - origin.x), y: Math.round(b.y - origin.y), w: Math.round(b.width), h: Math.round(b.height),
|
|
278
|
+
style: { family: st.fontFamily || null, size: st.fontSize || null, weight: weightOf(st), lineHeight: st.lineHeightPx || null, letterSpacing: st.letterSpacing || 0, italic: !!st.italic, align: (st.textAlignHorizontal || 'LEFT').toLowerCase(), transform: st.textCase === 'UPPER' ? 'uppercase' : st.textCase === 'LOWER' ? 'lowercase' : null },
|
|
279
|
+
color: fillPaint ? colorHex(fillPaint.color, fillPaint.opacity) : null, colorToken, typeToken, sizeToken: sizeVar ? tokenNames.get(sizeVar) || null : null, role, layer: n.name || ''
|
|
280
|
+
})
|
|
281
|
+
}
|
|
282
|
+
for (const c of n.children || []) walk(c, names)
|
|
283
|
+
}
|
|
284
|
+
for (const c of frame.children || []) walk(c, [frame.name || ''])
|
|
285
|
+
// headings: layers named like one, else the largest text on the frame when it stands out (24 px and up)
|
|
286
|
+
const sizes = out.map((t) => t.style.size || 0)
|
|
287
|
+
const top = Math.max(0, ...sizes)
|
|
288
|
+
for (const t of out) {
|
|
289
|
+
if (t.role) continue
|
|
290
|
+
if (HEADING.test(t.layer) && !/subtitle/i.test(t.layer)) t.role = 'h2'
|
|
291
|
+
if (t.style.size === top && top >= 24 && sizes.filter((s) => s === top).length <= 2) t.role = 'h1'
|
|
292
|
+
}
|
|
293
|
+
return out
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// ---------------------------------------------------------------- the step page
|
|
297
|
+
|
|
298
|
+
const escHtml = (s) => String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
|
299
|
+
const cssVar = (name) => `--${name.replace(/\./g, '-')}`
|
|
300
|
+
const cssFont = (f) => (/^[A-Za-z0-9 -]+$/.test(f) ? `"${f}"` : `"${String(f).replace(/["\\]/g, '')}"`)
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* The HTML of one step: each frame (default, or for one viewport) as a block of its size, its image on top and its
|
|
304
|
+
* text layers underneath. `frames`: [{ viewport | null, width, height, src, src2x, background, texts }].
|
|
305
|
+
* `vars`: Map token name → CSS value for the custom properties the texts use.
|
|
306
|
+
*/
|
|
307
|
+
export function stepHtml ({ title, lang = 'en', frames, viewports = {}, vars = new Map(), source = '' }) {
|
|
308
|
+
const css = [
|
|
309
|
+
'html,body{margin:0;padding:0}body{background:#fff}',
|
|
310
|
+
'.kf{position:relative;margin:0 auto;display:none;overflow:hidden}',
|
|
311
|
+
'.kf>img{position:absolute;left:0;top:0;width:100%;height:100%;display:block;z-index:1}',
|
|
312
|
+
'.kf>.t{position:absolute;margin:0;padding:0;white-space:pre-wrap;overflow:hidden;z-index:0}',
|
|
313
|
+
'.kf--default{display:block}'
|
|
314
|
+
]
|
|
315
|
+
if (vars.size) css.unshift(`:root{${[...vars].map(([n, v]) => `${cssVar(n)}:${v}`).join(';')}}`)
|
|
316
|
+
for (const f of frames.filter((x) => x.viewport && viewports[x.viewport])) css.push(`@media (width: ${viewports[f.viewport][0]}px){.kf--default{display:none}.kf--vp-${f.viewport.replace(/[^A-Za-z0-9_-]/g, '_')}{display:block}}`)
|
|
317
|
+
const block = (f) => {
|
|
318
|
+
const cls = f.viewport ? `kf${f.alsoDefault ? ' kf--default' : ''} kf--vp-${f.viewport.replace(/[^A-Za-z0-9_-]/g, '_')}` : 'kf kf--default'
|
|
319
|
+
const texts = (f.texts || []).map((t) => {
|
|
320
|
+
const tag = /^h[1-6]$/.test(t.role || '') ? t.role : 'p'
|
|
321
|
+
const role = t.role === 'button' ? ' role="button"' : t.role === 'link' ? ' role="link"' : ''
|
|
322
|
+
const st = t.style || {}
|
|
323
|
+
const color = t.colorToken && t.color ? `var(${cssVar(t.colorToken)}, ${t.color})` : t.color
|
|
324
|
+
const style = [`left:${t.x}px`, `top:${t.y}px`, `width:${Math.max(1, t.w)}px`, `min-height:${Math.max(1, t.h)}px`,
|
|
325
|
+
st.family && `font-family:${cssFont(st.family)}, sans-serif`, st.size && `font-size:${t.sizeToken ? `var(${cssVar(t.sizeToken)}, ${px(st.size)})` : px(st.size)}`, `font-weight:${st.weight || 400}`,
|
|
326
|
+
st.lineHeight && `line-height:${px(st.lineHeight)}`, st.letterSpacing && `letter-spacing:${px(st.letterSpacing)}`, st.italic && 'font-style:italic', st.align && st.align !== 'left' && `text-align:${st.align === 'justified' ? 'justify' : st.align}`,
|
|
327
|
+
st.transform && `text-transform:${st.transform}`, color && `color:${color}`].filter(Boolean).join(';')
|
|
328
|
+
return `<${tag} class="t"${role} style="${escHtml(style)}">${escHtml(t.text)}</${tag}>`
|
|
329
|
+
}).join('\n ')
|
|
330
|
+
const srcset = f.src2x && f.src !== f.src2x ? ` srcset="${escHtml(f.src)} 1x, ${escHtml(f.src2x)} 2x"` : ''
|
|
331
|
+
return ` <div class="${cls}" data-kaloko="figma-frame" style="width:${f.width}px;height:${f.height}px${f.background ? `;background:${f.background}` : ''}">
|
|
332
|
+
<img src="${escHtml(f.src)}"${srcset} alt="" width="${f.width}" height="${f.height}">
|
|
333
|
+
${texts}
|
|
334
|
+
</div>`
|
|
335
|
+
}
|
|
336
|
+
// without a frame for every viewport, the widest one with a viewport also stands for the viewports without one
|
|
337
|
+
const fallback = frames.some((f) => !f.viewport) ? null : [...frames].sort((a, b) => b.width - a.width)[0]
|
|
338
|
+
const ordered = [...frames.filter((f) => !f.viewport), ...frames.filter((f) => f.viewport)].map((f) => (f === fallback ? { ...f, alsoDefault: true } : f))
|
|
339
|
+
return `<!doctype html>
|
|
340
|
+
<html lang="${escHtml(lang)}">
|
|
341
|
+
<head>
|
|
342
|
+
<meta charset="utf-8">
|
|
343
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
344
|
+
<meta name="generator" content="${GENERATOR}">
|
|
345
|
+
<title>${escHtml(title)}</title>
|
|
346
|
+
<style>
|
|
347
|
+
${css.join('\n')}
|
|
348
|
+
</style>
|
|
349
|
+
</head>
|
|
350
|
+
<body>
|
|
351
|
+
<!-- ${escHtml(source)}: the frame image on top, its text layers underneath (for the step spec and drift). Re-import to update; edits here are overwritten. -->
|
|
352
|
+
${ordered.map(block).join('\n')}
|
|
353
|
+
</body>
|
|
354
|
+
</html>
|
|
355
|
+
`
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// ---------------------------------------------------------------- local export (no API token)
|
|
359
|
+
|
|
360
|
+
/** Width and height of a PNG, JPEG or SVG file (pixels; an SVG's width/height or viewBox). */
|
|
361
|
+
export function imageSize (buf, ext) {
|
|
362
|
+
if (ext === 'png' && buf.length > 24 && buf.readUInt32BE(12) === 0x49484452) return [buf.readUInt32BE(16), buf.readUInt32BE(20)]
|
|
363
|
+
if (ext === 'jpg' || ext === 'jpeg') {
|
|
364
|
+
for (let i = 2; i < buf.length - 9;) {
|
|
365
|
+
if (buf[i] !== 0xff) { i++; continue }
|
|
366
|
+
const marker = buf[i + 1]; const len = buf.readUInt16BE(i + 2)
|
|
367
|
+
if (marker >= 0xc0 && marker <= 0xcf && ![0xc4, 0xc8, 0xcc].includes(marker)) return [buf.readUInt16BE(i + 7), buf.readUInt16BE(i + 5)]
|
|
368
|
+
i += 2 + len
|
|
369
|
+
}
|
|
370
|
+
return null
|
|
371
|
+
}
|
|
372
|
+
if (ext === 'svg') {
|
|
373
|
+
const head = buf.toString('utf8', 0, 4000)
|
|
374
|
+
const tag = head.match(/<svg\b[^>]*>/i)?.[0] || ''
|
|
375
|
+
const num = (a) => { const m = tag.match(new RegExp(`\\s${a}\\s*=\\s*["']\\s*([\\d.]+)(px)?\\s*["']`, 'i')); return m ? Number(m[1]) : null }
|
|
376
|
+
const vb = tag.match(/viewBox\s*=\s*["']\s*[-\d.]+[\s,]+[-\d.]+[\s,]+([\d.]+)[\s,]+([\d.]+)/i)
|
|
377
|
+
const w = num('width') ?? (vb ? Number(vb[1]) : null), h = num('height') ?? (vb ? Number(vb[2]) : null)
|
|
378
|
+
return w && h ? [Math.round(w), Math.round(h)] : null
|
|
379
|
+
}
|
|
380
|
+
return null
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Frames of a folder exported from Figma (File → Export, or the export panel): PNG, JPEG or SVG files named after
|
|
385
|
+
* their frames, @2x/@3x suffixes for scales. Returns { frames: [{ id, name, width, height, files: { 1?, 2? } }], tokens }.
|
|
386
|
+
*/
|
|
387
|
+
export async function readExportFolder (dir) {
|
|
388
|
+
const list = []
|
|
389
|
+
const walk = async (d) => { for (const e of await readdir(d, { withFileTypes: true })) { if (e.name.startsWith('.')) continue; const p = join(d, e.name); if (e.isDirectory()) await walk(p); else list.push(p) } }
|
|
390
|
+
await walk(dir)
|
|
391
|
+
const byName = new Map()
|
|
392
|
+
let tokens = null
|
|
393
|
+
for (const p of list.sort()) {
|
|
394
|
+
const ext = extname(p).slice(1).toLowerCase()
|
|
395
|
+
if (ext === 'json' && /(^|[.-])tokens\.json$/i.test(basename(p))) {
|
|
396
|
+
try { const doc = JSON.parse(await readFile(p, 'utf8')); if (!validateTokens(doc).length) tokens = doc } catch {}
|
|
397
|
+
continue
|
|
398
|
+
}
|
|
399
|
+
if (!['png', 'svg', 'jpg', 'jpeg'].includes(ext)) continue
|
|
400
|
+
const rel = relative(dir, p).slice(0, -(ext.length + 1))
|
|
401
|
+
const m = rel.match(/^(.*?)(?:@([23])x)?$/)
|
|
402
|
+
const name = m[1].split(/[\\/]/).join(' / ')
|
|
403
|
+
const scale = Number(m[2] || 1)
|
|
404
|
+
const buf = await readFile(p)
|
|
405
|
+
const size = imageSize(buf, ext)
|
|
406
|
+
if (!size) continue
|
|
407
|
+
const f = byName.get(name) || byName.set(name, { id: `file:${name}`, name, width: 0, height: 0, files: {} }).get(name)
|
|
408
|
+
if (!f.files[scale] || ext === 'png') f.files[scale] = { path: p, ext, buf }
|
|
409
|
+
if (!f.width || scale === 1) { f.width = Math.round(size[0] / (ext === 'svg' ? 1 : scale)); f.height = Math.round(size[1] / (ext === 'svg' ? 1 : scale)) }
|
|
410
|
+
}
|
|
411
|
+
return { frames: [...byName.values()], tokens }
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// ---------------------------------------------------------------- the import
|
|
415
|
+
|
|
416
|
+
const exists = async (p) => { try { await stat(p); return true } catch { return false } }
|
|
417
|
+
const RECORD = (dir) => join(dir, '.qawalk', 'figma.json')
|
|
418
|
+
|
|
419
|
+
/** Reads a frame map file (JSON or YAML): { "<frame name or id>": "step" | { step, viewport, locale } | { skip: true } }. */
|
|
420
|
+
export async function readMap (file) {
|
|
421
|
+
const text = await readFile(file, 'utf8').catch(() => { throw new UserError(`--map: cannot read ${file}.`) })
|
|
422
|
+
const doc = /\.ya?ml$/i.test(file) ? yaml.load(text) : JSON.parse(text)
|
|
423
|
+
if (!doc || typeof doc !== 'object' || Array.isArray(doc)) throw new UserError('--map: an object of frame name or id → step id (or { step, viewport, locale }).')
|
|
424
|
+
return doc
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* Imports Figma frames into a design folder. `source`: a Figma URL, a file key or a local export folder. Writes per
|
|
429
|
+
* step `<step>/index.html` (or `<step>/<locale>/index.html` for locale frames), the frame images and figma.json, and
|
|
430
|
+
* tokens.json when the file has variables or styles (unless the folder has its own set: --tokens write overwrites,
|
|
431
|
+
* --tokens skip never writes). Returns a summary.
|
|
432
|
+
*/
|
|
433
|
+
export async function importFigma ({ source, scenario, viewports, locales, dir, token = null, fetch = globalThis.fetch, map = null, only = null, page = null, tokensMode = 'auto', force = false, dryRun = false, say = () => {} }) {
|
|
434
|
+
const steps = scenario.nodes.filter((n) => n.kind !== 'decision' && n.kind !== 'external')
|
|
435
|
+
const local = !parseFigmaRef(source) && await exists(source) && (await stat(source)).isDirectory()
|
|
436
|
+
const ref = local ? null : parseFigmaRef(source)
|
|
437
|
+
if (!local && !ref) throw new UserError(`Not a Figma file link, key or export folder: ${source}`)
|
|
438
|
+
const prev = JSON.parse(await readFile(RECORD(dir), 'utf8').catch(() => 'null'))
|
|
439
|
+
let frames, meta = {}, tokens = null
|
|
440
|
+
const nodes = new Map(), images = new Map()
|
|
441
|
+
const client = local ? null : figmaClient({ token, fetch })
|
|
442
|
+
const pick = (list) => (only?.length ? list.filter((f) => only.includes(f.id) || only.includes(f.name) || only.some((o) => slug(o) === slug(f.name))) : list)
|
|
443
|
+
if (local) {
|
|
444
|
+
const exp = await readExportFolder(source)
|
|
445
|
+
frames = pick(exp.frames)
|
|
446
|
+
if (exp.tokens) tokens = { doc: exp.tokens, names: new Map(), from: 'folder' }
|
|
447
|
+
meta = { source: 'folder', folder: source }
|
|
448
|
+
} else {
|
|
449
|
+
const file = await client.file(ref.key)
|
|
450
|
+
meta = { source: 'figma', key: ref.key, name: file.name, version: file.version || null, last_modified: file.lastModified || null }
|
|
451
|
+
let all = framesOf(file.document, { page })
|
|
452
|
+
if (ref.nodeIds.length) {
|
|
453
|
+
const picked = await client.nodes(ref.key, ref.nodeIds)
|
|
454
|
+
all = []
|
|
455
|
+
// a link to a page, a section or one frame: the frames it names
|
|
456
|
+
for (const [id, n] of Object.entries(picked.nodes || {})) {
|
|
457
|
+
const doc = n?.document
|
|
458
|
+
if (!doc) continue
|
|
459
|
+
if (doc.type === 'CANVAS') all.push(...framesOf({ children: [doc] }))
|
|
460
|
+
else if (doc.type === 'SECTION') all.push(...framesOf({ children: [{ type: 'CANVAS', name: '', children: [doc] }] }))
|
|
461
|
+
else if (FRAME_TYPES.has(doc.type)) { const b = doc.absoluteBoundingBox || {}; all.push({ id, name: doc.name, page: null, width: Math.round(b.width || 0), height: Math.round(b.height || 0) }) }
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
frames = pick(all)
|
|
465
|
+
meta.styles = file.styles || {}
|
|
466
|
+
}
|
|
467
|
+
const mapping = mapFrames(frames, { steps, viewports, locales, map })
|
|
468
|
+
if (!mapping.assigned.length) throw new UserError(`No frame matches a step of ${scenario.id}. Frames: ${frames.slice(0, 12).map((f) => `"${f.name}"`).join(', ')}${frames.length > 12 ? ', …' : ''}. Name frames after step ids or titles (with a viewport like "payment / mobile"), or pass --map frames.json.`)
|
|
469
|
+
// hand-written steps are not overwritten without --force
|
|
470
|
+
const touched = [...new Set(mapping.assigned.map((a) => a.step))]
|
|
471
|
+
const mine = []
|
|
472
|
+
for (const s of touched) {
|
|
473
|
+
const file = join(dir, s, 'index.html')
|
|
474
|
+
if (await exists(file) && !(await readFile(file, 'utf8')).includes(`content="${GENERATOR}"`)) mine.push(s)
|
|
475
|
+
}
|
|
476
|
+
if (mine.length && !force) throw new UserError(`These steps already have a design that was not imported from Figma: ${mine.join(', ')}. Pass --force to replace them, or --frames to import others.`)
|
|
477
|
+
if (!local) {
|
|
478
|
+
const ids = mapping.assigned.map((a) => a.frame.id)
|
|
479
|
+
for (let i = 0; i < ids.length; i += 50) {
|
|
480
|
+
const chunk = ids.slice(i, i + 50)
|
|
481
|
+
const full = await client.nodes(ref.key, chunk)
|
|
482
|
+
for (const [id, n] of Object.entries(full.nodes || {})) if (n?.document) { nodes.set(id, n.document); Object.assign(meta.styles, n.styles || {}) }
|
|
483
|
+
for (const scale of [1, 2]) {
|
|
484
|
+
const r = await client.images(ref.key, chunk, scale)
|
|
485
|
+
if (r.err) throw new UserError(`Figma could not render the frames: ${r.err}`)
|
|
486
|
+
for (const [id, url] of Object.entries(r.images || {})) if (url) images.set(`${id}@${scale}`, url)
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
const vars = tokensMode === 'skip' ? null : await client.variables(ref.key)
|
|
490
|
+
tokens = vars ? tokensFromVariables(vars) : null
|
|
491
|
+
if (tokens) tokens.from = 'variables'
|
|
492
|
+
else if (tokensMode !== 'skip') { const st = tokensFromStyles(meta.styles, [...nodes.values()]); if (st) tokens = { ...st, from: 'styles' } }
|
|
493
|
+
}
|
|
494
|
+
// the token set: written when the folder has none or the last one came from Figma
|
|
495
|
+
const tokFile = join(dir, TOKENS_FILE)
|
|
496
|
+
const ownSet = await exists(tokFile) && !prev?.tokens_written
|
|
497
|
+
const writeTokens = tokens?.doc && tokensMode !== 'skip' && (tokensMode === 'write' || !ownSet)
|
|
498
|
+
if (tokens?.doc) { const problems = validateTokens(tokens.doc); if (problems.length) { say(`! The token set from Figma has problems and is not written: ${problems.slice(0, 3).join('; ')}`); tokens = null } }
|
|
499
|
+
const tokenValues = new Map()
|
|
500
|
+
const known = tokens?.doc ? new Map(flattenTokens(tokens.doc).map((t) => [t.name, t.value])) : (await exists(tokFile) ? new Map(flattenTokens(JSON.parse(await readFile(tokFile, 'utf8'))).map((t) => [t.name, t.value])) : new Map())
|
|
501
|
+
// pages per step and locale (null = the step's default page)
|
|
502
|
+
const written = []
|
|
503
|
+
const warnings = []
|
|
504
|
+
const byStep = new Map()
|
|
505
|
+
for (const a of mapping.assigned) { const k = `${a.step}|${a.locale || ''}`; byStep.set(k, [...(byStep.get(k) || []), a]) }
|
|
506
|
+
// a step drawn only per locale: its first locale's frames are the page of the other locales too
|
|
507
|
+
for (const s of touched) if (!byStep.has(`${s}|`)) { const first = locales.map((l) => byStep.get(`${s}|${l}`)).find(Boolean); if (first) byStep.set(`${s}|`, first.map((a) => ({ ...a, locale: null }))) }
|
|
508
|
+
const lang = (l) => (l && l !== 'default' ? l : scenario.locales?.[0] && scenario.locales[0] !== 'default' ? scenario.locales[0] : 'en')
|
|
509
|
+
if (!dryRun) await mkdir(dir, { recursive: true })
|
|
510
|
+
for (const s of touched) {
|
|
511
|
+
if (dryRun) continue
|
|
512
|
+
const sdir = join(dir, s)
|
|
513
|
+
await mkdir(sdir, { recursive: true })
|
|
514
|
+
// images from an earlier import go first: a frame removed from Figma leaves nothing behind
|
|
515
|
+
for (const d of [sdir, ...locales.map((l) => join(sdir, l))]) for (const f of await readdir(d).catch(() => [])) if (IMAGE_RE.test(f) || f === 'figma.json') await rm(join(d, f), { force: true })
|
|
516
|
+
}
|
|
517
|
+
for (const [k, list] of byStep) {
|
|
518
|
+
const [s, loc] = k.split('|')
|
|
519
|
+
const node = steps.find((n) => n.id === s)
|
|
520
|
+
const pageDir = loc ? join(dir, s, loc) : join(dir, s)
|
|
521
|
+
const frameOut = []
|
|
522
|
+
const metaOut = []
|
|
523
|
+
for (const a of list) {
|
|
524
|
+
const f = a.frame
|
|
525
|
+
const name = a.viewport ? `figma-${a.viewport.replace(/[^A-Za-z0-9_-]/g, '_')}` : 'figma'
|
|
526
|
+
if (!dryRun) await mkdir(pageDir, { recursive: true })
|
|
527
|
+
const outs = {}
|
|
528
|
+
if (local) {
|
|
529
|
+
for (const [scale, file] of Object.entries(f.files)) {
|
|
530
|
+
const out = `${name}${scale === '1' ? '' : `.${scale}x`}.${file.ext}`
|
|
531
|
+
if (!dryRun) await writeFile(join(pageDir, out), file.buf)
|
|
532
|
+
outs[scale] = out
|
|
533
|
+
}
|
|
534
|
+
} else {
|
|
535
|
+
for (const scale of [1, 2]) {
|
|
536
|
+
const url = images.get(`${f.id}@${scale}`)
|
|
537
|
+
if (!url) continue
|
|
538
|
+
const out = `${name}${scale === 1 ? '' : '.2x'}.png`
|
|
539
|
+
if (!dryRun) await writeFile(join(pageDir, out), await client.download(url))
|
|
540
|
+
outs[scale] = out
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
const src = outs[1] || outs[2] || outs[3] || null
|
|
544
|
+
const src2x = outs[1] ? outs[2] || null : null
|
|
545
|
+
if (!src) { warnings.push(`${f.name}: no image (an empty or hidden frame?)`); continue }
|
|
546
|
+
const tree = nodes.get(f.id)
|
|
547
|
+
const texts = tree ? textsOfFrame(tree, { tokenNames: tokens?.names || new Map() }) : []
|
|
548
|
+
const vpSize = a.viewport ? viewports[a.viewport] : null
|
|
549
|
+
if (vpSize && Math.abs(vpSize[0] - f.width) > 2) warnings.push(`${f.name}: ${f.width} px wide, viewport ${a.viewport} is ${vpSize[0]} px; the pixel comparison will differ at the edges`)
|
|
550
|
+
if (!a.viewport && list.length === 1) { const off = Object.entries(viewports).filter(([, [w]]) => Math.abs(w - f.width) > 2).map(([v]) => v); if (off.length && off.length === Object.keys(viewports).length) warnings.push(`${f.name}: ${f.width} px wide matches no viewport (${Object.entries(viewports).map(([v, [w]]) => `${v} ${w}`).join(', ')}); add a frame per viewport ("${s} / ${Object.keys(viewports)[0]}") for a pixel comparison`) }
|
|
551
|
+
frameOut.push({ viewport: a.viewport, width: f.width, height: f.height, src, src2x, background: tree ? fillOf(tree) : null, texts })
|
|
552
|
+
metaOut.push({ id: f.id, name: f.name, page: f.page ?? null, viewport: a.viewport, locale: a.locale, width: f.width, height: f.height, texts: texts.length, ...(ref ? { url: `https://www.figma.com/design/${ref.key}?node-id=${encodeURIComponent(f.id.replace(/:/g, '-'))}` } : {}) })
|
|
553
|
+
}
|
|
554
|
+
if (!frameOut.length) continue
|
|
555
|
+
// the custom properties the texts use, with their values from the token set
|
|
556
|
+
const vars = new Map()
|
|
557
|
+
for (const fr of frameOut) for (const t of fr.texts) for (const n of [t.colorToken, t.sizeToken]) if (n && known.has(n) && typeof known.get(n) !== 'object') vars.set(n, known.get(n))
|
|
558
|
+
const title = (node && (typeof node.title === 'string' ? node.title : node.title?.[lang(loc)] || node.title?.en)) || s
|
|
559
|
+
const html = stepHtml({ title, lang: lang(loc), frames: frameOut, viewports, vars, source: local ? `Imported from the folder ${basename(source)}` : `Imported from Figma: ${meta.name || ref.key}` })
|
|
560
|
+
if (!dryRun) {
|
|
561
|
+
await mkdir(pageDir, { recursive: true })
|
|
562
|
+
await writeFile(join(pageDir, 'index.html'), html)
|
|
563
|
+
await writeFile(join(pageDir, 'figma.json'), JSON.stringify({ source: local ? 'folder' : 'figma', ...(ref ? { file: ref.key, name: meta.name || null } : {}), frames: metaOut }, null, 2) + '\n')
|
|
564
|
+
}
|
|
565
|
+
written.push({ step: s, locale: loc || null, frames: metaOut.length, viewports: metaOut.map((m) => m.viewport || 'all'), texts: frameOut.reduce((n, f) => n + f.texts.length, 0) })
|
|
566
|
+
}
|
|
567
|
+
if (writeTokens && !dryRun) await writeFile(tokFile, JSON.stringify(tokens.doc, null, 2) + '\n')
|
|
568
|
+
if (tokens?.doc && !writeTokens && tokensMode !== 'skip') warnings.push(`${TOKENS_FILE} is your own set, so the tokens from Figma were not written (--tokens write replaces it)`)
|
|
569
|
+
const record = { ...(ref ? { key: ref.key, name: meta.name, version: meta.version, last_modified: meta.last_modified } : { folder: source }), imported_at: new Date().toISOString(), tokens_written: writeTokens ? true : (prev?.tokens_written && !ownSet) || false, steps: written.map((w) => w.step) }
|
|
570
|
+
if (!dryRun) { await mkdir(join(dir, '.qawalk'), { recursive: true }); await writeFile(RECORD(dir), JSON.stringify(record, null, 2) + '\n'); await writeFile(join(dir, '.qawalk', '.gitignore'), '*\n').catch(() => {}) }
|
|
571
|
+
return { source: meta.source, file: ref ? { key: ref.key, name: meta.name, version: meta.version } : { folder: source }, written, unmatched: mapping.unmatched, duplicates: mapping.duplicates, missing: mapping.missing, tokens: tokens?.doc ? { from: tokens.from, count: flattenTokens(tokens.doc).length, written: !!writeTokens } : null, warnings }
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/** The import summary for the terminal. */
|
|
575
|
+
export function formatImport (r, { dir }) {
|
|
576
|
+
const lines = [`Imported ${r.written.reduce((n, w) => n + w.frames, 0)} frame(s) from ${r.file.name ? `Figma "${r.file.name}"` : r.file.key ? `Figma ${r.file.key}` : r.file.folder} into ${dir}:`]
|
|
577
|
+
for (const w of r.written) lines.push(` ✓ ${w.step}${w.locale ? ` (${w.locale})` : ''} ${w.viewports.join(', ')}${w.texts ? ` ${w.texts} text layer(s)` : ''}`)
|
|
578
|
+
if (r.tokens) lines.push(` tokens: ${r.tokens.count} from ${r.tokens.from}${r.tokens.written ? ` → ${TOKENS_FILE}` : ' (not written)'}`)
|
|
579
|
+
if (r.missing.length) lines.push(`Steps without a frame: ${r.missing.join(', ')}`)
|
|
580
|
+
if (r.unmatched.length) lines.push(`Frames that match no step (name them after a step, or map them with --map): ${r.unmatched.slice(0, 15).map((f) => `"${f.name}"${f.reason ? ` (${f.reason})` : ''}`).join(', ')}${r.unmatched.length > 15 ? ', …' : ''}`)
|
|
581
|
+
if (r.duplicates.length) lines.push(`Left out (a frame of the same step, locale and viewport came first): ${r.duplicates.map((f) => `"${f.name}"`).join(', ')}`)
|
|
582
|
+
for (const w of r.warnings) lines.push(`! ${w}`)
|
|
583
|
+
return lines.join('\n')
|
|
584
|
+
}
|
package/src/lib/git.mjs
CHANGED
|
@@ -24,7 +24,24 @@ export async function repoSlug (cwd) {
|
|
|
24
24
|
* Project = config.project, else the repository name. Issue = scenario.issue,
|
|
25
25
|
* else a number found in the scenario id (TASK-1273) or the branch (feat/1273-…).
|
|
26
26
|
*/
|
|
27
|
-
|
|
27
|
+
/**
|
|
28
|
+
* A Jira or Linear issue key (WEB-12, eng-42 in Linear's branch names) in the scenario's issue:, the branch or the
|
|
29
|
+
* pull request title; upper-cased, or null. Branch words that only look like keys (fix-12, release-3) are skipped.
|
|
30
|
+
*/
|
|
31
|
+
export function issueKeyOf ({ scenario = {}, branch = null, title = null } = {}) {
|
|
32
|
+
const given = scenario.issue?.key
|
|
33
|
+
if (typeof given === 'string' && /^[A-Z][A-Z0-9_]{0,19}-[1-9][0-9]{0,8}$/.test(given)) return given
|
|
34
|
+
const NOT = new Set(['FEAT', 'FEATURE', 'FIX', 'BUGFIX', 'HOTFIX', 'CHORE', 'DOCS', 'TEST', 'TESTS', 'REFACTOR', 'RELEASE', 'RELEASES', 'V', 'VERSION', 'ISSUE', 'ISSUES', 'PR', 'MR', 'PULL', 'BUG', 'TASK', 'STORY', 'WIP', 'UPDATE', 'DEPS', 'BUMP', 'BUILD', 'CI', 'PERF', 'STYLE', 'REVERT', 'MAIN', 'DEV', 'STAGING', 'PROD', 'SPRINT', 'WEEK', 'PHASE', 'STEP', 'PART', 'ROUND', 'BATCH', 'DEPENDABOT', 'RENOVATE'])
|
|
35
|
+
for (const text of [title, branch]) {
|
|
36
|
+
for (const m of String(text || '').matchAll(/(?:^|[^A-Za-z0-9])([A-Za-z][A-Za-z0-9_]{0,19})-([1-9][0-9]{0,8})(?=$|[^0-9])/g)) {
|
|
37
|
+
const key = m[1].toUpperCase()
|
|
38
|
+
if (!NOT.has(key) && /^[A-Z]/.test(key)) return `${key}-${m[2]}`
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return null
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function projectAndIssue ({ cwd, config, scenario, branch, title = null }) {
|
|
28
45
|
const repo = await repoSlug(cwd)
|
|
29
46
|
// the scenario may sort itself into a project; otherwise the config, then the repository name
|
|
30
47
|
const project = scenario.project
|
|
@@ -40,6 +57,9 @@ export async function projectAndIssue ({ cwd, config, scenario, branch }) {
|
|
|
40
57
|
const url = given?.url || (slug && repo?.host === 'github' ? `https://github.com/${slug}/issues/${num}` : slug && repo?.host === 'gitlab' ? `https://gitlab.com/${slug}/-/issues/${num}` : null)
|
|
41
58
|
issue = { repo: slug, number: num, url, title: given?.title || null }
|
|
42
59
|
}
|
|
60
|
+
// a Jira or Linear key travels beside a GitHub number: the service links the run to that issue (trackers)
|
|
61
|
+
const key = issueKeyOf({ scenario, branch, title })
|
|
62
|
+
if (key) issue = { ...(issue || { repo: null, url: given?.url || null, title: given?.title || null }), key }
|
|
43
63
|
return { project, issue }
|
|
44
64
|
}
|
|
45
65
|
|
|
@@ -63,11 +83,11 @@ export async function gitMeta (cwd) {
|
|
|
63
83
|
// CI checks out a detached commit: the branch and pull request come from the environment
|
|
64
84
|
const ciBranch = vars.KALOKO_BRANCH || vars.GITHUB_HEAD_REF || vars.CI_MERGE_REQUEST_SOURCE_BRANCH_NAME || vars.CI_COMMIT_REF_NAME || null
|
|
65
85
|
if (vars.GITLAB_CI && vars.CI_MERGE_REQUEST_IID) pr = { number: Number(vars.CI_MERGE_REQUEST_IID), url: `${vars.CI_MERGE_REQUEST_PROJECT_URL || vars.CI_PROJECT_URL}/-/merge_requests/${vars.CI_MERGE_REQUEST_IID}`, state: 'open' }
|
|
66
|
-
const json = pr ? null : await out('gh', ['pr', 'view', ...(vars.KALOKO_PR ? [String(vars.KALOKO_PR)] : []), '--json', 'number,url,state'], cwd)
|
|
86
|
+
const json = pr ? null : await out('gh', ['pr', 'view', ...(vars.KALOKO_PR ? [String(vars.KALOKO_PR)] : []), '--json', 'number,url,state,title'], cwd)
|
|
67
87
|
if (json) {
|
|
68
88
|
try {
|
|
69
89
|
const p = JSON.parse(json)
|
|
70
|
-
pr = { number: p.number, url: p.url, state: String(p.state).toLowerCase() }
|
|
90
|
+
pr = { number: p.number, url: p.url, state: String(p.state).toLowerCase(), ...(p.title ? { title: String(p.title).slice(0, 300) } : {}) }
|
|
71
91
|
} catch {}
|
|
72
92
|
}
|
|
73
93
|
return { branch: branch === 'HEAD' && ciBranch ? ciBranch : branch, commit, sha, message, author, pr }
|