@easysociable/cli 0.1.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.
Files changed (69) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +95 -0
  3. package/bin/easysociable.js +15 -0
  4. package/dist/easysociable.js +35464 -0
  5. package/dist/easysociable.js.map +1 -0
  6. package/package.json +58 -0
  7. package/skills/ONBOARDING.md +61 -0
  8. package/skills/easysociable-content/SKILL.md +115 -0
  9. package/skills/easysociable-content/platforms/claude.md +5 -0
  10. package/skills/easysociable-content/platforms/codex.md +5 -0
  11. package/skills/easysociable-content/platforms/hermes.md +5 -0
  12. package/skills/easysociable-content/platforms/openclaw.md +5 -0
  13. package/skills/easysociable-content/references/CLI.md +27 -0
  14. package/skills/easysociable-content/references/MCP.md +20 -0
  15. package/skills/easysociable-content/references/PAGE_PACK.md +6 -0
  16. package/skills/easysociable-content/references/TROUBLESHOOTING.md +11 -0
  17. package/skills/easysociable-content/references/VAULT.md +20 -0
  18. package/skills/easysociable-content/scripts/setup.mjs +156 -0
  19. package/skills/easysociable-content/scripts/setup.test.mjs +71 -0
  20. package/skills/easysociable-content/scripts/starter.mjs +53 -0
  21. package/skills/easysociable-content/workflows/ANALYSIS_AND_COVERAGE.md +8 -0
  22. package/skills/easysociable-content/workflows/CONTENT_EXPORT.md +19 -0
  23. package/skills/easysociable-content/workflows/DEVICE_TASKS.md +5 -0
  24. package/skills/easysociable-content/workflows/EXPERIMENTS.md +6 -0
  25. package/skills/easysociable-content/workflows/MCP_FIRST.md +10 -0
  26. package/skills/easysociable-content/workflows/PUBLICATION.md +5 -0
  27. package/skills/easysociable-content/workflows/TOPIC_DRAFT.md +5 -0
  28. package/skills/easysociable-content/workflows/WORKING_DRAFT.md +10 -0
  29. package/skills/easysociable-slideshow/SKILL.md +77 -0
  30. package/skills/easysociable-slideshow/references/CONTENT_PACK.md +25 -0
  31. package/skills/easysociable-slideshow/references/GATEWAY.md +16 -0
  32. package/skills/easysociable-slideshow/references/PLATFORM_CAPS.md +17 -0
  33. package/skills/easysociable-slideshow/references/VAULT.md +18 -0
  34. package/skills/easysociable-slideshow/workflows/CREATE_RUN.md +7 -0
  35. package/skills/easysociable-slideshow/workflows/MCP_FIRST.md +14 -0
  36. package/skills/easysociable-slideshow/workflows/OUT_OF_SCOPE.md +8 -0
  37. package/skills/easysociable-slideshow/workflows/PUBLISH_RUN.md +4 -0
  38. package/skills/easysociable-slideshow/workflows/RENDER_RUN.md +6 -0
  39. package/skills/easysociable-template/SKILL.md +106 -0
  40. package/skills/easysociable-template/references/AUTHORING_SOURCE.md +129 -0
  41. package/skills/easysociable-template/references/CATALOG_METADATA.md +221 -0
  42. package/skills/easysociable-template/references/DESIGN_INTELLIGENCE.md +69 -0
  43. package/skills/easysociable-template/references/DESIGN_V2_1.md +278 -0
  44. package/skills/easysociable-template/references/EXTERNAL_RESOURCES.md +52 -0
  45. package/skills/easysociable-template/references/GATEWAY.md +25 -0
  46. package/skills/easysociable-template/references/PLATFORM_SPECS.md +134 -0
  47. package/skills/easysociable-template/references/SLOTS.md +104 -0
  48. package/skills/easysociable-template/references/THEME_PRESETS.md +48 -0
  49. package/skills/easysociable-template/references/THEME_TOKENS.md +89 -0
  50. package/skills/easysociable-template/references/fixtures/surface-gradient.design-plan.json +74 -0
  51. package/skills/easysociable-template/references/theme-presets.json +95 -0
  52. package/skills/easysociable-template/scripts/generate-theme-from-hex.mjs +402 -0
  53. package/skills/easysociable-template/scripts/validate-authoring-source.mjs +228 -0
  54. package/skills/easysociable-template/scripts/validate-design-v2.mjs +318 -0
  55. package/skills/easysociable-template/workflows/ASSET_PLAN.md +34 -0
  56. package/skills/easysociable-template/workflows/AUTHORING_FROM_BRIEF.md +52 -0
  57. package/skills/easysociable-template/workflows/AUTHORING_FROM_IMAGE.md +52 -0
  58. package/skills/easysociable-template/workflows/AUTHORING_STUDIO.md +122 -0
  59. package/skills/easysociable-template/workflows/DESIGN_BRIEF.md +92 -0
  60. package/skills/easysociable-template/workflows/DESIGN_CONCEPTS.md +41 -0
  61. package/skills/easysociable-template/workflows/DESIGN_PHILOSOPHY.md +69 -0
  62. package/skills/easysociable-template/workflows/FILL_AND_RENDER.md +86 -0
  63. package/skills/easysociable-template/workflows/MCP_FIRST.md +9 -0
  64. package/skills/easysociable-template/workflows/OUT_OF_SCOPE.md +15 -0
  65. package/skills/easysociable-template/workflows/PALETTE_REVIEW.md +71 -0
  66. package/skills/easysociable-template/workflows/PUBLISH_FROM_DRAFT.md +60 -0
  67. package/skills/easysociable-template/workflows/SURFACE_REVIEW.md +178 -0
  68. package/skills/easysociable-template/workflows/THEME_FROM_BRAND.md +73 -0
  69. package/skills/easysociable-template/workflows/VISUAL_QA.md +55 -0
