lecodes-sdk 2.0.10 → 2.0.12
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/dist/types/ui/UITabs.d.ts +16 -3
- package/dist/types/version.d.ts +1 -1
- package/dist/types.json +1 -1
- package/package.json +1 -1
- package/prompts/ui.md +2 -0
- package/src/compile/index.ts +1 -1
- package/src/compile/serverSplit.ts +11 -14
- package/src/compile/serverTypes.ts +41 -16
- package/src/ui/UIPager.ts +2 -0
- package/src/ui/UITabs.ts +43 -5
- package/src/version.ts +1 -1
package/package.json
CHANGED
package/prompts/ui.md
CHANGED
|
@@ -41,6 +41,8 @@ const tabs = UITabs({
|
|
|
41
41
|
Router.init(tabs) // UITabs IS a UIScreen — present it directly
|
|
42
42
|
// .select(id), .tab (getter), .onSelect(cb(id, i)) — fires on a bar tap, swipe, or select()
|
|
43
43
|
// .badge(id, value) — true = dot, number/string = count pill, false/null/0 clears
|
|
44
|
+
// Ambient, from any screen, no reference threading: UITabs.current (the visible tabs, or null) and
|
|
45
|
+
// UITabs.select("profile") — the twin of UIPager.current / UIPager.push
|
|
44
46
|
// .pager — the UIPager underneath; UIPager.push(detail) from any screen keeps the bar
|
|
45
47
|
// The bar is colored by the theme's roles (accent, textMuted, surface, border) — no keys of its
|
|
46
48
|
// own; one bar's own colors: tabs.bar.theme({ accent, surface }). Custom layout: UIPager below.
|
package/src/compile/index.ts
CHANGED
|
@@ -19,7 +19,7 @@ export {
|
|
|
19
19
|
type ServerExportKind,
|
|
20
20
|
type ServerManifest,
|
|
21
21
|
} from "./serverSplit"
|
|
22
|
-
export { deriveEndpointParams, EndpointTypeError,
|
|
22
|
+
export { deriveEndpointParams, EndpointTypeError, ServerHelperImportError, UnawaitedUserQueryError, WrongSideGlobalError, type DeriveResult } from "./serverTypes"
|
|
23
23
|
export {
|
|
24
24
|
detectEntry,
|
|
25
25
|
scanModulesFallback,
|
|
@@ -23,6 +23,12 @@ export const isServerPath = (path: string) => SERVER_FILE.test(path)
|
|
|
23
23
|
/** Project path → endpoint-id prefix: no leading `./` or `/`, forward slashes. */
|
|
24
24
|
export const serverModuleId = (path: string) => path.split("\\").join("/").replace(/^(\.\/|\/)+/, "")
|
|
25
25
|
|
|
26
|
+
/**
|
|
27
|
+
* What an export of a server module is to the app: `fn` = an endpoint — an ASYNC function, called
|
|
28
|
+
* over the network; `channel` = a channel; `other` = server-only — a model, the db, a constant, or
|
|
29
|
+
* a helper: a function that is not async is used by server files alone, and the app's import of
|
|
30
|
+
* it is refused at compile time (serverTypes.ts), never a call that silently holds a promise.
|
|
31
|
+
*/
|
|
26
32
|
export type ServerExportKind = "fn" | "channel" | "other"
|
|
27
33
|
export type ServerExport = { name: string, kind: ServerExportKind }
|
|
28
34
|
|
|
@@ -60,18 +66,9 @@ const IDENT = "[A-Za-z_$][\\w$]*"
|
|
|
60
66
|
const classifyInit = (init: string): ServerExportKind => {
|
|
61
67
|
const s = init.trimStart()
|
|
62
68
|
if (/^channel\s*[<(]/.test(s)) return "channel"
|
|
63
|
-
if (/^
|
|
69
|
+
if (/^async\s+function\b/.test(s)) return "fn"
|
|
64
70
|
if (/^async\b/.test(s)) return "fn" // async (…) => / async x =>
|
|
65
|
-
|
|
66
|
-
if (/^\(/.test(s)) {
|
|
67
|
-
// `(a, b) => …` — find the matching paren and look for `=>` (a type annotation may sit between)
|
|
68
|
-
let depth = 0
|
|
69
|
-
for (let i = 0; i < s.length; i++) {
|
|
70
|
-
if (s[i] === "(") depth++
|
|
71
|
-
else if (s[i] === ")") { depth--; if (depth === 0) return /^\s*(:[^=]*)?=>/.test(s.slice(i + 1)) ? "fn" : "other" }
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
return "other"
|
|
71
|
+
return "other" // a sync function, a value: the server's own
|
|
75
72
|
}
|
|
76
73
|
|
|
77
74
|
/** Exported names of a server module with their kinds. Default exports are rejected. */
|
|
@@ -87,8 +84,8 @@ export const scanServerExports = (source: string, path = "<server module>"): Ser
|
|
|
87
84
|
if (i >= 0) found[i] = { at: found[i].at, name, kind }
|
|
88
85
|
else found.push({ at, name, kind })
|
|
89
86
|
}
|
|
90
|
-
// export
|
|
91
|
-
for (const m of src.matchAll(new RegExp(`(^|\\n)\\s*export\\s+(async\\s+)?function\\s*\\*?\\s*(${IDENT})`, "g"))) add(m.index!, m[3], "fn")
|
|
87
|
+
// export async function name = an endpoint; export function name = a helper of the server
|
|
88
|
+
for (const m of src.matchAll(new RegExp(`(^|\\n)\\s*export\\s+(async\\s+)?function\\s*\\*?\\s*(${IDENT})`, "g"))) add(m.index!, m[3], m[2] ? "fn" : "other")
|
|
92
89
|
// export const|let|var name [: T] = init
|
|
93
90
|
for (const m of src.matchAll(new RegExp(`(^|\\n)\\s*export\\s+(?:const|let|var)\\s+(${IDENT})\\s*(?::[^=]*?)?=\\s*([^\\n]*)`, "g"))) {
|
|
94
91
|
add(m.index!, m[2], classifyInit(m[3]))
|
|
@@ -102,7 +99,7 @@ export const scanServerExports = (source: string, path = "<server module>"): Ser
|
|
|
102
99
|
if (!local || /^type\s/.test(local)) continue
|
|
103
100
|
const name = exported ?? local
|
|
104
101
|
let kind: ServerExportKind = "other"
|
|
105
|
-
if (new RegExp(`(^|\\n)\\s*
|
|
102
|
+
if (new RegExp(`(^|\\n)\\s*async\\s+function\\s*\\*?\\s*${local}\\b`).test(src)) kind = "fn"
|
|
106
103
|
else {
|
|
107
104
|
const decl = src.match(new RegExp(`(^|\\n)\\s*(?:const|let|var)\\s+${local}\\s*(?::[^=]*?)?=\\s*([^\\n]*)`))
|
|
108
105
|
if (decl) kind = classifyInit(decl[2])
|
|
@@ -47,15 +47,16 @@ export class EndpointTypeError extends Error {
|
|
|
47
47
|
}
|
|
48
48
|
|
|
49
49
|
/**
|
|
50
|
-
* In the app an endpoint's call is always a promise: it goes over the network.
|
|
51
|
-
* not `async` is
|
|
52
|
-
*
|
|
50
|
+
* In the app an endpoint's call is always a promise: it goes over the network. A function of a
|
|
51
|
+
* server file that is not `async` is therefore not an endpoint but a helper, the server's own
|
|
52
|
+
* (serverSplit.ts): imported by the app it would keep the type it was written with (`number`), so
|
|
53
|
+
* `const n = add(1, 2)` would type-check and throw. The import is refused instead.
|
|
53
54
|
*/
|
|
54
|
-
export class
|
|
55
|
+
export class ServerHelperImportError extends Error {
|
|
55
56
|
readonly problems: string[]
|
|
56
57
|
constructor(problems: string[]) {
|
|
57
|
-
super(`
|
|
58
|
-
this.name = "
|
|
58
|
+
super(`a function of a server file that is not async is a helper of the server, for server files only — the app calls endpoints, which are \`export async function\`:\n ${problems.join("\n ")}`)
|
|
59
|
+
this.name = "ServerHelperImportError"
|
|
59
60
|
this.problems = problems
|
|
60
61
|
}
|
|
61
62
|
}
|
|
@@ -135,6 +136,38 @@ export type DeriveResult = {
|
|
|
135
136
|
warnings: string[]
|
|
136
137
|
}
|
|
137
138
|
|
|
139
|
+
/**
|
|
140
|
+
* The app's imports of a server module's helpers: a named import, from a `*.server.ts` file, of an
|
|
141
|
+
* export the scan classed `other` that is a function. The checker resolves the import to its
|
|
142
|
+
* declaration, so the module is the one the import names, however it was spelled.
|
|
143
|
+
*/
|
|
144
|
+
const helperImports = (ts: TS, program: import("typescript").Program, checker: import("typescript").TypeChecker, modules: Record<string, ServerExport[]>, projectFiles: Set<string>): string[] => {
|
|
145
|
+
const kinds = new Map(Object.entries(modules).map(([path, exports]) => [normalizePath(path), new Map(exports.map(e => [e.name, e.kind]))]))
|
|
146
|
+
const out: string[] = []
|
|
147
|
+
for (const sf of program.getSourceFiles()) {
|
|
148
|
+
if (!projectFiles.has(sf.fileName) || sf.isDeclarationFile || isServerPath(sf.fileName)) continue
|
|
149
|
+
const file = serverModuleId(sf.fileName)
|
|
150
|
+
for (const statement of sf.statements) {
|
|
151
|
+
if (!ts.isImportDeclaration(statement) || !statement.importClause?.namedBindings || !ts.isNamedImports(statement.importClause.namedBindings)) continue
|
|
152
|
+
if (statement.importClause.isTypeOnly) continue
|
|
153
|
+
for (const spec of statement.importClause.namedBindings.elements) {
|
|
154
|
+
if (spec.isTypeOnly) continue
|
|
155
|
+
let symbol = checker.getSymbolAtLocation(spec.name)
|
|
156
|
+
if (symbol && symbol.flags & ts.SymbolFlags.Alias) symbol = checker.getAliasedSymbol(symbol)
|
|
157
|
+
const decl = symbol?.valueDeclaration ?? symbol?.declarations?.[0]
|
|
158
|
+
if (!symbol || !decl) continue
|
|
159
|
+
const target = normalizePath(decl.getSourceFile().fileName)
|
|
160
|
+
const name = (spec.propertyName ?? spec.name).text
|
|
161
|
+
if (kinds.get(target)?.get(name) !== "other") continue
|
|
162
|
+
if (checker.getTypeOfSymbolAtLocation(symbol, decl).getCallSignatures().length === 0) continue
|
|
163
|
+
const { line } = sf.getLineAndCharacterOfPosition(spec.getStart(sf))
|
|
164
|
+
out.push(`${file}:${line + 1}: ${name} of ${serverModuleId(target)}`)
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return out
|
|
169
|
+
}
|
|
170
|
+
|
|
138
171
|
/**
|
|
139
172
|
* Derive `{ "<module>#<fn>": ParamSchema[] }` for every function export of every `*.server.ts` entry.
|
|
140
173
|
* Throws `EndpointTypeError` when a parameter type can't be validated at runtime.
|
|
@@ -196,11 +229,6 @@ export const deriveEndpointParams = async (entries: CompileEntry[], modules: Rec
|
|
|
196
229
|
const params: Record<string, ParamSchema[]> = {}
|
|
197
230
|
const warnings: string[] = []
|
|
198
231
|
const problems: string[] = []
|
|
199
|
-
const sync: string[] = []
|
|
200
|
-
// a promise, or anything awaited the same way (a query handed back as it is); `any` is not judged
|
|
201
|
-
const awaitable = (type: import("typescript").Type): boolean =>
|
|
202
|
-
!!(type.flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown))
|
|
203
|
-
|| (type.flags & ts.TypeFlags.Union ? (type as import("typescript").UnionType).types.every(awaitable) : !!type.getProperty("then"))
|
|
204
232
|
|
|
205
233
|
for (const [path, exports] of Object.entries(modules)) {
|
|
206
234
|
const sf = program.getSourceFile(normalizePath(path))
|
|
@@ -229,10 +257,6 @@ export const deriveEndpointParams = async (entries: CompileEntry[], modules: Rec
|
|
|
229
257
|
}
|
|
230
258
|
const sig = type.getCallSignatures()[0]
|
|
231
259
|
if (!sig) { warnings.push(`${where}: not callable — unvalidated`); continue }
|
|
232
|
-
if (ex.kind === "fn" && !awaitable(checker.getReturnTypeOfSignature(sig))) {
|
|
233
|
-
const { line } = sf.getLineAndCharacterOfPosition(decl.getStart(sf))
|
|
234
|
-
sync.push(`${serverModuleId(path)}:${line + 1}: ${ex.name} — write \`async\` before it`)
|
|
235
|
-
}
|
|
236
260
|
const list: ParamSchema[] = []
|
|
237
261
|
for (const p of sig.getParameters()) {
|
|
238
262
|
const pd = p.valueDeclaration as import("typescript").ParameterDeclaration | undefined
|
|
@@ -257,9 +281,10 @@ export const deriveEndpointParams = async (entries: CompileEntry[], modules: Rec
|
|
|
257
281
|
}
|
|
258
282
|
}
|
|
259
283
|
if (problems.length) throw new EndpointTypeError(problems)
|
|
260
|
-
if (sync.length) throw new SyncEndpointError(sync)
|
|
261
284
|
const wrongSide = wrongSideGlobals(ts, program, checker, new Set(files.keys()))
|
|
262
285
|
if (wrongSide.length) throw new WrongSideGlobalError(wrongSide)
|
|
286
|
+
const helpers = helperImports(ts, program, checker, modules, new Set(files.keys()))
|
|
287
|
+
if (helpers.length) throw new ServerHelperImportError(helpers)
|
|
263
288
|
const unawaited: string[] = []
|
|
264
289
|
for (const path of Object.keys(modules)) {
|
|
265
290
|
const sf = program.getSourceFile(normalizePath(path))
|
package/src/ui/UIPager.ts
CHANGED
|
@@ -31,6 +31,8 @@ export type UIPagerStyle = ElementStyle & DrawableStyle
|
|
|
31
31
|
/** The pagers whose screen is currently visible, outermost → innermost. `UIPager.current` is the
|
|
32
32
|
* last (innermost). Maintained by each owned screen's onOpen/onClose via _activate/_deactivate. */
|
|
33
33
|
const _activeStack: PagerElement[] = []
|
|
34
|
+
/** @internal The active pagers, outermost → innermost — what `UITabs.current` searches. */
|
|
35
|
+
export const _activePagers = (): readonly PagerElement[] => _activeStack
|
|
34
36
|
|
|
35
37
|
/**
|
|
36
38
|
* The screen-navigation element: sibling **tabs** that swipe natively side to side, each with its
|
package/src/ui/UITabs.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { PagerElement, type UIPager } from "./UIPager"
|
|
1
|
+
import { PagerElement, _activePagers, type UIPager } from "./UIPager"
|
|
2
2
|
import { ScreenElement, UIScreen } from "./UIScreen"
|
|
3
3
|
import { UIButton } from "./UIButton"
|
|
4
4
|
import { UIRow, UIColumn, type UIRow as UIRowType } from "./UIContainer"
|
|
@@ -63,6 +63,9 @@ export interface UITabs extends UIScreen {
|
|
|
63
63
|
readonly bar: UIRowType,
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
+
/** The tabs by the pager each owns — what `UITabs.current` resolves through the active pagers. */
|
|
67
|
+
const _byPager = new WeakMap<PagerElement, TabsElement>()
|
|
68
|
+
|
|
66
69
|
export class TabsElement extends ScreenElement {
|
|
67
70
|
private readonly ids: string[]
|
|
68
71
|
private readonly _pagerEl: PagerElement
|
|
@@ -80,6 +83,7 @@ export class TabsElement extends ScreenElement {
|
|
|
80
83
|
if (this.ids.length === 0) console.warn("UITabs: no tabs declared")
|
|
81
84
|
|
|
82
85
|
this._pagerEl = new PagerElement({ flexGrow: 1 }, this.ids.map((id) => defs[id].screen))
|
|
86
|
+
_byPager.set(this._pagerEl, this)
|
|
83
87
|
|
|
84
88
|
const buttons = this.ids.map((id, i) => {
|
|
85
89
|
const def = defs[id]
|
|
@@ -173,10 +177,44 @@ export class TabsElement extends ScreenElement {
|
|
|
173
177
|
}
|
|
174
178
|
}
|
|
175
179
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
+
export interface UITabsConstructor {
|
|
181
|
+
/** Create the standard bottom-tab shell — see {@link UITabs}. Keys are tab ids in tab order;
|
|
182
|
+
* each tab needs a `label` and its root `screen` (plus an optional `icon`). */
|
|
183
|
+
(tabs: Record<string, UITabDef>): UITabs
|
|
184
|
+
/** The tabs whose page is currently visible, or `null` when the visible screen is not a tab
|
|
185
|
+
* of any — the twin of `UIPager.current`, so a screen switches tabs with no reference
|
|
186
|
+
* threading: `UITabs.current?.select("services")`. With tabs nested inside a tab's page,
|
|
187
|
+
* resolves to the innermost. */
|
|
188
|
+
readonly current: UITabs | null
|
|
189
|
+
/** `UITabs.current.select(id)` — switch the visible tabs to `id`; a console warning and no-op
|
|
190
|
+
* when no tabs are on screen. */
|
|
191
|
+
select(id: string, animated?: boolean): void
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const tabsFactory = (tabs: Record<string, UITabDef>): UITabs => new TabsElement(tabs) as unknown as UITabs
|
|
195
|
+
|
|
196
|
+
/** Create the standard bottom-tab shell — see {@link UITabs}; `UITabs.current` / `UITabs.select(id)`
|
|
197
|
+
* reach the visible tabs from anywhere. */
|
|
198
|
+
export const UITabs = tabsFactory as unknown as UITabsConstructor
|
|
199
|
+
|
|
200
|
+
const _resolveCurrent = (): TabsElement | null => {
|
|
201
|
+
const pagers = _activePagers()
|
|
202
|
+
for (let i = pagers.length - 1; i >= 0; i--) {
|
|
203
|
+
const tabs = _byPager.get(pagers[i])
|
|
204
|
+
if (tabs) return tabs
|
|
205
|
+
}
|
|
206
|
+
return null
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// The ambient statics, attached the way UIPager's are (a statement that mutates a binding this
|
|
210
|
+
// module declares is kept by chisel iff `UITabs` is reached — see UIPager.ts).
|
|
211
|
+
Object.defineProperty(UITabs, "current", {
|
|
212
|
+
get: () => (_resolveCurrent() ?? null) as unknown as UITabs | null,
|
|
213
|
+
})
|
|
214
|
+
;(UITabs as any).select = (id: string, animated?: boolean) => {
|
|
215
|
+
const tabs = _resolveCurrent()
|
|
216
|
+
if (!tabs) { console.warn("UITabs.select: no tabs on the current screen"); return }
|
|
217
|
+
tabs.select(id, animated)
|
|
180
218
|
}
|
|
181
219
|
|
|
182
220
|
// ---- defineTabs — the design-stage twin ---------------------------------------------------------
|
package/src/version.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// same manifest). Its MAJOR is the bundle ↔ runtime contract: a bundle's `// sdk:` header line
|
|
5
5
|
// and a host's embedded version must agree on the major for the host to run the bundle; minor
|
|
6
6
|
// and patch never gate anything (features are detected, never versioned).
|
|
7
|
-
export const SDK_VERSION = "2.0.
|
|
7
|
+
export const SDK_VERSION = "2.0.12"
|
|
8
8
|
|
|
9
9
|
/** The major of a semver string, or null when it is not one. */
|
|
10
10
|
export const sdkMajor = (version: string | null | undefined): number | null => {
|