@newt-kit/plan 0.0.0-stage → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,89 @@
1
+ import type { AppMap } from '../map/app-map'
2
+ import type { Check, StepState } from '../plan/compare'
3
+ import type { PlanInput } from '../plan/plan.schema'
4
+ import type { ResolvedPlan, Status } from '../plan/resolve'
5
+
6
+ const MODULE_STATUS: Record<Status, string> = { new: 'new', modified: 'mod', consumed: 'use', unchanged: 'old', removed: 'del' }
7
+ const PIECE_STATUS: Record<Status, string> = { new: 'n', modified: 'm', consumed: 'u', unchanged: 'o', removed: 'x' }
8
+ const RELATION_STATUS = { new: 'n', unchanged: 'o', removed: 'x' } as const
9
+ const PIECE_KIND = { useCase: 'uc', event: 'ev', schedule: 'sch' } as const
10
+ const RELATION_KIND = { emits: 'emit', triggers: 'ev', calls: 'call' } as const
11
+ const PIECE_ID = /\{([a-z][\w-]*\/[a-zA-Z]\w*\.[a-zA-Z]\w*)\}/g
12
+
13
+ export type PageStep = { title: string, text?: string, focus: string[], state?: StepState, done?: number, of?: number }
14
+
15
+ /** The page's data, as its script reads it (see the comment that opens it). */
16
+ export type PageData = {
17
+ language: ResolvedPlan['language']
18
+ mode: ResolvedPlan['mode']
19
+ title: string
20
+ subtitle?: string
21
+ contexts: Record<string, string>
22
+ modules: Record<string, { ctx: string, port: boolean, st: string, why?: string, items: [string, string, string, { why?: string, cron?: string }][] }>
23
+ relations: [string, string, string, string, string, string, boolean][]
24
+ steps: PageStep[]
25
+ rules: { ok: boolean, text: string }[]
26
+ /** A review held against a plan: each thing done or not. */
27
+ checks?: Check[]
28
+ /** A plan's page: the plan, for its button to save. */
29
+ plan?: PlanInput
30
+ /** A review's page: both maps, for a plan loaded in the page to be held against them. */
31
+ maps?: { base: AppMap, head: AppMap }
32
+ }
33
+
34
+ export type PageExtras = {
35
+ steps?: { title: string, text?: string, focus: string[], state?: StepState, done?: number, of?: number }[]
36
+ checks?: Check[]
37
+ plan?: PlanInput
38
+ maps?: { base: AppMap, head: AppMap }
39
+ }
40
+
41
+ /**
42
+ * The page's data: a module is named by its name, or `<context>/<name>` where two
43
+ * contexts have one of the same name, and a piece by its module's key and its name.
44
+ */
45
+ export function pageData(plan: ResolvedPlan, extras: PageExtras = {}): PageData {
46
+ const named = new Map<string, number>()
47
+ for (const module of plan.modules) {
48
+ named.set(module.name, (named.get(module.name) ?? 0) + 1)
49
+ }
50
+ const keyOf = new Map(plan.modules.map(module => [module.id, named.get(module.name)! > 1 ? module.id : module.name]))
51
+ const pieceKey = (id: string) => keyOf.has(id.slice(0, id.indexOf('.'))) ? `${keyOf.get(id.slice(0, id.indexOf('.')))}.${id.slice(id.indexOf('.') + 1)}` : id
52
+ const inText = (text: string) => text.replace(PIECE_ID, (_, id: string) => `{${pieceKey(id)}}`)
53
+
54
+ const modules: PageData['modules'] = {}
55
+ for (const module of plan.modules) {
56
+ modules[keyOf.get(module.id)!] = {
57
+ ctx: module.context,
58
+ port: module.port,
59
+ st: MODULE_STATUS[module.status],
60
+ ...(module.why ? { why: module.why } : {}),
61
+ items: module.pieces.map(piece => [piece.name, PIECE_KIND[piece.kind], PIECE_STATUS[piece.status], {
62
+ ...(piece.why ? { why: piece.why } : {}),
63
+ ...(piece.cron ? { cron: piece.cron } : {}),
64
+ }]),
65
+ }
66
+ }
67
+ return {
68
+ language: plan.language,
69
+ mode: plan.mode,
70
+ title: plan.title,
71
+ ...(plan.subtitle ? { subtitle: plan.subtitle } : {}),
72
+ contexts: Object.fromEntries(plan.contexts.filter(context => plan.modules.some(module => module.context === context.name)).map(context => [context.name, context.name])),
73
+ modules,
74
+ relations: plan.relations.map(relation => [
75
+ RELATION_KIND[relation.kind], pieceKey(relation.from), pieceKey(relation.to), RELATION_STATUS[relation.status],
76
+ relation.why ?? '', relation.queue ?? '', relation.conditional ?? false,
77
+ ]),
78
+ steps: (extras.steps ?? plan.steps).map(step => ({
79
+ ...step,
80
+ title: inText(step.title),
81
+ ...(step.text ? { text: inText(step.text) } : {}),
82
+ focus: step.focus.map(pieceKey),
83
+ })),
84
+ rules: plan.rules,
85
+ ...(extras.checks ? { checks: extras.checks } : {}),
86
+ ...(extras.plan ? { plan: extras.plan } : {}),
87
+ ...(extras.maps ? { maps: extras.maps } : {}),
88
+ }
89
+ }
@@ -0,0 +1,38 @@
1
+ import { join } from 'node:path'
2
+ import type { PageData } from './page-data'
3
+
4
+ export const TEMPLATE = join(import.meta.dir, 'template.html')
5
+ const BROWSER = join(import.meta.dir, 'browser.ts')
6
+
7
+ /** As JSON in a script: nothing in it may close the script or open a comment. */
8
+ const inScript = (value: unknown) => JSON.stringify(value).replace(/[<\u2028\u2029]/g, char => `\\u${char.charCodeAt(0).toString(16).padStart(4, '0')}`)
9
+
10
+ /** The review the page runs on a plan loaded in it, bundled for the browser once. */
11
+ let bundle: Promise<string> | undefined
12
+ function reviewScript(): Promise<string> {
13
+ bundle ??= Bun.build({ entrypoints: [BROWSER], target: 'browser', format: 'iife', minify: true }).then(async built => {
14
+ if (!built.success) {
15
+ throw new AggregateError(built.logs, 'The page\'s review script does not build')
16
+ }
17
+ return (await built.outputs[0]!.text()).replace(/<\/script/gi, '<\\/script')
18
+ })
19
+ return bundle
20
+ }
21
+
22
+ /**
23
+ * The page: the template, always the same, with the data in it, and for a review the
24
+ * script that holds it against a plan loaded in the page. The same data gives the same bytes.
25
+ */
26
+ export async function renderPage(data: PageData, template = TEMPLATE): Promise<string> {
27
+ const page = await Bun.file(template).text()
28
+ const marker = '/*@@DATA@@*/null'
29
+ if (!page.includes(marker)) {
30
+ throw new Error(`${template} has no ${marker} to put the data in`)
31
+ }
32
+ const withData = page.replace(marker, () => inScript(data))
33
+ if (!data.maps) {
34
+ return withData
35
+ }
36
+ const script = await reviewScript()
37
+ return withData.replace('<!--@@REVIEW@@-->', () => `<script>${script}</script>`)
38
+ }