@svgrid/enterprise 1.2.0 → 2.0.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 (159) hide show
  1. package/dist/cdn/svgrid-enterprise.svelte-external.js +26039 -805
  2. package/dist/designer/assets/index-Dp44bTid.js +939 -0
  3. package/dist/designer/assets/index-RJp6x8tw.css +1 -0
  4. package/dist/designer/assets/jszip.min-CjMo-QGg.js +2 -0
  5. package/dist/designer/index.html +13 -0
  6. package/dist/node/studio.js +22554 -0
  7. package/package.json +10 -3
  8. package/src/SvAuthGate.svelte +115 -0
  9. package/src/SvBoard.dom.test.ts +67 -0
  10. package/src/SvBoard.svelte +192 -0
  11. package/src/SvExportMenu.svelte +553 -0
  12. package/src/SvFileInput.svelte +113 -0
  13. package/src/SvGridEditPanel.dom.test.ts +146 -0
  14. package/src/SvGridEditPanel.svelte +793 -0
  15. package/src/SvGridMasterDetail.svelte +95 -0
  16. package/src/SvImportDialog.svelte +1020 -0
  17. package/src/SvLookupInput.svelte +180 -0
  18. package/src/SvRecordDetail.dom.test.ts +137 -0
  19. package/src/SvRecordDetail.svelte +288 -0
  20. package/src/SvSchedule.dom.test.ts +57 -0
  21. package/src/SvSchedule.svelte +156 -0
  22. package/src/SvSchemaChart.svelte +233 -0
  23. package/src/SvSchemaDashboard.svelte +130 -0
  24. package/src/ai-export-pdf.test.ts +74 -0
  25. package/src/ai-export-xlsx.test.ts +87 -0
  26. package/src/ai-export.test.ts +110 -0
  27. package/src/ai.ts +1188 -782
  28. package/src/edit-panel.test.ts +213 -0
  29. package/src/edit-panel.ts +228 -0
  30. package/src/export-conditional.test.ts +60 -0
  31. package/src/export-conditional.ts +94 -0
  32. package/src/export-ooxml.test.ts +152 -0
  33. package/src/export-ooxml.ts +485 -0
  34. package/src/export-pdf.test.ts +138 -0
  35. package/src/export-pdf.ts +245 -0
  36. package/src/export-print.test.ts +66 -0
  37. package/src/export-print.ts +132 -0
  38. package/src/export-serialize.test.ts +56 -0
  39. package/src/export-serialize.ts +24 -0
  40. package/src/export-xls.ts +196 -0
  41. package/src/export-xlsx-roundtrip.test.ts +120 -0
  42. package/src/export-xlsx.test.ts +150 -0
  43. package/src/export.test.ts +349 -0
  44. package/src/export.ts +1452 -549
  45. package/src/import-automap.test.ts +131 -0
  46. package/src/import-hardening.test.ts +158 -0
  47. package/src/import.ts +1042 -648
  48. package/src/index.ts +282 -0
  49. package/src/install.ts +134 -114
  50. package/src/license-core.test.ts +23 -0
  51. package/src/license-core.ts +35 -0
  52. package/src/license.ts +103 -90
  53. package/src/master-detail.test.ts +61 -0
  54. package/src/master-detail.ts +42 -0
  55. package/src/print.ts +107 -127
  56. package/src/schema-designer.test.ts +121 -0
  57. package/src/schema-designer.ts +142 -0
  58. package/src/schema.test.ts +268 -0
  59. package/src/schema.ts +499 -0
  60. package/src/smart-shim.ts +107 -105
  61. package/src/sources/aggregate.test.ts +79 -0
  62. package/src/sources/aggregate.ts +102 -0
  63. package/src/sources/auth-supabase.test.ts +80 -0
  64. package/src/sources/auth-supabase.ts +87 -0
  65. package/src/sources/dashboard.kpi.test.ts +50 -0
  66. package/src/sources/dashboard.test.ts +61 -0
  67. package/src/sources/dashboard.ts +136 -0
  68. package/src/sources/field-inference.test.ts +60 -0
  69. package/src/sources/field-inference.ts +63 -0
  70. package/src/sources/filters.test.ts +58 -0
  71. package/src/sources/filters.ts +64 -0
  72. package/src/sources/index.ts +67 -0
  73. package/src/sources/introspect-supabase.test.ts +100 -0
  74. package/src/sources/introspect-supabase.ts +119 -0
  75. package/src/sources/realtime-supabase.test.ts +93 -0
  76. package/src/sources/realtime-supabase.ts +99 -0
  77. package/src/sources/relation-lookup.test.ts +99 -0
  78. package/src/sources/relation-lookup.ts +128 -0
  79. package/src/sources/rest-adapters.test.ts +95 -0
  80. package/src/sources/rest-adapters.ts +120 -0
  81. package/src/sources/rest.test.ts +104 -0
  82. package/src/sources/rest.ts +129 -0
  83. package/src/sources/schema-from-columns.test.ts +106 -0
  84. package/src/sources/schema-from-columns.ts +88 -0
  85. package/src/sources/supabase.test.ts +110 -0
  86. package/src/sources/supabase.ts +99 -0
  87. package/src/sources/with-entity-rules.test.ts +104 -0
  88. package/src/sources/with-entity-rules.ts +78 -0
  89. package/src/sources/with-relation-labels.test.ts +75 -0
  90. package/src/sources/with-relation-labels.ts +73 -0
  91. package/src/studio/bug-report.test.ts +101 -0
  92. package/src/studio/bug-report.ts +165 -0
  93. package/src/studio/cli.test.ts +187 -0
  94. package/src/studio/cli.ts +112 -0
  95. package/src/studio/csv.test.ts +90 -0
  96. package/src/studio/csv.ts +165 -0
  97. package/src/studio/db-connect-string.test.ts +94 -0
  98. package/src/studio/db-connect-string.ts +128 -0
  99. package/src/studio/emit-project.test.ts +923 -0
  100. package/src/studio/emit-project.ts +1273 -0
  101. package/src/studio/emit-schema.test.ts +94 -0
  102. package/src/studio/emit-schema.ts +920 -0
  103. package/src/studio/index.ts +195 -0
  104. package/src/studio/introspect-db.test.ts +186 -0
  105. package/src/studio/introspect-db.ts +312 -0
  106. package/src/studio/introspect-prisma.test.ts +99 -0
  107. package/src/studio/introspect-prisma.ts +175 -0
  108. package/src/studio/introspect.test.ts +172 -0
  109. package/src/studio/introspect.ts +310 -0
  110. package/src/studio/pipeline.test.ts +42 -0
  111. package/src/studio/project-robust.test.ts +157 -0
  112. package/src/studio/project.test.ts +473 -0
  113. package/src/studio/project.ts +916 -0
  114. package/src/studio/sample-data.test.ts +58 -0
  115. package/src/studio/sample-data.ts +200 -0
  116. package/src/studio/samples/ats.ts +202 -0
  117. package/src/studio/samples/clinic.ts +177 -0
  118. package/src/studio/samples/crm.ts +250 -0
  119. package/src/studio/samples/ecommerce.ts +187 -0
  120. package/src/studio/samples/events.ts +199 -0
  121. package/src/studio/samples/fleet.ts +184 -0
  122. package/src/studio/samples/gym.ts +213 -0
  123. package/src/studio/samples/hr.ts +193 -0
  124. package/src/studio/samples/index.ts +55 -0
  125. package/src/studio/samples/insurance.ts +195 -0
  126. package/src/studio/samples/inventory.ts +182 -0
  127. package/src/studio/samples/invoicing.ts +162 -0
  128. package/src/studio/samples/library.ts +180 -0
  129. package/src/studio/samples/live-data.test.ts +98 -0
  130. package/src/studio/samples/live-data.ts +308 -0
  131. package/src/studio/samples/projects.ts +190 -0
  132. package/src/studio/samples/realestate.ts +196 -0
  133. package/src/studio/samples/restaurant.ts +187 -0
  134. package/src/studio/samples/samples.test.ts +208 -0
  135. package/src/studio/samples/school.ts +197 -0
  136. package/src/studio/samples/seed-floor.test.ts +25 -0
  137. package/src/studio/samples/shared.ts +305 -0
  138. package/src/studio/samples/subscriptions.ts +183 -0
  139. package/src/studio/samples/support.ts +182 -0
  140. package/src/studio/scaffold-app.test.ts +91 -0
  141. package/src/studio/scaffold-app.ts +161 -0
  142. package/src/studio/scaffold.test.ts +178 -0
  143. package/src/studio/scaffold.ts +374 -0
  144. package/src/studio/themes.ts +172 -0
  145. package/src/studio/verify.test.ts +47 -0
  146. package/src/studio/verify.ts +79 -0
  147. package/src/sveltekit/in-memory.test.ts +104 -0
  148. package/src/sveltekit/in-memory.ts +129 -0
  149. package/src/sveltekit/index.ts +23 -0
  150. package/src/sveltekit/query-plan.test.ts +109 -0
  151. package/src/sveltekit/query-plan.ts +125 -0
  152. package/src/sveltekit/sql-source.test.ts +185 -0
  153. package/src/sveltekit/sql-source.ts +166 -0
  154. package/src/sveltekit/sql.test.ts +80 -0
  155. package/src/sveltekit/sql.ts +131 -0
  156. package/src/sveltekit/transport.test.ts +175 -0
  157. package/src/sveltekit/transport.ts +254 -0
  158. package/src/sveltekit/types.ts +11 -0
  159. package/src/upgrade-prompt.ts +149 -148