@@ -0,0 +1,402 @@
1
+ #!/usr/bin/env node
2
+
3
+ import fs from 'node:fs'
4
+ import path from 'node:path'
5
+ import { fileURLToPath } from 'node:url'
6
+
7
+ const SHADE_SPECS = {
8
+ 50: { l: 97, sMul: 0.8 },
9
+ 100: { l: 94, sMul: 0.8 },
10
+ 200: { l: 87, sMul: 0.85 },
11
+ 300: { l: 75, sMul: 0.9 },
12
+ 400: { l: 62, sMul: 0.95 },
13
+ 500: { l: 48, sMul: 1.0 },
14
+ 600: { l: 40, sMul: 1.0 },
15
+ 700: { l: 33, sMul: 1.0 },
16
+ 800: { l: 27, sMul: 1.0 },
17
+ 900: { l: 20, sMul: 1.0 },
18
+ 950: { l: 10, sMul: 1.0 },
19
+ }
20
+
21
+ const SEMANTIC_LIGHT = {
22
+ canvas: '#FFFFFF',
23
+ text: '950',
24
+ accent: '700',
25
+ muted: '800',
26
+ primary: '800',
27
+ border: '200',
28
+ }
29
+
30
+ const SEMANTIC_DARK = {
31
+ canvas: '950',
32
+ text: '50',
33
+ accent: '400',
34
+ muted: '400',
35
+ primary: '700',
36
+ border: '800',
37
+ }
38
+
39
+ function parseArgs(argv) {
40
+ const options = {
41
+ hex: null,
42
+ mode: null,
43
+ heading: null,
44
+ body: null,
45
+ output: null,
46
+ preset: null,
47
+ listPresets: false,
48
+ }
49
+
50
+ for (let i = 0; i < argv.length; i += 1) {
51
+ const arg = argv[i]
52
+ if (arg === '--mode') {
53
+ options.mode = argv[++i]
54
+ continue
55
+ }
56
+ if (arg === '--heading') {
57
+ options.heading = argv[++i]
58
+ continue
59
+ }
60
+ if (arg === '--body') {
61
+ options.body = argv[++i]
62
+ continue
63
+ }
64
+ if (arg === '--output' || arg === '-o') {
65
+ options.output = argv[++i]
66
+ continue
67
+ }
68
+ if (arg === '--preset') {
69
+ options.preset = argv[++i]
70
+ continue
71
+ }
72
+ if (arg === '--list-presets') {
73
+ options.listPresets = true
74
+ continue
75
+ }
76
+ if (arg.startsWith('#'))
77
+ options.hex = arg
78
+ }
79
+
80
+ return options
81
+ }
82
+
83
+ function loadPresets() {
84
+ const presetsPath = path.resolve(
85
+ path.dirname(fileURLToPath(import.meta.url)),
86
+ '../references/theme-presets.json',
87
+ )
88
+ const raw = JSON.parse(fs.readFileSync(presetsPath, 'utf8'))
89
+ return raw.presets ?? []
90
+ }
91
+
92
+ function resolvePreset(presetId) {
93
+ const presets = loadPresets()
94
+ const preset = presets.find((item) => item.id === presetId)
95
+ if (!preset)
96
+ throw new Error(`Unknown preset "${presetId}". Use --list-presets.`)
97
+ return preset
98
+ }
99
+
100
+ function applyPresetDefaults(options) {
101
+ if (!options.preset)
102
+ return {
103
+ ...options,
104
+ mode: options.mode ?? 'light',
105
+ heading: options.heading ?? 'Inter',
106
+ body: options.body ?? 'Inter',
107
+ }
108
+
109
+ const preset = resolvePreset(options.preset)
110
+ return {
111
+ ...options,
112
+ hex: options.hex ?? preset.brandHex,
113
+ mode: options.mode ?? preset.mode ?? 'light',
114
+ heading: options.heading ?? preset.fonts?.heading ?? 'Inter',
115
+ body: options.body ?? preset.fonts?.body ?? 'Inter',
116
+ presetMeta: {
117
+ id: preset.id,
118
+ name: preset.name,
119
+ bestFor: preset.bestFor,
120
+ notFor: preset.notFor,
121
+ },
122
+ }
123
+ }
124
+
125
+ function hexToHsl(hex) {
126
+ const normalized = hex.replace(/^#/, '')
127
+ if (!/^[0-9a-fA-F]{6}$/.test(normalized))
128
+ throw new Error(`Invalid hex color: ${hex}`)
129
+
130
+ const r = Number.parseInt(normalized.slice(0, 2), 16) / 255
131
+ const g = Number.parseInt(normalized.slice(2, 4), 16) / 255
132
+ const b = Number.parseInt(normalized.slice(4, 6), 16) / 255
133
+ const max = Math.max(r, g, b)
134
+ const min = Math.min(r, g, b)
135
+ const diff = max - min
136
+ let l = (max + min) / 2
137
+ let s = 0
138
+
139
+ if (diff !== 0)
140
+ s = l > 0.5 ? diff / (2 - max - min) : diff / (max + min)
141
+
142
+ let h = 0
143
+ if (diff !== 0) {
144
+ if (max === r)
145
+ h = ((g - b) / diff + (g < b ? 6 : 0)) / 6
146
+ else if (max === g)
147
+ h = ((b - r) / diff + 2) / 6
148
+ else
149
+ h = ((r - g) / diff + 4) / 6
150
+ }
151
+
152
+ return {
153
+ h: Math.round(h * 360),
154
+ s: Math.round(s * 100),
155
+ l: Math.round(l * 100),
156
+ }
157
+ }
158
+
159
+ function hslToHex(h, s, l) {
160
+ const sat = s / 100
161
+ const light = l / 100
162
+ const c = (1 - Math.abs(2 * light - 1)) * sat
163
+ const x = c * (1 - Math.abs(((h / 60) % 2) - 1))
164
+ const m = light - c / 2
165
+ let r = 0
166
+ let g = 0
167
+ let b = 0
168
+
169
+ if (h < 60) {
170
+ r = c
171
+ g = x
172
+ }
173
+ else if (h < 120) {
174
+ r = x
175
+ g = c
176
+ }
177
+ else if (h < 180) {
178
+ g = c
179
+ b = x
180
+ }
181
+ else if (h < 240) {
182
+ g = x
183
+ b = c
184
+ }
185
+ else if (h < 300) {
186
+ r = x
187
+ b = c
188
+ }
189
+ else {
190
+ r = c
191
+ b = x
192
+ }
193
+
194
+ const toHex = (value) => Math.round((value + m) * 255).toString(16).padStart(2, '0')
195
+ return `#${toHex(r)}${toHex(g)}${toHex(b)}`.toUpperCase()
196
+ }
197
+
198
+ function generateScale(brandHex) {
199
+ const { h, s } = hexToHsl(brandHex)
200
+ const scale = {}
201
+
202
+ for (const [shade, { l, sMul }] of Object.entries(SHADE_SPECS))
203
+ scale[shade] = hslToHex(h, Math.round(s * sMul), l)
204
+
205
+ return scale
206
+ }
207
+
208
+ function resolveSemantic(scale, mapping) {
209
+ const colors = {}
210
+ for (const [token, value] of Object.entries(mapping)) {
211
+ if (value.startsWith('#'))
212
+ colors[token] = value
213
+ else
214
+ colors[token] = scale[value]
215
+ }
216
+ return colors
217
+ }
218
+
219
+ function getLuminance(hex) {
220
+ const normalized = hex.replace(/^#/, '')
221
+ const channels = [
222
+ Number.parseInt(normalized.slice(0, 2), 16) / 255,
223
+ Number.parseInt(normalized.slice(2, 4), 16) / 255,
224
+ Number.parseInt(normalized.slice(4, 6), 16) / 255,
225
+ ].map((channel) => (
226
+ channel <= 0.03928
227
+ ? channel / 12.92
228
+ : ((channel + 0.055) / 1.055) ** 2.4
229
+ ))
230
+
231
+ return 0.2126 * channels[0] + 0.7152 * channels[1] + 0.0722 * channels[2]
232
+ }
233
+
234
+ function getContrastRatio(foreground, background) {
235
+ const lighter = Math.max(getLuminance(foreground), getLuminance(background))
236
+ const darker = Math.min(getLuminance(foreground), getLuminance(background))
237
+ return (lighter + 0.05) / (darker + 0.05)
238
+ }
239
+
240
+ function buildContrastReport(light, dark) {
241
+ const modes = [
242
+ { mode: 'light', palette: light, buttonForeground: light.canvas },
243
+ { mode: 'dark', palette: dark, buttonForeground: dark.text },
244
+ ]
245
+ const pairs = [
246
+ { pair: 'text/canvas', foregroundKey: 'text', backgroundKey: 'canvas', minAA: 4.5 },
247
+ { pair: 'muted/canvas', foregroundKey: 'muted', backgroundKey: 'canvas', minAA: 4.5 },
248
+ { pair: 'accent/canvas', foregroundKey: 'accent', backgroundKey: 'canvas', minAA: 3 },
249
+ ]
250
+
251
+ return modes.flatMap(({ mode, palette, buttonForeground }) => {
252
+ const base = pairs.map(({ pair, foregroundKey, backgroundKey, minAA }) => {
253
+ const foreground = palette[foregroundKey]
254
+ const background = palette[backgroundKey]
255
+ const ratio = Number(getContrastRatio(foreground, background).toFixed(2))
256
+ return {
257
+ pair,
258
+ mode,
259
+ foreground,
260
+ background,
261
+ ratio,
262
+ wcagAA: ratio >= minAA,
263
+ wcagAAA: ratio >= 7,
264
+ }
265
+ })
266
+ const buttonRatio = Number(getContrastRatio(buttonForeground, palette.primary).toFixed(2))
267
+ base.push({
268
+ pair: 'label/primary',
269
+ mode,
270
+ foreground: buttonForeground,
271
+ background: palette.primary,
272
+ ratio: buttonRatio,
273
+ wcagAA: buttonRatio >= 3,
274
+ wcagAAA: buttonRatio >= 7,
275
+ })
276
+ return base
277
+ })
278
+ }
279
+
280
+ function buildThemeTokens(colors, fonts, surfaces, palette) {
281
+ return {
282
+ colors: {
283
+ canvas: colors.canvas,
284
+ text: colors.text,
285
+ accent: colors.accent,
286
+ muted: colors.muted,
287
+ primary: colors.primary,
288
+ border: colors.border,
289
+ },
290
+ fonts: {
291
+ heading: fonts.heading,
292
+ body: fonts.body,
293
+ },
294
+ surfaces: {
295
+ radius: surfaces.radius,
296
+ shadow: surfaces.shadow,
297
+ },
298
+ palette,
299
+ }
300
+ }
301
+
302
+ function generateThemeTokens(options) {
303
+ const scale = generateScale(options.hex)
304
+ const light = resolveSemantic(scale, SEMANTIC_LIGHT)
305
+ const dark = resolveSemantic(scale, SEMANTIC_DARK)
306
+ const contrast = buildContrastReport(light, dark)
307
+ const surfaces = {
308
+ radius: '12px',
309
+ shadow: options.mode === 'dark'
310
+ ? '0 8px 24px rgba(0,0,0,.45)'
311
+ : '0 8px 24px rgba(0,0,0,.12)',
312
+ }
313
+ const fonts = { heading: options.heading, body: options.body }
314
+ const activePalette = options.mode === 'dark' ? dark : light
315
+ const palette = {
316
+ brandHex: options.hex.toUpperCase(),
317
+ scale,
318
+ activeMode: options.mode,
319
+ semantic: { light, dark },
320
+ contrast,
321
+ contrastPass: contrast.every((entry) => entry.wcagAA),
322
+ }
323
+
324
+ return {
325
+ schemaVersion: 'theme_tokens_v2',
326
+ brandHex: options.hex.toUpperCase(),
327
+ scale,
328
+ semantic: { light, dark },
329
+ activeMode: options.mode,
330
+ preset: options.presetMeta ?? null,
331
+ themeTokens: buildThemeTokens(activePalette, fonts, surfaces, palette),
332
+ themeTokensLight: buildThemeTokens(light, fonts, surfaces, { ...palette, activeMode: 'light' }),
333
+ themeTokensDark: buildThemeTokens(dark, fonts, surfaces, { ...palette, activeMode: 'dark' }),
334
+ contrast,
335
+ contrastPass: palette.contrastPass,
336
+ }
337
+ }
338
+
339
+ function printSummary(result) {
340
+ if (result.preset)
341
+ console.log(`Preset: ${result.preset.id} (${result.preset.name})`)
342
+ console.log(`Brand: ${result.brandHex}`)
343
+ console.log(`Active mode: ${result.activeMode}`)
344
+ console.log(`Contrast pass (AA): ${result.contrastPass ? 'yes' : 'no'}`)
345
+ console.log('Scale:')
346
+ for (const shade of Object.keys(SHADE_SPECS))
347
+ console.log(` ${shade}: ${result.scale[shade]}`)
348
+ console.log('ThemeTokens (active):')
349
+ console.log(JSON.stringify(result.themeTokens, null, 2))
350
+ if (!result.contrastPass) {
351
+ console.error('Contrast failures:')
352
+ result.contrast
353
+ .filter((entry) => !entry.wcagAA)
354
+ .forEach((entry) => console.error(` - [${entry.mode}] ${entry.pair}: ${entry.ratio}:1`))
355
+ }
356
+ }
357
+
358
+ function main() {
359
+ const raw = parseArgs(process.argv.slice(2))
360
+ if (raw.listPresets) {
361
+ loadPresets().forEach((preset) => {
362
+ console.log(`${preset.id}\t${preset.name}\t${preset.brandHex}\t${preset.mode}\t${preset.bestFor}`)
363
+ })
364
+ return
365
+ }
366
+
367
+ let options
368
+ try {
369
+ options = applyPresetDefaults(raw)
370
+ }
371
+ catch (error) {
372
+ console.error(error.message)
373
+ process.exit(2)
374
+ }
375
+
376
+ if (!options.hex) {
377
+ console.error('Usage: node generate-theme-from-hex.mjs <#hex|--preset id> [--mode light|dark] [--heading Font] [--body Font] [--output path]')
378
+ console.error(' node generate-theme-from-hex.mjs --list-presets')
379
+ process.exit(2)
380
+ }
381
+ if (!['light', 'dark'].includes(options.mode)) {
382
+ console.error('--mode must be light or dark')
383
+ process.exit(2)
384
+ }
385
+
386
+ const result = generateThemeTokens(options)
387
+ const json = `${JSON.stringify(result, null, 2)}\n`
388
+
389
+ if (options.output) {
390
+ fs.writeFileSync(path.resolve(options.output), json)
391
+ console.log(`Wrote ${path.resolve(options.output)}`)
392
+ }
393
+ else {
394
+ process.stdout.write(json)
395
+ }
396
+
397
+ printSummary(result)
398
+ if (!result.contrastPass)
399
+ process.exit(1)
400
+ }
401
+
402
+ main()
@@ -0,0 +1,228 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { readFileSync } from 'node:fs'
4
+
5
+ function fail(message) {
6
+ process.stderr.write(`authoring-source invalid: ${message}\n`)
7
+ process.exitCode = 1
8
+ }
9
+
10
+ function readInput(path) {
11
+ if (path === '-')
12
+ return readFileSync(0, 'utf8')
13
+ return readFileSync(path, 'utf8')
14
+ }
15
+
16
+ function arbitraryTokens(className) {
17
+ return className
18
+ .split(/\s+/)
19
+ .filter(Boolean)
20
+ .filter(token => token.includes('[') && token.includes(']'))
21
+ }
22
+
23
+ function inlineStyleSource(tag) {
24
+ const objectStyle = tag.match(/\bstyle\s*=\s*\{\{([\s\S]*?)\}\}/)
25
+ if (objectStyle)
26
+ return objectStyle[1]
27
+
28
+ const stringStyle = tag.match(/\bstyle\s*=\s*["']([^"']*)["']/)
29
+ return stringStyle?.[1] ?? ''
30
+ }
31
+
32
+ function styleEntriesToSource(style) {
33
+ if (!style || typeof style !== 'object')
34
+ return ''
35
+ return Object.entries(style).map(([key, item]) => `${key}:${String(item)}`).join(';')
36
+ }
37
+
38
+ function isAbsolutelyPositionedStyle(styleSource) {
39
+ return /\bposition\s*:\s*['"]?absolute\b/i.test(styleSource)
40
+ }
41
+
42
+ function checkClassName(className, location, styleSource = '') {
43
+ const arbitrary = arbitraryTokens(className)
44
+ if (arbitrary.length > 0) {
45
+ fail(
46
+ `${location} uses runtime Tailwind arbitrary-value classes: ${arbitrary.join(', ')}. `
47
+ + 'Move exact geometry, typography, and Theme values into inline style.',
48
+ )
49
+ }
50
+
51
+ if (className.split(/\s+/).includes('absolute')) {
52
+ const hasInlinePosition = /\bposition\s*:\s*['"]?absolute\b/.test(styleSource)
53
+ const hasInlineCoordinate = /\b(?:left|right|top|bottom)\s*:/.test(styleSource)
54
+ if (!hasInlinePosition || !hasInlineCoordinate) {
55
+ fail(
56
+ `${location} relies on className="absolute" without inline position and coordinates. `
57
+ + 'Runtime authoring geometry must be deterministic inline style.',
58
+ )
59
+ }
60
+ }
61
+ }
62
+
63
+ function tagNameFromJsx(tag) {
64
+ const match = tag.match(/^<\/?\s*([A-Za-z][\w:-]*)/)
65
+ return match?.[1]?.toLowerCase() ?? 'unknown'
66
+ }
67
+
68
+ /**
69
+ * Flat-tree rule: absolutely positioned nodes must be canvas siblings.
70
+ * Nesting absolute inside absolute breaks Studio drag/select coordinates.
71
+ * Non-positioned text/inline children under a text unit remain allowed.
72
+ */
73
+ function checkJsxFlatNesting(jsx) {
74
+ const tags = jsx.match(/<\/?[A-Za-z][\s\S]*?>/g) ?? []
75
+ const stack = []
76
+
77
+ tags.forEach((tag, index) => {
78
+ const name = tagNameFromJsx(tag)
79
+ const location = `json tag ${index + 1} (<${name}>)`
80
+
81
+ if (tag.startsWith('</')) {
82
+ while (stack.length > 0) {
83
+ const top = stack.pop()
84
+ if (top.name === name)
85
+ break
86
+ }
87
+ return
88
+ }
89
+
90
+ const styleSource = inlineStyleSource(tag)
91
+ const absolute = isAbsolutelyPositionedStyle(styleSource)
92
+ if (absolute) {
93
+ const absAncestor = [...stack].reverse().find(frame => frame.absolute)
94
+ if (absAncestor) {
95
+ fail(
96
+ `${location} is absolutely positioned inside another absolute node (<${absAncestor.name}>). `
97
+ + 'Flatten to canvas siblings with canvas coordinates. '
98
+ + 'Only non-positioned text/inline children may nest under a text unit; '
99
+ + 'card chrome must be a separate decoration sibling.',
100
+ )
101
+ }
102
+ }
103
+
104
+ const selfClosing = /\/\s*>$/.test(tag) || /^<(?:img|br|hr|input|meta|link)\b/i.test(tag)
105
+ if (!selfClosing)
106
+ stack.push({ name, absolute })
107
+ })
108
+ }
109
+
110
+ function checkJsxString(jsx) {
111
+ const tags = jsx.match(/<[A-Za-z][\s\S]*?>/g) ?? []
112
+ tags.forEach((tag, index) => {
113
+ const classMatch = tag.match(/\bclass(?:Name)?\s*=\s*["']([^"']*)["']/)
114
+ if (!classMatch)
115
+ return
116
+ checkClassName(classMatch[1], `json tag ${index + 1}`, inlineStyleSource(tag))
117
+ })
118
+ checkJsxFlatNesting(jsx)
119
+ }
120
+
121
+ function nodeTagName(value) {
122
+ const raw = value.tagName || value.type || value.name
123
+ return typeof raw === 'string' ? raw.toLowerCase() : 'node'
124
+ }
125
+
126
+ function nodeStyleSource(value) {
127
+ const fromProps = value.props?.style
128
+ if (fromProps && typeof fromProps === 'object')
129
+ return styleEntriesToSource(fromProps)
130
+ if (value.style && typeof value.style === 'object')
131
+ return styleEntriesToSource(value.style)
132
+ return ''
133
+ }
134
+
135
+ function nodeClassName(value) {
136
+ if (typeof value.className === 'string')
137
+ return value.className
138
+ if (typeof value.class === 'string')
139
+ return value.class
140
+ if (typeof value.props?.className === 'string')
141
+ return value.props.className
142
+ if (typeof value.props?.class === 'string')
143
+ return value.props.class
144
+ return ''
145
+ }
146
+
147
+ function checkObjectFlatNesting(value, path = 'json', ancestorAbsolute = false) {
148
+ if (Array.isArray(value)) {
149
+ value.forEach((item, index) => checkObjectFlatNesting(item, `${path}[${index}]`, ancestorAbsolute))
150
+ return
151
+ }
152
+ if (!value || typeof value !== 'object')
153
+ return
154
+
155
+ const styleSource = nodeStyleSource(value)
156
+ const absolute = isAbsolutelyPositionedStyle(styleSource)
157
+ if (absolute && ancestorAbsolute) {
158
+ fail(
159
+ `${path} (<${nodeTagName(value)}>) is absolutely positioned inside another absolute node. `
160
+ + 'Flatten to canvas siblings with canvas coordinates. '
161
+ + 'Only non-positioned text/inline children may nest under a text unit.',
162
+ )
163
+ }
164
+
165
+ const childAncestorAbsolute = ancestorAbsolute || absolute
166
+ const children = value.children ?? value.props?.children
167
+ if (Array.isArray(children))
168
+ checkObjectFlatNesting(children, `${path}.children`, childAncestorAbsolute)
169
+ else if (children && typeof children === 'object')
170
+ checkObjectFlatNesting(children, `${path}.children`, childAncestorAbsolute)
171
+
172
+ Object.entries(value).forEach(([key, item]) => {
173
+ if (['children', 'props', 'style', 'className', 'class'].includes(key))
174
+ return
175
+ if (item && typeof item === 'object')
176
+ checkObjectFlatNesting(item, `${path}.${key}`, false)
177
+ })
178
+ }
179
+
180
+ function checkObjectProjection(value, path = 'json') {
181
+ if (Array.isArray(value)) {
182
+ value.forEach((item, index) => checkObjectProjection(item, `${path}[${index}]`))
183
+ return
184
+ }
185
+ if (!value || typeof value !== 'object')
186
+ return
187
+
188
+ const className = nodeClassName(value)
189
+ const style = nodeStyleSource(value)
190
+ if (className)
191
+ checkClassName(className, path, style)
192
+
193
+ Object.entries(value).forEach(([key, item]) => {
194
+ if (!['className', 'class', 'style'].includes(key))
195
+ checkObjectProjection(item, `${path}.${key}`)
196
+ })
197
+ }
198
+
199
+ const inputPath = process.argv[2]
200
+ if (!inputPath) {
201
+ process.stderr.write('Usage: node validate-authoring-source.mjs <authoring-source.json|->\n')
202
+ process.exit(2)
203
+ }
204
+
205
+ let source
206
+ try {
207
+ source = JSON.parse(readInput(inputPath))
208
+ }
209
+ catch (error) {
210
+ fail(`cannot read JSON: ${error instanceof Error ? error.message : String(error)}`)
211
+ process.exit()
212
+ }
213
+
214
+ if (!source?.canvas || !Number.isInteger(source.canvas.width) || source.canvas.width <= 0
215
+ || !Number.isInteger(source.canvas.height) || source.canvas.height <= 0) {
216
+ fail('canvas.width and canvas.height must be positive integers')
217
+ }
218
+
219
+ if (typeof source?.json === 'string') {
220
+ checkJsxString(source.json)
221
+ }
222
+ else {
223
+ checkObjectProjection(source?.json)
224
+ checkObjectFlatNesting(source?.json)
225
+ }
226
+
227
+ if (!process.exitCode)
228
+ process.stdout.write('authoring-source valid\n')