@@ -0,0 +1,374 @@
1
+ /**
2
+ * scaffold - turn an `EntitySchema` into runnable SvelteKit files. The shared
3
+ * code-emit core: both the AI generator (MCP `scaffold_entity` tool) and the
4
+ * visual designer's "Generate code" action call this one function, so there is
5
+ * a single source of truth for what generated code looks like.
6
+ *
7
+ * Every generated file wraps its body in `svgrid:managed` markers. A future
8
+ * regeneration replaces only the managed region, so edits a user makes outside
9
+ * the markers survive - idempotent generation, the thing that makes a
10
+ * generator trustworthy enough to keep using.
11
+ *
12
+ * Pure string building. No filesystem, no Svelte. The `validate` (Standard
13
+ * Schema) functions on a field cannot be serialized to a literal, so they are
14
+ * omitted from the emitted schema with a note to re-add them by hand.
15
+ */
16
+ import { resolveIdField, type EntityField, type EntitySchema } from '../schema.js'
17
+ import type { SqlDialectName } from './introspect-db.js'
18
+
19
+ export type GeneratedFile = {
20
+ path: string
21
+ contents: string
22
+ description: string
23
+ }
24
+
25
+ export type ScaffoldOptions = {
26
+ /** Route segment for the page, defaults to the schema name. e.g. `'customers'`. */
27
+ route?: string
28
+ /** API route the page talks to, defaults to `/api/{route}`. */
29
+ apiRoute?: string
30
+ /** `$lib` path for the generated schema module, defaults to `$lib/{route}.schema`. */
31
+ schemaImport?: string
32
+ /**
33
+ * Backend the generated API route uses:
34
+ * - `'memory'` (default): `createInMemoryDataSource` - runs immediately, no DB.
35
+ * - `'sql'`: `createSqlDataSource` with an `execute(text, params)` stub you fill in.
36
+ * - a dialect (`'postgres'` | `'supabase'` | `'mysql'` | `'mssql'` | `'sqlite'`):
37
+ * a fully-connected route wired to that driver via `process.env.DATABASE_URL`.
38
+ */
39
+ dataSource?: 'memory' | 'sql' | SqlDialectName
40
+ }
41
+
42
+ /** Per-dialect driver wiring for a connected `+server.ts`. */
43
+ type DriverWiring = { imports: string; setup: string; dialectArg: string; execBody: string }
44
+ const DRIVERS: Record<'postgres' | 'mysql' | 'mssql' | 'sqlite', DriverWiring> = {
45
+ postgres: {
46
+ imports: `import pg from 'pg'`,
47
+ setup: `const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL })`,
48
+ dialectArg: `\n dialect: { placeholders: '$', ilike: true },`,
49
+ execBody: `const result = await pool.query(text, params)\n return result.rows`,
50
+ },
51
+ mysql: {
52
+ imports: `import mysql from 'mysql2/promise'`,
53
+ setup: `const pool = mysql.createPool(process.env.DATABASE_URL ?? '')`,
54
+ dialectArg: ``,
55
+ execBody: `const [rows] = await pool.query(text, params)\n return rows as Record<string, unknown>[]`,
56
+ },
57
+ mssql: {
58
+ imports: `import mssql from 'mssql'`,
59
+ setup: `const poolPromise = mssql.connect(process.env.DATABASE_URL ?? '')`,
60
+ dialectArg: `\n dialect: { placeholders: '@' },`,
61
+ execBody: `const pool = await poolPromise\n const request = pool.request()\n params.forEach((p, i) => request.input('p' + (i + 1), p))\n const result = await request.query(text)\n return result.recordset as Record<string, unknown>[]`,
62
+ },
63
+ sqlite: {
64
+ imports: `import Database from 'better-sqlite3'`,
65
+ setup: `const db = new Database(process.env.DATABASE_URL ?? 'data.db')`,
66
+ dialectArg: ``,
67
+ execBody: `return db.prepare(text).all(...params) as Record<string, unknown>[]`,
68
+ },
69
+ }
70
+
71
+ export const MANAGED_START = '// svgrid:managed:start'
72
+ export const MANAGED_END = '// svgrid:managed:end'
73
+ const MANAGED_NOTE = '// Regenerated by SvGrid Studio. Edits inside these markers are overwritten.'
74
+
75
+ function pascal(name: string): string {
76
+ return name
77
+ .replace(/[_\-\s]+/g, ' ')
78
+ .replace(/([a-z\d])([A-Z])/g, '$1 $2')
79
+ .split(' ')
80
+ .filter(Boolean)
81
+ .map((w) => w.charAt(0).toUpperCase() + w.slice(1))
82
+ .join('')
83
+ }
84
+
85
+ function camel(name: string): string {
86
+ const p = pascal(name)
87
+ return p.charAt(0).toLowerCase() + p.slice(1)
88
+ }
89
+
90
+ function tsType(field: EntityField): string {
91
+ switch (field.type) {
92
+ case 'number':
93
+ return 'number'
94
+ case 'boolean':
95
+ return 'boolean'
96
+ default:
97
+ return 'string'
98
+ }
99
+ }
100
+
101
+ function fieldLiteral(field: EntityField): string {
102
+ const parts = [`field: ${JSON.stringify(field.field)}`, `type: ${JSON.stringify(field.type)}`]
103
+ if (field.label) parts.push(`label: ${JSON.stringify(field.label)}`)
104
+ if (field.primaryKey) parts.push('primaryKey: true')
105
+ if (field.readonly) parts.push('readonly: true')
106
+ if (field.required) parts.push('required: true')
107
+ if (field.hidden) parts.push(`hidden: ${JSON.stringify(field.hidden)}`)
108
+ if (field.options) parts.push(`options: ${JSON.stringify(field.options)}`)
109
+ if (field.relation) parts.push(`relation: ${JSON.stringify(field.relation)}`)
110
+ return ` { ${parts.join(', ')} }`
111
+ }
112
+
113
+ function schemaLiteral(schema: EntitySchema, constName: string, rowType: string): string {
114
+ const head = [` name: ${JSON.stringify(schema.name)}`]
115
+ if (schema.label) head.push(` label: ${JSON.stringify(schema.label)}`)
116
+ if (schema.idField) head.push(` idField: ${JSON.stringify(schema.idField)}`)
117
+ const fields = schema.fields.map(fieldLiteral).join(',\n')
118
+ return `export const ${constName}: EntitySchema<${rowType}> = {\n${head.join(',\n')},\n fields: [\n${fields}\n ],\n}`
119
+ }
120
+
121
+ function managed(body: string): string {
122
+ return `${MANAGED_START}\n${MANAGED_NOTE}\n${body}\n${MANAGED_END}`
123
+ }
124
+
125
+ export function scaffold(schema: EntitySchema, options: ScaffoldOptions = {}): { files: GeneratedFile[] } {
126
+ const route = options.route ?? schema.name
127
+ const apiRoute = options.apiRoute ?? `/api/${route}`
128
+ const schemaImport = options.schemaImport ?? `$lib/${route}.schema`
129
+ const constName = `${camel(schema.name)}Schema`
130
+ const rowType = `${pascal(schema.name)}Row`
131
+ const idField = resolveIdField(schema)
132
+ const label = schema.label ?? schema.name
133
+
134
+ const rowFields = schema.fields
135
+ .map((f) => ` ${f.field}: ${tsType(f)}`)
136
+ .join('\n')
137
+
138
+ // 1. The schema module ($lib).
139
+ const schemaFile: GeneratedFile = {
140
+ path: `src/lib/${route}.schema.ts`,
141
+ description: 'Entity schema + row type. Re-add any Standard Schema `validate` functions here by hand.',
142
+ contents: `import type { EntitySchema } from '@svgrid/enterprise'
143
+
144
+ ${managed(`export type ${rowType} = {
145
+ ${rowFields}
146
+ }
147
+
148
+ ${schemaLiteral(schema, constName, rowType)}`)}
149
+ `,
150
+ }
151
+
152
+ // 2. The API route: SvelteKit +server.ts using the transport handlers.
153
+ const dataSource = options.dataSource ?? 'memory'
154
+ const driver = dataSource === 'supabase' ? DRIVERS.postgres : DRIVERS[dataSource as 'postgres']
155
+ let serverImport: string
156
+ let serverBody: string
157
+ let serverDescription: string
158
+
159
+ if (driver) {
160
+ // Fully-connected route: driver + DATABASE_URL, no manual editing needed.
161
+ serverImport = `${driver.imports}
162
+ import { createKitHandlers, createSqlDataSource } from '@svgrid/enterprise'`
163
+ serverBody = `${driver.setup}
164
+
165
+ const source = createSqlDataSource<${rowType}>({
166
+ schema: ${constName},
167
+ table: ${JSON.stringify(schema.name)},${driver.dialectArg}
168
+ execute: async (text, params) => {
169
+ ${driver.execBody}
170
+ },
171
+ })
172
+
173
+ export const { POST } = createKitHandlers({ schema: ${constName}, source })`
174
+ serverDescription = `API route connected to your ${dataSource} database via process.env.DATABASE_URL.`
175
+ } else if (dataSource === 'sql') {
176
+ serverImport = `import { createKitHandlers, createSqlDataSource } from '@svgrid/enterprise'`
177
+ serverBody = `// Wire execute() to your SQL client: run \`text\` with \`params\`, return the rows.
178
+ // Works with Drizzle (db.execute), postgres.js, better-sqlite3, mysql2, ...
179
+ const source = createSqlDataSource<${rowType}>({
180
+ schema: ${constName},
181
+ table: ${JSON.stringify(schema.name)},
182
+ dialect: { placeholders: '$', ilike: true },
183
+ execute: async (text, params) => {
184
+ throw new Error('TODO: run the query with your database client and return the rows.')
185
+ },
186
+ })
187
+
188
+ export const { POST } = createKitHandlers({ schema: ${constName}, source })`
189
+ serverDescription = 'API route. Fill in execute() with your SQL client (Drizzle / postgres.js / ...).'
190
+ } else {
191
+ serverImport = `import { createInMemoryDataSource, createKitHandlers } from '@svgrid/enterprise'`
192
+ serverBody = `// Starts empty and in-memory so the screen runs immediately. Swap this for
193
+ // a database-backed source (scaffold with dataSource:"sql" or a dialect) when ready.
194
+ const source = createInMemoryDataSource<${rowType}>([], ${constName})
195
+
196
+ export const { POST } = createKitHandlers({ schema: ${constName}, source })`
197
+ serverDescription = 'API route using an in-memory source. Scaffold with a dialect for a real database.'
198
+ }
199
+
200
+ const serverFile: GeneratedFile = {
201
+ path: `src/routes${apiRoute}/+server.ts`,
202
+ description: serverDescription,
203
+ contents: `${serverImport}
204
+ import { ${constName}, type ${rowType} } from '${schemaImport}'
205
+
206
+ ${managed(serverBody)}
207
+ `,
208
+ }
209
+
210
+ // Relation (foreign-key) fields become searchable lookups in the edit form.
211
+ // Each lookup queries the related entity's API route, so scaffold those tables
212
+ // too (e.g. `npx @svgrid/studio add <relatedTable>`).
213
+ const relationFields = schema.fields.filter(
214
+ (f): f is EntityField & { relation: NonNullable<EntityField['relation']> } =>
215
+ f.type === 'relation' && !!f.relation,
216
+ )
217
+ const hasLookups = relationFields.length > 0
218
+ const lookupDecls = relationFields
219
+ .map((f) => {
220
+ const name = `${camel(f.field)}Lookup`
221
+ const endpoint = JSON.stringify(`/api/${f.relation.entity}`)
222
+ return ` const ${name} = createRelationLookup<Record<string, unknown>>({
223
+ source: createKitDataSource({ endpoint: ${endpoint} }),
224
+ labelField: ${JSON.stringify(f.relation.labelField)},
225
+ })`
226
+ })
227
+ .join('\n')
228
+ const lookupsMap = ` const lookups = { ${relationFields
229
+ .map((f) => `${JSON.stringify(f.field)}: ${camel(f.field)}Lookup`)
230
+ .join(', ')} }`
231
+ const lookupsSection = hasLookups ? `\n\n${lookupDecls}\n${lookupsMap}` : ''
232
+
233
+ // 3. The page: a full data screen - search, server sort + paging,
234
+ // multi-select delete (optimistic), create/edit, loading/error/empty.
235
+ const pageBody = ` const source = createKitDataSource<${rowType}>({ endpoint: '${apiRoute}' })
236
+ const columns = schemaToColumns(${constName})${lookupsSection}
237
+
238
+ let state = $state<ServerState<${rowType}>>({
239
+ rows: [], total: 0, loading: false, saving: false, error: null,
240
+ pageIndex: 0, pageSize: 25, pageCount: 1, sortModel: [], filterModel: {},
241
+ })
242
+ const controller = createServerDataSource<${rowType}>(source, {
243
+ pageSize: 25,
244
+ optimistic: true,
245
+ getRowId: (r) => String(r.${idField}),
246
+ onChange: (s) => (state = s),
247
+ })
248
+ controller.refresh()
249
+
250
+ let selected = $state<${rowType}[]>([])
251
+ let editing = $state<${rowType} | null | undefined>(undefined) // undefined = closed, null = create
252
+
253
+ async function save({ mode, id, values }: { mode: 'create' | 'edit'; id: string | null; values: Partial<${rowType}> }) {
254
+ if (mode === 'create') {
255
+ await controller.createRow(values)
256
+ controller.setPage(state.pageCount - 1) // jump to the new row (appended at the end)
257
+ } else if (id) {
258
+ await controller.updateRow(id, values)
259
+ }
260
+ editing = undefined
261
+ }
262
+
263
+ async function removeSelected() {
264
+ for (const row of selected) await controller.deleteRow(String(row.${idField}))
265
+ selected = []
266
+ }`
267
+
268
+ const pageFile: GeneratedFile = {
269
+ path: `src/routes/${route}/+page.svelte`,
270
+ description: 'The data screen: sort, filter, search, paging, multi-select delete, and create/edit - wired to the API route.',
271
+ contents: `<script lang="ts">
272
+ import { SvGrid, createServerDataSource } from '@svgrid/grid'
273
+ import {
274
+ SvGridEditPanel,
275
+ createKitDataSource,${hasLookups ? '\n createRelationLookup,' : ''}
276
+ schemaToColumns,
277
+ type ServerState,
278
+ } from '@svgrid/enterprise'
279
+ import { ${constName}, type ${rowType} } from '${schemaImport}'
280
+
281
+ ${managed(pageBody)}
282
+ </script>
283
+
284
+ <div class="page">
285
+ <header class="page__toolbar">
286
+ <h2 class="page__title">${label}</h2>
287
+ <div class="page__actions">
288
+ <button onclick={() => (editing = null)}>New</button>
289
+ <button disabled={selected.length === 0} onclick={removeSelected}>
290
+ Delete {selected.length > 0 ? '(' + selected.length + ')' : ''}
291
+ </button>
292
+ </div>
293
+ </header>
294
+
295
+ {#if state.error}<p class="page__error" role="alert">{state.error}</p>{/if}
296
+
297
+ <SvGrid
298
+ data={state.rows}
299
+ {columns}
300
+ loading={state.loading}
301
+ loadingOverlay
302
+ fitColumns
303
+ enableRowSummaries={false}
304
+ showRowSelection
305
+ enableCellSelection
306
+ sortable
307
+ externalSort
308
+ onSortingChange={(s) => controller.setSort(s)}
309
+ filterable
310
+ showGlobalFilter
311
+ externalFilter
312
+ onFiltersChange={(f) =>
313
+ controller.setFilter({
314
+ global: f.global || undefined,
315
+ columns: Object.fromEntries(
316
+ f.columns.map((c) => [c.id, { operator: c.operator, value: c.value, valueTo: c.valueTo, selectedValues: c.selectedValues }]),
317
+ ),
318
+ })}
319
+ onRowClick={(e) => (editing = e.row)}
320
+ onRowSelectionChange={(_sel, rows) => (selected = rows)}
321
+ showPagination
322
+ externalPagination
323
+ rowCount={state.total}
324
+ pageIndex={state.pageIndex}
325
+ pageSize={state.pageSize}
326
+ onPaginationChange={({ pageIndex, pageSize }) => {
327
+ if (pageSize !== state.pageSize) controller.setPageSize(pageSize)
328
+ else controller.setPage(pageIndex)
329
+ }}
330
+ emptyMessage="No ${label} yet"
331
+ containerHeight={480}
332
+ />
333
+
334
+ {#if editing !== undefined}
335
+ <SvGridEditPanel schema={${constName}} row={editing} ${hasLookups ? 'lookups={lookups} ' : ''}onSubmit={save} onCancel={() => (editing = undefined)} />
336
+ {/if}
337
+ </div>
338
+
339
+ <style>
340
+ .page { display: flex; flex-direction: column; gap: 12px; }
341
+ .page__toolbar { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
342
+ .page__title { margin: 0; font-size: 18px; font-weight: 600; }
343
+ .page__actions { display: flex; gap: 8px; }
344
+ .page__actions button { padding: 7px 12px; border: 1px solid #ccc; border-radius: 6px; background: #fff; cursor: pointer; }
345
+ .page__error { margin: 0; padding: 8px 12px; color: #b3261e; background: #fdecea; border-radius: 6px; }
346
+ </style>
347
+ `,
348
+ }
349
+
350
+ return { files: [schemaFile, serverFile, pageFile] }
351
+ }
352
+
353
+ /**
354
+ * Merge freshly-scaffolded files into existing file contents, replacing only
355
+ * each file's `svgrid:managed` region and preserving everything a user wrote
356
+ * outside it. When there's no existing content (or no markers in it), the new
357
+ * file is used whole. This is what makes regeneration safe.
358
+ */
359
+ export function mergeManaged(existing: string | null | undefined, generated: string): string {
360
+ if (!existing) return generated
361
+
362
+ const startIdx = existing.indexOf(MANAGED_START)
363
+ const endIdx = existing.indexOf(MANAGED_END)
364
+ if (startIdx === -1 || endIdx === -1 || endIdx < startIdx) return generated
365
+
366
+ const genStart = generated.indexOf(MANAGED_START)
367
+ const genEnd = generated.indexOf(MANAGED_END)
368
+ if (genStart === -1 || genEnd === -1) return generated
369
+ const newRegion = generated.slice(genStart, genEnd + MANAGED_END.length)
370
+
371
+ return (
372
+ existing.slice(0, startIdx) + newRegion + existing.slice(endIdx + MANAGED_END.length)
373
+ )
374
+ }
@@ -0,0 +1,172 @@
1
+ /**
2
+ * The Studio look-and-feel themes: faithful reproductions of SvGrid's built-in
3
+ * design-system presets (shadcn/ui, Tailwind, Material 3, Excel, Fluent 2, ...),
4
+ * the same tokens that ship in `examples/src/themes/*.css` and the website Theme
5
+ * Builder. Each theme carries an explicit light + dark palette; picking one (and
6
+ * a light/dark mode) restyles the WHOLE generated app - shell, grids, charts,
7
+ * KPIs, forms - not just the accent.
8
+ *
9
+ * Pure + node-safe (studio subtree): plain data + helpers, so the same tokens
10
+ * drive the browser preview and the emitted `+layout.svelte` / `app.css`.
11
+ */
12
+ import type { ProjectTheme } from './project.js'
13
+
14
+ export type ThemeMode = 'light' | 'dark'
15
+
16
+ /** The core palette for one mode. Mirrors the Theme Builder's preset shape. */
17
+ export type ThemePalette = {
18
+ bg: string; fg: string; muted: string; border: string
19
+ headerBg: string; headerFg: string; accent: string
20
+ rowAlt: string; rowHover: string; selectionBg: string
21
+ }
22
+
23
+ export type StudioTheme = {
24
+ /** Stable id, stored on `ProjectTheme.preset`. */
25
+ id: string
26
+ /** Display name shown on the swatch. */
27
+ name: string
28
+ /** Control corner rounding (px). */
29
+ radius: number
30
+ /** Font stack applied to the app surface. */
31
+ font: string
32
+ light: ThemePalette
33
+ dark: ThemePalette
34
+ }
35
+
36
+ /** The built-in Studio themes, in gallery order (default first). */
37
+ export const studioThemes: StudioTheme[] = [
38
+ { id: 'shadcn', name: 'shadcn/ui', radius: 6, font: 'ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif',
39
+ light: { bg: '#ffffff', fg: '#09090b', muted: '#71717a', border: '#e4e4e7', headerBg: '#fafafa', headerFg: '#71717a', accent: '#18181b', rowAlt: '#ffffff', rowHover: '#f4f4f5', selectionBg: '#f4f4f5' },
40
+ dark: { bg: '#0a0a0a', fg: '#fafafa', muted: '#a1a1aa', border: '#27272a', headerBg: '#18181b', headerFg: '#a1a1aa', accent: '#fafafa', rowAlt: '#0a0a0a', rowHover: '#18181b', selectionBg: '#27272a' } },
41
+ { id: 'tailwind', name: 'Tailwind', radius: 6, font: 'Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif',
42
+ light: { bg: '#ffffff', fg: '#0f172a', muted: '#64748b', border: '#e2e8f0', headerBg: '#f8fafc', headerFg: '#0f172a', accent: '#4f46e5', rowAlt: '#ffffff', rowHover: '#f1f5f9', selectionBg: '#e0e7ff' },
43
+ dark: { bg: '#0f172a', fg: '#f8fafc', muted: '#94a3b8', border: '#334155', headerBg: '#1e293b', headerFg: '#f8fafc', accent: '#818cf8', rowAlt: '#0f172a', rowHover: '#1e293b', selectionBg: '#312e81' } },
44
+ { id: 'material', name: 'Material 3', radius: 8, font: '"Roboto Flex", Roboto, "Segoe UI", system-ui, sans-serif',
45
+ light: { bg: '#fef7ff', fg: '#1d1b20', muted: '#49454f', border: '#cac4d0', headerBg: '#f3edf7', headerFg: '#1d1b20', accent: '#6750a4', rowAlt: '#fffbfe', rowHover: '#ece6f0', selectionBg: '#e8def8' },
46
+ dark: { bg: '#141218', fg: '#e6e0e9', muted: '#cac4d0', border: '#49454f', headerBg: '#211f26', headerFg: '#e6e0e9', accent: '#d0bcff', rowAlt: '#141218', rowHover: '#2b2930', selectionBg: '#4a4458' } },
47
+ { id: 'excel', name: 'Excel', radius: 0, font: '"Aptos", "Segoe UI", Calibri, Arial, sans-serif',
48
+ light: { bg: '#ffffff', fg: '#323130', muted: '#605e5c', border: '#d2d0ce', headerBg: '#f3f2f1', headerFg: '#444444', accent: '#107c41', rowAlt: '#ffffff', rowHover: '#edebe9', selectionBg: 'rgba(16, 124, 65, 0.10)' },
49
+ dark: { bg: '#1b1a19', fg: '#f3f2f1', muted: '#c8c6c4', border: '#3b3a39', headerBg: '#252423', headerFg: '#c8c6c4', accent: '#58c188', rowAlt: '#1b1a19', rowHover: '#323130', selectionBg: 'rgba(88, 193, 136, 0.16)' } },
50
+ { id: 'fluent', name: 'Fluent 2', radius: 4, font: '"Segoe UI Variable", "Segoe UI", system-ui, sans-serif',
51
+ light: { bg: '#ffffff', fg: '#242424', muted: '#616161', border: '#e0e0e0', headerBg: '#f5f5f5', headerFg: '#242424', accent: '#0f6cbd', rowAlt: '#fafafa', rowHover: '#f0f0f0', selectionBg: '#ebebeb' },
52
+ dark: { bg: '#1f1f1f', fg: '#ffffff', muted: '#d6d6d6', border: '#666666', headerBg: '#141414', headerFg: '#ffffff', accent: '#479ef5', rowAlt: '#1a1a1a', rowHover: '#3d3d3d', selectionBg: '#383838' } },
53
+ { id: 'carbon', name: 'Carbon', radius: 0, font: '"IBM Plex Sans", "Helvetica Neue", Arial, sans-serif',
54
+ light: { bg: '#ffffff', fg: '#161616', muted: '#525252', border: '#e0e0e0', headerBg: '#e0e0e0', headerFg: '#161616', accent: '#0f62fe', rowAlt: '#f4f4f4', rowHover: '#e8e8e8', selectionBg: '#d0e2ff' },
55
+ dark: { bg: '#161616', fg: '#f4f4f4', muted: '#c6c6c6', border: '#393939', headerBg: '#393939', headerFg: '#f4f4f4', accent: '#78a9ff', rowAlt: '#262626', rowHover: '#333333', selectionBg: '#393939' } },
56
+ { id: 'sap', name: 'SAP Fiori', radius: 6, font: '"72", "72full", "Arial", sans-serif',
57
+ light: { bg: '#f5f6f7', fg: '#131e29', muted: '#556b82', border: '#e5e5e5', headerBg: '#ffffff', headerFg: '#131e29', accent: '#0070f2', rowAlt: '#f5f6f7', rowHover: '#eaecee', selectionBg: '#ebf8ff' },
58
+ dark: { bg: '#12171c', fg: '#f5f6f7', muted: '#8396a8', border: '#2e3742', headerBg: '#1d232a', headerFg: '#f5f6f7', accent: '#4db1ff', rowAlt: '#12171c', rowHover: '#222b35', selectionBg: '#1d2d3e' } },
59
+ { id: 'salesforce', name: 'Salesforce', radius: 4, font: '"Salesforce Sans", "SF Pro", Helvetica, Arial, sans-serif',
60
+ light: { bg: '#ffffff', fg: '#181818', muted: '#757575', border: '#c9c9c9', headerBg: '#f3f3f3', headerFg: '#181818', accent: '#0176d3', rowAlt: '#ffffff', rowHover: '#f3f3f3', selectionBg: '#ecebea' },
61
+ dark: { bg: '#181818', fg: '#ffffff', muted: '#c9c9c9', border: '#3e3e3c', headerBg: '#2e2e2e', headerFg: '#ffffff', accent: '#1b96ff', rowAlt: '#181818', rowHover: '#2e2e2e', selectionBg: '#1e4d8e' } },
62
+ { id: 'atlassian', name: 'Atlassian', radius: 3, font: '"Charlie Display", "Atlassian Sans", -apple-system, "Segoe UI", sans-serif',
63
+ light: { bg: '#ffffff', fg: '#172b4d', muted: '#44546f', border: '#dcdfe4', headerBg: '#fafbfc', headerFg: '#44546f', accent: '#0c66e4', rowAlt: '#ffffff', rowHover: '#f1f2f4', selectionBg: '#e9f2ff' },
64
+ dark: { bg: '#1d2125', fg: '#b6c2cf', muted: '#8590a2', border: '#38414a', headerBg: '#22272b', headerFg: '#8590a2', accent: '#579dff', rowAlt: '#1d2125', rowHover: '#2c333a', selectionBg: '#09326c' } },
65
+ { id: 'github', name: 'GitHub', radius: 6, font: '-apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", Helvetica, Arial, sans-serif',
66
+ light: { bg: '#ffffff', fg: '#1f2328', muted: '#59636e', border: '#d1d9e0', headerBg: '#f6f8fa', headerFg: '#59636e', accent: '#0969da', rowAlt: '#ffffff', rowHover: '#f6f8fa', selectionBg: '#ddf4ff' },
67
+ dark: { bg: '#0d1117', fg: '#f0f6fc', muted: '#9198a1', border: '#3d444d', headerBg: '#151b23', headerFg: '#9198a1', accent: '#4493f8', rowAlt: '#0d1117', rowHover: '#151b23', selectionBg: 'rgba(56, 139, 253, 0.10)' } },
68
+ { id: 'antd', name: 'Ant Design', radius: 6, font: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "PingFang SC", sans-serif',
69
+ light: { bg: '#ffffff', fg: 'rgba(0, 0, 0, 0.88)', muted: 'rgba(0, 0, 0, 0.65)', border: '#d9d9d9', headerBg: '#fafafa', headerFg: 'rgba(0, 0, 0, 0.88)', accent: '#1677ff', rowAlt: '#ffffff', rowHover: '#f5f5f5', selectionBg: '#e6f4ff' },
70
+ dark: { bg: '#141414', fg: 'rgba(255, 255, 255, 0.85)', muted: 'rgba(255, 255, 255, 0.65)', border: '#424242', headerBg: '#1f1f1f', headerFg: 'rgba(255, 255, 255, 0.85)', accent: '#177ddc', rowAlt: '#141414', rowHover: '#262626', selectionBg: '#111a2c' } },
71
+ { id: 'ag-alpine', name: 'Alpine', radius: 3, font: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
72
+ light: { bg: '#ffffff', fg: '#181d1f', muted: 'rgba(24, 29, 31, 0.5)', border: '#babfc7', headerBg: '#f8f8f8', headerFg: '#181d1f', accent: '#2196f3', rowAlt: '#fcfcfc', rowHover: 'rgba(33, 150, 243, 0.1)', selectionBg: 'rgba(33, 150, 243, 0.3)' },
73
+ dark: { bg: '#181d1f', fg: '#ffffff', muted: 'rgba(255, 255, 255, 0.5)', border: '#68686e', headerBg: '#222628', headerFg: '#ffffff', accent: '#2196f3', rowAlt: '#222628', rowHover: 'rgba(33, 150, 243, 0.1)', selectionBg: 'rgba(33, 150, 243, 0.3)' } },
74
+ { id: 'bootstrap', name: 'Bootstrap 5', radius: 6, font: 'system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif',
75
+ light: { bg: '#ffffff', fg: '#212529', muted: '#6c757d', border: '#dee2e6', headerBg: '#f8f9fa', headerFg: '#212529', accent: '#0d6efd', rowAlt: '#fcfcfd', rowHover: '#f2f2f2', selectionBg: '#cfe2ff' },
76
+ dark: { bg: '#212529', fg: '#dee2e6', muted: '#adb5bd', border: '#495057', headerBg: '#2b3035', headerFg: '#dee2e6', accent: '#0d6efd', rowAlt: '#25292e', rowHover: '#2c3034', selectionBg: '#031633' } },
77
+ { id: 'vercel', name: 'Vercel', radius: 6, font: '"Geist", "Geist Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
78
+ light: { bg: '#ffffff', fg: '#000000', muted: '#666666', border: '#eaeaea', headerBg: '#fafafa', headerFg: '#666666', accent: '#0070f3', rowAlt: '#fafafa', rowHover: '#f5f5f5', selectionBg: '#e8f0fe' },
79
+ dark: { bg: '#000000', fg: '#ffffff', muted: '#888888', border: '#333333', headerBg: '#111111', headerFg: '#888888', accent: '#3291ff', rowAlt: '#0a0a0a', rowHover: '#1a1a1a', selectionBg: '#0d2847' } },
80
+ { id: 'linear', name: 'Linear', radius: 6, font: '"Inter Variable", "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
81
+ light: { bg: '#ffffff', fg: '#282a30', muted: '#6b6f76', border: '#e9e9eb', headerBg: '#f7f8f8', headerFg: '#6b6f76', accent: '#5e6ad2', rowAlt: '#fbfbfc', rowHover: '#f4f4f5', selectionBg: '#e8eafd' },
82
+ dark: { bg: '#08090a', fg: '#f7f8f8', muted: '#8a8f98', border: '#23252a', headerBg: '#0f1011', headerFg: '#8a8f98', accent: '#5e6ad2', rowAlt: '#0f1011', rowHover: '#1a1b1e', selectionBg: '#2a2d4a' } },
83
+ { id: 'notion', name: 'Notion', radius: 4, font: 'ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif',
84
+ light: { bg: '#ffffff', fg: '#37352f', muted: '#787774', border: '#e9e9e7', headerBg: '#f7f6f3', headerFg: '#787774', accent: '#2383e2', rowAlt: '#fbfbfa', rowHover: '#f1f1ef', selectionBg: '#d9eaf7' },
85
+ dark: { bg: '#191919', fg: '#d4d4d4', muted: '#9b9b9b', border: '#2f2f2f', headerBg: '#202020', headerFg: '#9b9b9b', accent: '#529cca', rowAlt: '#1c1c1c', rowHover: '#252525', selectionBg: '#20415c' } },
86
+ { id: 'nord', name: 'Nord', radius: 4, font: '"Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
87
+ light: { bg: '#eceff4', fg: '#2e3440', muted: '#4c566a', border: '#d8dee9', headerBg: '#e5e9f0', headerFg: '#2e3440', accent: '#5e81ac', rowAlt: '#e5e9f0', rowHover: '#dde3ec', selectionBg: '#d8dee9' },
88
+ dark: { bg: '#2e3440', fg: '#d8dee9', muted: '#4c566a', border: '#434c5e', headerBg: '#3b4252', headerFg: '#e5e9f0', accent: '#88c0d0', rowAlt: '#333b48', rowHover: '#3b4252', selectionBg: '#434c5e' } },
89
+ { id: 'dracula', name: 'Dracula', radius: 6, font: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
90
+ light: { bg: '#fffbeb', fg: '#1f1f1f', muted: '#6c664b', border: '#dedccf', headerBg: '#ece9df', headerFg: '#1f1f1f', accent: '#644ac9', rowAlt: '#fffdf5', rowHover: '#f2efe3', selectionBg: '#cfcfde' },
91
+ dark: { bg: '#282a36', fg: '#f8f8f2', muted: '#6272a4', border: '#44475a', headerBg: '#21222c', headerFg: '#f8f8f2', accent: '#bd93f9', rowAlt: '#2d2f3b', rowHover: '#343746', selectionBg: '#44475a' } },
92
+ { id: 'catppuccin', name: 'Catppuccin', radius: 8, font: '"Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
93
+ light: { bg: '#eff1f5', fg: '#4c4f69', muted: '#6c6f85', border: '#ccd0da', headerBg: '#e6e9ef', headerFg: '#4c4f69', accent: '#8839ef', rowAlt: '#e6e9ef', rowHover: '#dce0e8', selectionBg: '#dce0e8' },
94
+ dark: { bg: '#1e1e2e', fg: '#cdd6f4', muted: '#a6adc8', border: '#313244', headerBg: '#181825', headerFg: '#cdd6f4', accent: '#cba6f7', rowAlt: '#1a1a28', rowHover: '#313244', selectionBg: '#45475a' } },
95
+ ]
96
+
97
+ /** The default theme (used when a project has no `preset`). */
98
+ export const defaultStudioTheme: StudioTheme = studioThemes.find((t) => t.id === 'tailwind') ?? studioThemes[0]!
99
+
100
+ /** Look up a theme by id. */
101
+ export const getStudioTheme = (id?: string): StudioTheme | undefined =>
102
+ id ? studioThemes.find((t) => t.id === id) : undefined
103
+
104
+ /** A readable (near-black / white) foreground for text placed on the accent. */
105
+ function onAccent(accent: string): string {
106
+ const m = /^#?([0-9a-f]{6})$/i.exec(accent.trim())
107
+ if (!m) return '#ffffff'
108
+ const n = parseInt(m[1]!, 16)
109
+ const r = (n >> 16) & 255, g = (n >> 8) & 255, b = n & 255
110
+ // Perceived luminance (sRGB, quick approximation).
111
+ const lum = (0.299 * r + 0.587 * g + 0.114 * b) / 255
112
+ return lum > 0.6 ? '#18181b' : '#ffffff'
113
+ }
114
+
115
+ /** Resolve the effective `--sg-*` tokens for a project theme: the chosen preset
116
+ * (default if none) in the chosen mode, with the accent override applied. */
117
+ export function resolveThemeTokens(theme?: ProjectTheme): Record<string, string> {
118
+ const preset = getStudioTheme(theme?.preset) ?? defaultStudioTheme
119
+ const mode: ThemeMode = theme?.mode === 'dark' ? 'dark' : 'light'
120
+ const p = preset[mode]
121
+ const accent = theme?.accent || p.accent
122
+ return {
123
+ '--sg-bg': p.bg,
124
+ '--sg-fg': p.fg,
125
+ '--sg-muted': p.muted,
126
+ '--sg-border': p.border,
127
+ '--sg-header-bg': p.headerBg,
128
+ '--sg-header-fg': p.headerFg,
129
+ // A subtle inset surface (used by e.g. the master-detail region behind the
130
+ // nested grid). Themed so it isn't a fixed light grey on dark presets.
131
+ '--sg-bg-subtle': p.headerBg,
132
+ '--sg-row-alt-bg': p.rowAlt,
133
+ '--sg-row-hover-bg': p.rowHover,
134
+ '--sg-selection-bg': p.selectionBg,
135
+ // Inputs/checkboxes: pin to the theme's own surface so they never borrow the
136
+ // host page's input tokens (which is how a themed grid ended up with dark
137
+ // checkboxes on a light page when the host mode differed).
138
+ '--sg-input-bg': p.bg,
139
+ '--sg-input-border': p.border,
140
+ '--sg-accent': accent,
141
+ '--sg-on-accent': onAccent(accent),
142
+ '--sg-radius': `${preset.radius}px`,
143
+ '--sg-font': preset.font,
144
+ // The grid's custom scrollbar is a shadow-DOM web component that reads its
145
+ // OWN --sg-scrollbar-* tokens (with fixed light-mode fallbacks). Without
146
+ // these it ignores the theme entirely - a light scrollbar left on a dark
147
+ // grid. CSS custom properties pierce shadow DOM, so defining them here (on
148
+ // the themed container) reaches the scrollbar. Derive them from the palette
149
+ // so the scrollbar tracks the preset + light/dark mode like everything else.
150
+ '--sg-scrollbar-bg': p.headerBg,
151
+ '--sg-scrollbar-border': p.border,
152
+ '--sg-scrollbar-thumb': p.muted,
153
+ '--sg-scrollbar-thumb-hover': p.fg,
154
+ '--sg-scrollbar-thumb-active': p.fg,
155
+ '--sg-scrollbar-arrow': p.muted,
156
+ '--sg-scrollbar-arrow-hover': p.fg,
157
+ '--sg-scrollbar-arrow-hover-bg': p.rowHover,
158
+ '--sg-scrollbar-arrow-active': p.fg,
159
+ '--sg-scrollbar-arrow-active-bg': p.selectionBg,
160
+ '--sg-scrollbar-arrow-disabled': p.border,
161
+ }
162
+ }
163
+
164
+ /** True when the project's theme is rendering in dark mode. */
165
+ export const isDarkTheme = (theme?: ProjectTheme): boolean => theme?.mode === 'dark'
166
+
167
+ /** An inline `style` string (`--sg-x: y; ...; color-scheme; font-family`) for previews. */
168
+ export function themeStyleString(theme?: ProjectTheme): string {
169
+ const tokens = resolveThemeTokens(theme)
170
+ const vars = Object.entries(tokens).map(([k, v]) => `${k}: ${v}`).join('; ')
171
+ return `${vars}; color-scheme: ${isDarkTheme(theme) ? 'dark' : 'light'}; font-family: ${tokens['--sg-font']}`
172
+ }
@@ -0,0 +1,47 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import type { EntitySchema } from '../schema'
3
+ import { scaffold } from './scaffold'
4
+ import { summarizeVerify, verifyScaffold } from './verify'
5
+
6
+ const schema: EntitySchema = {
7
+ name: 'customers',
8
+ idField: 'id',
9
+ fields: [
10
+ { field: 'id', type: 'text', primaryKey: true },
11
+ { field: 'name', type: 'text' },
12
+ ],
13
+ }
14
+
15
+ describe('verifyScaffold', () => {
16
+ it('reports ok when the scaffolded Svelte page compiles', async () => {
17
+ const { files } = scaffold(schema)
18
+ const res = await verifyScaffold(files)
19
+ expect(res.ok).toBe(true)
20
+ expect(res.checked).toBeGreaterThan(0) // the .svelte page was compiled for real
21
+ expect(res.issues.filter((i) => i.severity === 'error')).toHaveLength(0)
22
+ })
23
+
24
+ it('flags a malformed Svelte file with an error', async () => {
25
+ const res = await verifyScaffold([
26
+ { path: 'Bad.svelte', description: '', contents: '{#if true}<p>oops' },
27
+ ])
28
+ expect(res.ok).toBe(false)
29
+ expect(res.issues.some((i) => i.severity === 'error')).toBe(true)
30
+ })
31
+
32
+ it('skips non-svelte files (they need the project tsc)', async () => {
33
+ const res = await verifyScaffold([{ path: 'x.ts', description: '', contents: 'export const a = 1' }])
34
+ expect(res.skipped).toBe(1)
35
+ expect(res.checked).toBe(0)
36
+ })
37
+ })
38
+
39
+ describe('summarizeVerify', () => {
40
+ it('summarizes ok, failure, and skipped', () => {
41
+ expect(summarizeVerify({ ok: true, issues: [], checked: 2, skipped: 1 })).toMatch(/verified: 2/)
42
+ expect(
43
+ summarizeVerify({ ok: false, checked: 1, skipped: 0, issues: [{ path: 'p.svelte', severity: 'error', message: 'boom' }] }),
44
+ ).toMatch(/VERIFICATION FAILED/)
45
+ expect(summarizeVerify({ ok: true, issues: [], checked: 0, skipped: 2 })).toMatch(/skipped/)
46
+ })
47
+ })