@tiaforge-ui/sinter 0.0.0-stage → 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.
- package/LICENSE +21 -0
- package/README.md +132 -2
- package/dist/cache.d.ts +5 -0
- package/dist/cache.js +95 -0
- package/dist/check/index.d.ts +82 -0
- package/dist/check/index.js +156 -0
- package/dist/check/openapi-types.d.ts +6 -0
- package/dist/check/openapi-types.js +84 -0
- package/dist/check/shadow.d.ts +47 -0
- package/dist/check/shadow.js +245 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +67 -0
- package/dist/compiler/check-unit.d.ts +97 -0
- package/dist/compiler/check-unit.js +3 -0
- package/dist/compiler/compile.d.ts +62 -0
- package/dist/compiler/compile.js +1133 -0
- package/dist/compiler/diagnostic-docs.d.ts +10 -0
- package/dist/compiler/diagnostic-docs.js +137 -0
- package/dist/compiler/diagnostics.d.ts +56 -0
- package/dist/compiler/diagnostics.js +76 -0
- package/dist/compiler/expr.d.ts +89 -0
- package/dist/compiler/expr.js +838 -0
- package/dist/compiler/index.d.ts +21 -0
- package/dist/compiler/index.js +106 -0
- package/dist/compiler/openapi.d.ts +40 -0
- package/dist/compiler/openapi.js +177 -0
- package/dist/compiler/vocabulary.d.ts +12 -0
- package/dist/compiler/vocabulary.js +38 -0
- package/dist/dev.d.ts +10 -0
- package/dist/dev.js +92 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +4 -0
- package/dist/ir.d.ts +127 -0
- package/dist/ir.js +121 -0
- package/dist/lsp/quickfix.d.ts +15 -0
- package/dist/lsp/quickfix.js +58 -0
- package/dist/lsp/rpc.d.ts +31 -0
- package/dist/lsp/rpc.js +120 -0
- package/dist/lsp/server.d.ts +36 -0
- package/dist/lsp/server.js +281 -0
- package/dist/lsp/spec-location.d.ts +9 -0
- package/dist/lsp/spec-location.js +42 -0
- package/dist/lsp/typescript.d.ts +77 -0
- package/dist/lsp/typescript.js +242 -0
- package/dist/lsp/workspace.d.ts +56 -0
- package/dist/lsp/workspace.js +227 -0
- package/dist/optimistic.d.ts +3 -0
- package/dist/optimistic.js +15 -0
- package/dist/paginate.d.ts +3 -0
- package/dist/paginate.js +184 -0
- package/dist/persist.d.ts +3 -0
- package/dist/persist.js +55 -0
- package/dist/project.d.ts +36 -0
- package/dist/project.js +100 -0
- package/dist/runtime/app.d.ts +125 -0
- package/dist/runtime/app.js +1183 -0
- package/dist/runtime/config.d.ts +53 -0
- package/dist/runtime/config.js +118 -0
- package/dist/runtime/env.d.ts +18 -0
- package/dist/runtime/env.js +23 -0
- package/dist/runtime/helpers.d.ts +9 -0
- package/dist/runtime/helpers.js +55 -0
- package/dist/runtime/request.d.ts +23 -0
- package/dist/runtime/request.js +93 -0
- package/dist/runtime/security.d.ts +12 -0
- package/dist/runtime/security.js +34 -0
- package/dist/runtime/store.d.ts +95 -0
- package/dist/runtime/store.js +142 -0
- package/dist/sse.d.ts +7 -0
- package/dist/sse.js +206 -0
- package/dist/testing/index.d.ts +71 -0
- package/dist/testing/index.js +345 -0
- package/dist/vite.d.ts +11 -0
- package/dist/vite.js +94 -0
- package/dist/ws.d.ts +18 -0
- package/dist/ws.js +57 -0
- package/docs/diagnostics.md +273 -0
- package/html-custom-data.json +234 -0
- package/llms.txt +531 -0
- package/package.json +90 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Tia Heinrich
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,133 @@
|
|
|
1
|
-
#
|
|
1
|
+
# sinter
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
**Your JSON API, bound to plain HTML.** sinter loads and caches server data, keeps it current from
|
|
4
|
+
server events, renders it through `<template>` bindings and runs mutations with optimistic updates,
|
|
5
|
+
without components, a router or a store library. A build step compiles the HTML, so the browser runs
|
|
6
|
+
no parser and no `eval`.
|
|
7
|
+
|
|
8
|
+
```html
|
|
9
|
+
<sn-resource name="contacts" src="/api/contacts" items="contacts" key="id" order="name"></sn-resource>
|
|
10
|
+
|
|
11
|
+
<sn-sync src="/api/events">
|
|
12
|
+
<sn-event name="contact_updated" resource="contacts" op="upsert" item="contact"></sn-event>
|
|
13
|
+
<sn-event name="contact_removed" resource="contacts" op="remove"></sn-event>
|
|
14
|
+
</sn-sync>
|
|
15
|
+
|
|
16
|
+
<section data-sn-state="{ q: '' }">
|
|
17
|
+
<input type="search" data-sn-model="q">
|
|
18
|
+
<ul>
|
|
19
|
+
<template data-sn-for="c in contacts.filter(c => c.name.includes(q))">
|
|
20
|
+
<li>
|
|
21
|
+
<span data-sn-text="c.name"></span>
|
|
22
|
+
<button data-sn-delete="/api/contacts/{c.id}" data-sn-optimistic="remove('contacts', c)">Delete</button>
|
|
23
|
+
</li>
|
|
24
|
+
</template>
|
|
25
|
+
</ul>
|
|
26
|
+
</section>
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
That page loads the contacts, keeps them sorted, applies updates other users make as they arrive,
|
|
30
|
+
filters as you type, and removes a contact at once when you click Delete, putting it back if the
|
|
31
|
+
server refuses.
|
|
32
|
+
|
|
33
|
+
## Why sinter
|
|
34
|
+
|
|
35
|
+
- **The server-state plumbing is gone.** You don't write loading flags, stale-response guards,
|
|
36
|
+
refetch on reconnect, list upserts from SSE or WebSocket events, or rollback of failed updates.
|
|
37
|
+
They are attributes.
|
|
38
|
+
- **Optimistic updates that stay correct**, also when requests overlap or a server event arrives
|
|
39
|
+
while a request is in flight.
|
|
40
|
+
- **Less code.** The [RealWorld](examples/realworld) app takes 735 lines in sinter. The official
|
|
41
|
+
Angular version takes 2,210. Each page downloads 12–14 KB of JS.
|
|
42
|
+
- **Errors at build time.** Every expression is type-checked against your TypeScript types or your
|
|
43
|
+
OpenAPI spec. Your editor shows the errors as you type, with hover, completion and go to
|
|
44
|
+
definition.
|
|
45
|
+
- **Safe by construction.** Directives are compiled, not read from the live DOM. Pages run under
|
|
46
|
+
`script-src 'self'` with no `unsafe-eval`, and markup injected at runtime stays inert.
|
|
47
|
+
- **Plain, valid HTML.** There is one way to do each thing, and everything is visible on the element.
|
|
48
|
+
This makes the pages easy to read and easy for an LLM to write correctly (`llms.txt` ships with the
|
|
49
|
+
package).
|
|
50
|
+
|
|
51
|
+
## Compared with
|
|
52
|
+
|
|
53
|
+
**Angular, React, Vue.** If your app mostly shows and edits API data, a SPA framework makes you build
|
|
54
|
+
components, services, a store and a router to get there. sinter keeps the page as HTML and moves the
|
|
55
|
+
data handling into attributes. The tradeoff is that sinter apps are multi-page: each navigation is a
|
|
56
|
+
page load (the shared code comes from the browser cache).
|
|
57
|
+
|
|
58
|
+
**htmx, Datastar.** These expect a server that renders HTML for every page and interaction. If you
|
|
59
|
+
have a JSON API, you would have to build and maintain that server. sinter uses the API you already
|
|
60
|
+
have, and its mutations are optimistic where htmx waits for the server's answer. If your backend
|
|
61
|
+
renders HTML anyway and you want no build step, htmx is a good choice.
|
|
62
|
+
|
|
63
|
+
**Alpine, petite-vue.** These also render JSON with client-side templates, but they leave fetching,
|
|
64
|
+
caching, live updates and rollback to you.
|
|
65
|
+
|
|
66
|
+
Measurements and sources: [`docs/realworld-comparison.md`](docs/realworld-comparison.md).
|
|
67
|
+
|
|
68
|
+
## When not to use sinter
|
|
69
|
+
|
|
70
|
+
- You want a SPA: client-side routing, transitions, a component system.
|
|
71
|
+
- The UI is mostly client-side interaction (editors, canvas, drag and drop). sinter leaves that to
|
|
72
|
+
islands, which are plain custom elements.
|
|
73
|
+
- You can't have a build step.
|
|
74
|
+
- Your backend renders HTML, not JSON.
|
|
75
|
+
- You need stability guarantees. sinter is at v0.1.
|
|
76
|
+
|
|
77
|
+
## Setup
|
|
78
|
+
|
|
79
|
+
```sh
|
|
80
|
+
npm install @tiaforge-ui/sinter
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
```js
|
|
84
|
+
// vite.config.js
|
|
85
|
+
import sinter from '@tiaforge-ui/sinter/vite';
|
|
86
|
+
export default { plugins: [sinter()] };
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Then write `data-sn-*` attributes in your HTML pages. Optional:
|
|
90
|
+
|
|
91
|
+
- `src/sinter.config.js`: `configure({ request, sessionProbe, onError, locale, origins, urlSchemes, transport })`. Auth headers, error handling, WebSocket (`@tiaforge-ui/sinter/ws`) or SignalR transports.
|
|
92
|
+
- `src/sinter.helpers.js`: functions and namespaces your expressions can call (`export * as m from './paraglide/messages.js'`).
|
|
93
|
+
- `src/sinter.types.ts`: types named by `<sn-resource type="Contact[]">`. Or use `sinter({ openapi: 'openapi.yml' })` to type resources from your API spec.
|
|
94
|
+
- `npx sinter check` type-checks every expression. `sinter({ typecheck: true })` runs it in `vite build`.
|
|
95
|
+
- `<sn-resource … cache>` shows the last page load's data at once, then revalidates. Call `clearPersisted()` on logout.
|
|
96
|
+
- Serve `<sn-sync>` streams over HTTP/2. Over HTTP/1.1, each tab's stream takes one of six connections per origin.
|
|
97
|
+
- Editors: `npx sinter lsp --stdio`. There is a VS Code extension in [`editors/vscode`](editors/vscode). For Neovim, Helix and Emacs, see [`docs/editors.md`](docs/editors.md).
|
|
98
|
+
|
|
99
|
+
## Docs
|
|
100
|
+
|
|
101
|
+
- [`llms.txt`](llms.txt): attribute reference, scope rules and recipes (each with tests).
|
|
102
|
+
- [`docs/spec.md`](docs/spec.md): the v0.1 specification.
|
|
103
|
+
- [`docs/diagnostics.md`](docs/diagnostics.md): every `SN` error code and how to fix it.
|
|
104
|
+
- [`examples/realworld`](examples/realworld): a complete multi-page app.
|
|
105
|
+
- [`docs/progress.md`](docs/progress.md): implementation status and decisions.
|
|
106
|
+
|
|
107
|
+
## Packages
|
|
108
|
+
|
|
109
|
+
| Import | Contents |
|
|
110
|
+
| --- | --- |
|
|
111
|
+
| `@tiaforge-ui/sinter` | `mount`, store, renderer, actions, request layer, instance API, `configure` |
|
|
112
|
+
| `@tiaforge-ui/sinter/sse` | `<sn-sync>`, SSE transport |
|
|
113
|
+
| `@tiaforge-ui/sinter/ws` | WebSocket transport for `<sn-sync>` |
|
|
114
|
+
| `@tiaforge-ui/sinter/optimistic` | optimistic layers |
|
|
115
|
+
| `@tiaforge-ui/sinter/paginate` | keyset, offset, append |
|
|
116
|
+
| `@tiaforge-ui/sinter/persist` | `data-sn-persist` |
|
|
117
|
+
| `@tiaforge-ui/sinter/compiler` | `compile()` |
|
|
118
|
+
| `@tiaforge-ui/sinter/vite` | Vite plugin |
|
|
119
|
+
| `@tiaforge-ui/sinter/check` | `check()` (used by `sinter check`), `checkPage()` for tests |
|
|
120
|
+
| `@tiaforge-ui/sinter/testing` | `render`, `mockRequests`, `mockStream` |
|
|
121
|
+
|
|
122
|
+
## Development
|
|
123
|
+
|
|
124
|
+
```sh
|
|
125
|
+
npm test # vitest (happy-dom)
|
|
126
|
+
npm run typecheck
|
|
127
|
+
npm run build # dist/
|
|
128
|
+
npm run size # size budget per module
|
|
129
|
+
npm run docs # regenerate llms.txt, html-custom-data.json (also the VS Code extension's) and docs/diagnostics.md
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
License: MIT. Contributions are accepted under the Developer Certificate of Origin
|
|
133
|
+
(`Signed-off-by` in each commit, `git commit -s`); no CLA.
|
package/dist/cache.d.ts
ADDED
package/dist/cache.js
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { CACHE_PREFIX } from './runtime/config.js';
|
|
2
|
+
/** entries larger than this (serialized) are not written */
|
|
3
|
+
export const MAX_ENTRY = 256 * 1024;
|
|
4
|
+
const cache = {
|
|
5
|
+
name: 'cache',
|
|
6
|
+
install(app) {
|
|
7
|
+
const tracked = new Map();
|
|
8
|
+
const keyOf = (def, url) => CACHE_PREFIX + JSON.stringify([url, def.items, def.total]);
|
|
9
|
+
/** drops the older half of the cache entries; false when there was nothing to drop */
|
|
10
|
+
const evict = (s, keep) => {
|
|
11
|
+
const entries = [];
|
|
12
|
+
for (let i = 0; i < s.length; i++) {
|
|
13
|
+
const k = s.key(i);
|
|
14
|
+
if (!k || !k.startsWith(CACHE_PREFIX) || k == keep)
|
|
15
|
+
continue;
|
|
16
|
+
let t = 0;
|
|
17
|
+
try {
|
|
18
|
+
t = JSON.parse(s.getItem(k)).t;
|
|
19
|
+
}
|
|
20
|
+
catch { /* unreadable: oldest */ }
|
|
21
|
+
entries.push([k, t]);
|
|
22
|
+
}
|
|
23
|
+
if (!entries.length)
|
|
24
|
+
return false;
|
|
25
|
+
entries.sort((a, b) => a[1] - b[1]);
|
|
26
|
+
for (const [k] of entries.slice(0, Math.ceil(entries.length / 2)))
|
|
27
|
+
s.removeItem(k);
|
|
28
|
+
return true;
|
|
29
|
+
};
|
|
30
|
+
const write = (def, url, items, total) => {
|
|
31
|
+
const s = app.env.session();
|
|
32
|
+
if (!s)
|
|
33
|
+
return;
|
|
34
|
+
let text;
|
|
35
|
+
try {
|
|
36
|
+
text = JSON.stringify({ t: app.env.now(), items, total });
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
if (text.length > MAX_ENTRY)
|
|
42
|
+
return app.warn(19, def.name, Math.round(text.length / 1024));
|
|
43
|
+
const key = keyOf(def, url);
|
|
44
|
+
for (let tries = 0; tries < 2; tries++) {
|
|
45
|
+
try {
|
|
46
|
+
s.setItem(key, text);
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
if (!evict(s, key))
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
app.ext.cache = {
|
|
56
|
+
read(def, url) {
|
|
57
|
+
try {
|
|
58
|
+
const v = app.env.session()?.getItem(keyOf(def, url));
|
|
59
|
+
if (!v)
|
|
60
|
+
return null;
|
|
61
|
+
const e = JSON.parse(v);
|
|
62
|
+
return def.cache && app.env.now() - e.t > def.cache ? null : e;
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
},
|
|
68
|
+
loaded(def, r, url, items, total, appended) {
|
|
69
|
+
let t = tracked.get(def.name);
|
|
70
|
+
if (!t) {
|
|
71
|
+
const entry = (t = { def, r, url, dirty: false, appended });
|
|
72
|
+
// confirmed changes after the load (server events, action results); layers never fire this
|
|
73
|
+
r.subscribe(() => { entry.dirty = true; });
|
|
74
|
+
tracked.set(def.name, t);
|
|
75
|
+
}
|
|
76
|
+
t.url = url;
|
|
77
|
+
t.appended = appended;
|
|
78
|
+
// `append` caches only its first page: later pages would be shown as if they were it
|
|
79
|
+
if (!appended)
|
|
80
|
+
write(def, url, items, total);
|
|
81
|
+
t.dirty = false;
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
app.addCleanup(app.env.listen('pagehide', () => {
|
|
85
|
+
for (const t of tracked.values()) {
|
|
86
|
+
if (!t.dirty || t.appended || t.def.paginate?.mode == 'append')
|
|
87
|
+
continue;
|
|
88
|
+
const b = t.r.base();
|
|
89
|
+
write(t.def, t.url, b.items, b.total);
|
|
90
|
+
t.dirty = false;
|
|
91
|
+
}
|
|
92
|
+
}));
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
export default cache;
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import type { CheckUnit } from '../compiler/check-unit.js';
|
|
2
|
+
import type { HelperManifest } from '../compiler/compile.js';
|
|
3
|
+
import type { Diagnostic } from '../compiler/diagnostics.js';
|
|
4
|
+
import { type ProjectOptions } from '../project.js';
|
|
5
|
+
import type { OpenApiSpec } from '../compiler/openapi.js';
|
|
6
|
+
import { type Region, type Shadow } from './shadow.js';
|
|
7
|
+
export interface CheckOptions {
|
|
8
|
+
root: string;
|
|
9
|
+
/** from `compile(…, { check: true })`, one per page */
|
|
10
|
+
units: CheckUnit[];
|
|
11
|
+
helpers: HelperManifest;
|
|
12
|
+
/** absolute path of the types module, or null */
|
|
13
|
+
types: string | null;
|
|
14
|
+
/** the specs the pages were compiled with (`compile(…, { openapi })`) */
|
|
15
|
+
openapi?: OpenApiSpec[];
|
|
16
|
+
/** where the shadow files go (default `<root>/.sinter/check`) */
|
|
17
|
+
outDir?: string;
|
|
18
|
+
/** path of TypeScript's `tsc` script (default: resolved from the project) */
|
|
19
|
+
tsc?: string;
|
|
20
|
+
}
|
|
21
|
+
export interface CheckResult {
|
|
22
|
+
diagnostics: Diagnostic[];
|
|
23
|
+
resources: number;
|
|
24
|
+
/** resources with `type` or an OpenAPI match */
|
|
25
|
+
typed: number;
|
|
26
|
+
/** of those, typed from OpenAPI */
|
|
27
|
+
openapi: number;
|
|
28
|
+
}
|
|
29
|
+
/** What every shadow file of a project refers to, written once per run. */
|
|
30
|
+
export interface Support {
|
|
31
|
+
outDir: string;
|
|
32
|
+
/** absolute path of `sinter.d.ts` */
|
|
33
|
+
declarations: string;
|
|
34
|
+
types: {
|
|
35
|
+
file: string;
|
|
36
|
+
names: string[];
|
|
37
|
+
} | null;
|
|
38
|
+
/** generated declarations per OpenAPI spec, by spec index */
|
|
39
|
+
openapi: string[];
|
|
40
|
+
}
|
|
41
|
+
/** Writes `sinter.d.ts` and the spec declarations into `outDir`. */
|
|
42
|
+
export declare function writeSupport(o: {
|
|
43
|
+
root: string;
|
|
44
|
+
outDir: string;
|
|
45
|
+
types: string | null;
|
|
46
|
+
openapi?: OpenApiSpec[];
|
|
47
|
+
}): Support;
|
|
48
|
+
/** The compiler options shadow files are checked with. */
|
|
49
|
+
export declare const COMPILER_OPTIONS: {
|
|
50
|
+
allowJs: boolean;
|
|
51
|
+
checkJs: boolean;
|
|
52
|
+
strict: boolean;
|
|
53
|
+
strictNullChecks: boolean;
|
|
54
|
+
noImplicitAny: boolean;
|
|
55
|
+
noEmit: boolean;
|
|
56
|
+
skipLibCheck: boolean;
|
|
57
|
+
target: string;
|
|
58
|
+
module: string;
|
|
59
|
+
moduleResolution: string;
|
|
60
|
+
lib: string[];
|
|
61
|
+
types: string[];
|
|
62
|
+
};
|
|
63
|
+
/** Where a page's shadow file goes. */
|
|
64
|
+
export declare function shadowPath(support: Support, root: string, page: string): string;
|
|
65
|
+
export declare function shadowOf(unit: CheckUnit, support: Support, root: string, helpers: HelperManifest): Shadow;
|
|
66
|
+
/** A TypeScript error at a 1-based shadow position, as an SN208 diagnostic at the HTML position. */
|
|
67
|
+
export declare function typeDiagnostic(unit: CheckUnit, regions: Region[], line: number, col: number, code: string, message: string, root: string): Diagnostic;
|
|
68
|
+
export declare function check(o: CheckOptions): CheckResult;
|
|
69
|
+
/**
|
|
70
|
+
* Type-checks one page in a temporary project, for tests: `files` are written next to it
|
|
71
|
+
* (`{ 'src/sinter.types.ts': … }`), options name them as in the Vite config. Returns the
|
|
72
|
+
* compiler's and TypeScript's diagnostics; TypeScript is resolved from the current directory.
|
|
73
|
+
*/
|
|
74
|
+
export declare function checkPage(html: string, o?: ProjectOptions & {
|
|
75
|
+
files?: Record<string, string>;
|
|
76
|
+
}): Diagnostic[];
|
|
77
|
+
/** TypeScript's `tsc` script, resolved from the project like its own imports. */
|
|
78
|
+
export declare function findTsc(root: string): string;
|
|
79
|
+
/** Names a types module exports: TS declarations, `export { … }` lists and JSDoc typedefs. */
|
|
80
|
+
export declare function typeExports(source: string): string[];
|
|
81
|
+
/** Shortens absolute module paths in TypeScript's messages to project-relative ones. */
|
|
82
|
+
export declare function clean(message: string, root: string): string;
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
// `sinter check`: type-checks every expression of the compiled pages with the
|
|
2
|
+
// project's own TypeScript (`tsc` on generated, checked JavaScript files).
|
|
3
|
+
import { spawnSync } from 'node:child_process';
|
|
4
|
+
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
5
|
+
import { createRequire } from 'node:module';
|
|
6
|
+
import { tmpdir } from 'node:os';
|
|
7
|
+
import { dirname, join, relative, resolve } from 'node:path';
|
|
8
|
+
import { compile } from '../compiler/index.js';
|
|
9
|
+
import { project } from '../project.js';
|
|
10
|
+
import { specDeclarations } from './openapi-types.js';
|
|
11
|
+
import { DECLARATIONS, mapPosition, shadow } from './shadow.js';
|
|
12
|
+
/** Writes `sinter.d.ts` and the spec declarations into `outDir`. */
|
|
13
|
+
export function writeSupport(o) {
|
|
14
|
+
const declarations = join(o.outDir, 'sinter.d.ts');
|
|
15
|
+
mkdirSync(o.outDir, { recursive: true });
|
|
16
|
+
writeIfChanged(declarations, DECLARATIONS);
|
|
17
|
+
const openapi = (o.openapi ?? []).map((s, i) => {
|
|
18
|
+
const file = join(o.outDir, `openapi-${i}.d.ts`);
|
|
19
|
+
writeIfChanged(file, specDeclarations(s.doc, relative(o.root, s.file).split('\\').join('/')));
|
|
20
|
+
return file;
|
|
21
|
+
});
|
|
22
|
+
const types = o.types ? { file: o.types, names: typeExports(readFileSync(o.types, 'utf8')) } : null;
|
|
23
|
+
return { outDir: o.outDir, declarations, types, openapi };
|
|
24
|
+
}
|
|
25
|
+
/** The compiler options shadow files are checked with. */
|
|
26
|
+
export const COMPILER_OPTIONS = {
|
|
27
|
+
allowJs: true, checkJs: true, strict: true, strictNullChecks: false, noImplicitAny: false, noEmit: true, skipLibCheck: true,
|
|
28
|
+
target: 'es2022', module: 'esnext', moduleResolution: 'bundler', lib: ['es2023', 'dom', 'dom.iterable'], types: [],
|
|
29
|
+
};
|
|
30
|
+
/** Where a page's shadow file goes. */
|
|
31
|
+
export function shadowPath(support, root, page) {
|
|
32
|
+
return join(support.outDir, relative(root, page) + '.js');
|
|
33
|
+
}
|
|
34
|
+
export function shadowOf(unit, support, root, helpers) {
|
|
35
|
+
return shadow({ unit, file: shadowPath(support, root, unit.file), root, declarations: support.declarations, types: support.types, helpers, openapi: support.openapi });
|
|
36
|
+
}
|
|
37
|
+
/** A TypeScript error at a 1-based shadow position, as an SN208 diagnostic at the HTML position. */
|
|
38
|
+
export function typeDiagnostic(unit, regions, line, col, code, message, root) {
|
|
39
|
+
const text = `${clean(message, root)} (${code})`;
|
|
40
|
+
const loc = mapPosition(regions, line, col);
|
|
41
|
+
return loc
|
|
42
|
+
? { severity: 'error', code: 'SN208', message: text + (loc.chain.length ? ` (included from ${loc.chain.join(' ← ')})` : ''), file: loc.file, line: loc.line, col: loc.col }
|
|
43
|
+
: { severity: 'error', code: 'SN208', message: `in code sinter generated (please report): ${text}`, file: unit.file, line: 1, col: 1 };
|
|
44
|
+
}
|
|
45
|
+
function writeIfChanged(file, text) {
|
|
46
|
+
try {
|
|
47
|
+
if (readFileSync(file, 'utf8') === text)
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
catch { /* missing */ }
|
|
51
|
+
writeFileSync(file, text);
|
|
52
|
+
}
|
|
53
|
+
export function check(o) {
|
|
54
|
+
const outDir = o.outDir ?? join(o.root, '.sinter', 'check');
|
|
55
|
+
const support = writeSupport({ root: o.root, outDir, types: o.types, openapi: o.openapi });
|
|
56
|
+
const shadows = new Map();
|
|
57
|
+
for (const unit of o.units) {
|
|
58
|
+
const file = shadowPath(support, o.root, unit.file);
|
|
59
|
+
const s = shadowOf(unit, support, o.root, o.helpers);
|
|
60
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
61
|
+
writeFileSync(file, s.text);
|
|
62
|
+
shadows.set(resolve(file), { unit, regions: s.regions, brokenFrom: s.brokenFrom });
|
|
63
|
+
}
|
|
64
|
+
const tsconfig = join(outDir, 'tsconfig.json');
|
|
65
|
+
writeFileSync(tsconfig, JSON.stringify({
|
|
66
|
+
compilerOptions: COMPILER_OPTIONS,
|
|
67
|
+
files: ['sinter.d.ts', ...support.openapi.map((f) => relative(outDir, f)), ...[...shadows.keys()].map((f) => relative(outDir, f).split('\\').join('/'))],
|
|
68
|
+
}, null, 2) + '\n');
|
|
69
|
+
const tsc = o.tsc ?? findTsc(o.root);
|
|
70
|
+
const run = spawnSync(process.execPath, [tsc, '-p', tsconfig, '--pretty', 'false'], { encoding: 'utf8', cwd: outDir });
|
|
71
|
+
if (run.error)
|
|
72
|
+
throw run.error;
|
|
73
|
+
const diagnostics = parseTsc(run.stdout, outDir, o.root, shadows);
|
|
74
|
+
const resources = o.units.reduce((n, u) => n + u.resources.length, 0);
|
|
75
|
+
const typed = o.units.reduce((n, u) => n + u.resources.filter((r) => r.type || r.openapi).length, 0);
|
|
76
|
+
const viaOpenapi = o.units.reduce((n, u) => n + u.resources.filter((r) => !r.type && r.openapi).length, 0);
|
|
77
|
+
return { diagnostics, resources, typed, openapi: viaOpenapi };
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Type-checks one page in a temporary project, for tests: `files` are written next to it
|
|
81
|
+
* (`{ 'src/sinter.types.ts': … }`), options name them as in the Vite config. Returns the
|
|
82
|
+
* compiler's and TypeScript's diagnostics; TypeScript is resolved from the current directory.
|
|
83
|
+
*/
|
|
84
|
+
export function checkPage(html, o = {}) {
|
|
85
|
+
const root = mkdtempSync(join(tmpdir(), 'sinter-check-page-'));
|
|
86
|
+
try {
|
|
87
|
+
for (const [rel, text] of Object.entries(o.files ?? {})) {
|
|
88
|
+
mkdirSync(dirname(join(root, rel)), { recursive: true });
|
|
89
|
+
writeFileSync(join(root, rel), text);
|
|
90
|
+
}
|
|
91
|
+
const file = join(root, 'index.html');
|
|
92
|
+
writeFileSync(file, html);
|
|
93
|
+
const p = project(root, o);
|
|
94
|
+
const out = compile(html, { file, root, dev: false, helpers: p.helpers, readFile: p.readFile, check: true, openapi: p.openapi });
|
|
95
|
+
const r = check({ root, units: out.check ? [out.check] : [], helpers: p.helpers, types: p.types, openapi: p.openapi, tsc: findTsc(process.cwd()) });
|
|
96
|
+
return [...p.diagnostics, ...out.diagnostics, ...r.diagnostics].map((d) => ({ ...d, file: relative(root, d.file) }));
|
|
97
|
+
}
|
|
98
|
+
finally {
|
|
99
|
+
rmSync(root, { recursive: true, force: true });
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
/** TypeScript's `tsc` script, resolved from the project like its own imports. */
|
|
103
|
+
export function findTsc(root) {
|
|
104
|
+
const req = createRequire(join(root, 'package.json'));
|
|
105
|
+
let pkg;
|
|
106
|
+
try {
|
|
107
|
+
pkg = req.resolve('typescript/package.json');
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
throw new Error('sinter check needs TypeScript: npm install -D typescript');
|
|
111
|
+
}
|
|
112
|
+
const bin = JSON.parse(readFileSync(pkg, 'utf8')).bin?.tsc ?? 'bin/tsc';
|
|
113
|
+
return join(dirname(pkg), bin);
|
|
114
|
+
}
|
|
115
|
+
/** Names a types module exports: TS declarations, `export { … }` lists and JSDoc typedefs. */
|
|
116
|
+
export function typeExports(source) {
|
|
117
|
+
const names = new Set();
|
|
118
|
+
for (const m of source.matchAll(/export\s+(?:declare\s+)?(?:abstract\s+)?(?:interface|type|class|enum)\s+([A-Za-z_$][\w$]*)/g))
|
|
119
|
+
names.add(m[1]);
|
|
120
|
+
for (const m of source.matchAll(/export\s+(?:type\s+)?\{([^}]*)\}/g)) {
|
|
121
|
+
for (const part of m[1].split(',')) {
|
|
122
|
+
const name = part.trim().replace(/^type\s+/, '').split(/\s+as\s+/).pop().trim();
|
|
123
|
+
if (/^[A-Za-z_$][\w$]*$/.test(name) && name !== 'default')
|
|
124
|
+
names.add(name);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
for (const m of source.matchAll(/@typedef\s+(?:\{.*?\}\s+)?([A-Za-z_$][\w$]*)/g))
|
|
128
|
+
names.add(m[1]);
|
|
129
|
+
return [...names].sort();
|
|
130
|
+
}
|
|
131
|
+
/** `file(line,col): error TS1234: message`, with indented continuation lines. */
|
|
132
|
+
function parseTsc(stdout, outDir, root, shadows) {
|
|
133
|
+
const out = [];
|
|
134
|
+
let last = null;
|
|
135
|
+
for (const raw of stdout.split(/\r?\n/)) {
|
|
136
|
+
const m = /^(.+?)\((\d+),(\d+)\): error (TS\d+): (.*)$/.exec(raw);
|
|
137
|
+
if (!m) {
|
|
138
|
+
if (last && /^\s+\S/.test(raw))
|
|
139
|
+
last.message += '\n ' + raw.trim();
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
last = null;
|
|
143
|
+
const s = shadows.get(resolve(outDir, m[1]));
|
|
144
|
+
// errors in the app's own modules are its own tsc's business; broken expressions are the compiler's
|
|
145
|
+
if (!s || (s.brokenFrom !== null && Number(m[2]) >= s.brokenFrom))
|
|
146
|
+
continue;
|
|
147
|
+
const d = typeDiagnostic(s.unit, s.regions, Number(m[2]), Number(m[3]), m[4], m[5], root);
|
|
148
|
+
out.push(d);
|
|
149
|
+
last = d;
|
|
150
|
+
}
|
|
151
|
+
return out;
|
|
152
|
+
}
|
|
153
|
+
/** Shortens absolute module paths in TypeScript's messages to project-relative ones. */
|
|
154
|
+
export function clean(message, root) {
|
|
155
|
+
return message.replace(/import\("([^"]+)"\)/g, (_, p) => `import("${p.startsWith(root) ? relative(root, p).split('\\').join('/') : p}")`);
|
|
156
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** A schema name as a TypeScript identifier. */
|
|
2
|
+
export declare function typeName(name: string): string;
|
|
3
|
+
/** TypeScript for a schema; `ref` turns a named schema into a type reference. */
|
|
4
|
+
export declare function schemaToTs(schema: unknown, ref: (name: string) => string, depth?: number): string;
|
|
5
|
+
/** Declarations for every named schema of a spec: interfaces for objects, aliases otherwise. */
|
|
6
|
+
export declare function specDeclarations(doc: unknown, file: string): string;
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// JSON Schema (OpenAPI 3.0 and 3.1) to TypeScript, for `sinter check`. Named
|
|
2
|
+
// schemas (`components.schemas`) become named declarations, so messages say
|
|
3
|
+
// `type 'Article'`. Precision where checking expressions needs it (properties,
|
|
4
|
+
// arrays, unions, enums); `any` where it does not (formats, patterns, limits).
|
|
5
|
+
const isObj = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
6
|
+
const SCHEMA_REF = '#/components/schemas/';
|
|
7
|
+
/** A schema name as a TypeScript identifier. */
|
|
8
|
+
export function typeName(name) {
|
|
9
|
+
const id = name.replace(/[^\w$]/g, '_');
|
|
10
|
+
return /^\d/.test(id) ? '_' + id : id;
|
|
11
|
+
}
|
|
12
|
+
const key = (k) => (/^[A-Za-z_$][\w$]*$/.test(k) ? k : JSON.stringify(k));
|
|
13
|
+
/** TypeScript for a schema; `ref` turns a named schema into a type reference. */
|
|
14
|
+
export function schemaToTs(schema, ref, depth = 0) {
|
|
15
|
+
if (schema === true || schema === undefined)
|
|
16
|
+
return 'any';
|
|
17
|
+
if (schema === false)
|
|
18
|
+
return 'never';
|
|
19
|
+
if (!isObj(schema) || depth > 24)
|
|
20
|
+
return 'any';
|
|
21
|
+
const s = schema;
|
|
22
|
+
if (typeof s.$ref === 'string') {
|
|
23
|
+
return s.$ref.startsWith(SCHEMA_REF) ? ref(s.$ref.slice(SCHEMA_REF.length).replace(/~1/g, '/').replace(/~0/g, '~')) : 'any';
|
|
24
|
+
}
|
|
25
|
+
const sub = (v) => schemaToTs(v, ref, depth + 1);
|
|
26
|
+
let t;
|
|
27
|
+
if ('const' in s)
|
|
28
|
+
t = JSON.stringify(s.const);
|
|
29
|
+
else if (Array.isArray(s.enum))
|
|
30
|
+
t = s.enum.map((v) => JSON.stringify(v)).join(' | ') || 'never';
|
|
31
|
+
else if (Array.isArray(s.allOf))
|
|
32
|
+
t = s.allOf.map((x) => paren(sub(x))).join(' & ');
|
|
33
|
+
else if (Array.isArray(s.oneOf) || Array.isArray(s.anyOf))
|
|
34
|
+
t = (s.oneOf ?? s.anyOf).map(sub).join(' | ');
|
|
35
|
+
else {
|
|
36
|
+
const types = Array.isArray(s.type) ? s.type.map(String) : typeof s.type === 'string' ? [s.type] : [];
|
|
37
|
+
if (!types.length && (isObj(s.properties) || 'additionalProperties' in s))
|
|
38
|
+
types.push('object');
|
|
39
|
+
if (!types.length && 'items' in s)
|
|
40
|
+
types.push('array');
|
|
41
|
+
t = types.length ? types.map((ty) => single(ty, s, sub)).join(' | ') : 'any';
|
|
42
|
+
}
|
|
43
|
+
if (s.nullable === true && t !== 'any')
|
|
44
|
+
t = `${paren(t)} | null`;
|
|
45
|
+
return t;
|
|
46
|
+
}
|
|
47
|
+
function single(type, s, sub) {
|
|
48
|
+
switch (type) {
|
|
49
|
+
case 'string': return 'string';
|
|
50
|
+
case 'number':
|
|
51
|
+
case 'integer': return 'number';
|
|
52
|
+
case 'boolean': return 'boolean';
|
|
53
|
+
case 'null': return 'null';
|
|
54
|
+
case 'array': return `${paren(sub(s.items))}[]`;
|
|
55
|
+
case 'object': {
|
|
56
|
+
const props = isObj(s.properties) ? s.properties : {};
|
|
57
|
+
const required = new Set(Array.isArray(s.required) ? s.required.map(String) : []);
|
|
58
|
+
const parts = Object.entries(props).map(([k, v]) => `${key(k)}${required.has(k) ? '' : '?'}: ${sub(v)}`);
|
|
59
|
+
const extra = s.additionalProperties;
|
|
60
|
+
if (extra === true || isObj(extra))
|
|
61
|
+
parts.push(`[key: string]: ${extra === true ? 'any' : sub(extra)}`);
|
|
62
|
+
else if (!parts.length && extra === undefined)
|
|
63
|
+
parts.push('[key: string]: any');
|
|
64
|
+
return `{ ${parts.join('; ')} }`;
|
|
65
|
+
}
|
|
66
|
+
default: return 'any';
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
const paren = (t) => (/[|&]/.test(t) && !/^\{.*\}$/.test(t) ? `(${t})` : t);
|
|
70
|
+
/** Declarations for every named schema of a spec: interfaces for objects, aliases otherwise. */
|
|
71
|
+
export function specDeclarations(doc, file) {
|
|
72
|
+
const out = [`// Generated by \`sinter check\` from ${file}. Do not edit.`, 'export {};'];
|
|
73
|
+
const schemas = isObj(doc) && isObj(doc.components) && isObj(doc.components.schemas) ? doc.components.schemas : {};
|
|
74
|
+
const ref = (n) => typeName(n);
|
|
75
|
+
for (const [name, schema] of Object.entries(schemas)) {
|
|
76
|
+
const t = schemaToTs(schema, ref);
|
|
77
|
+
// a plain object type becomes an interface, which keeps its name in messages
|
|
78
|
+
if (/^\{ .* \}$/s.test(t) && !/\} [|&] \{/.test(t))
|
|
79
|
+
out.push(`export interface ${typeName(name)} ${t}`);
|
|
80
|
+
else
|
|
81
|
+
out.push(`export type ${typeName(name)} = ${t};`);
|
|
82
|
+
}
|
|
83
|
+
return out.join('\n') + '\n';
|
|
84
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { CheckLoc, CheckUnit } from '../compiler/check-unit.js';
|
|
2
|
+
import type { HelperManifest } from '../compiler/compile.js';
|
|
3
|
+
export interface ShadowOptions {
|
|
4
|
+
unit: CheckUnit;
|
|
5
|
+
/** absolute path the shadow file is written to */
|
|
6
|
+
file: string;
|
|
7
|
+
/** project root; root-relative module specifiers (`/src/x.js`) resolve against it */
|
|
8
|
+
root: string;
|
|
9
|
+
/** absolute path of the declarations sinter writes next to the shadow files (`sinter.d.ts`) */
|
|
10
|
+
declarations: string;
|
|
11
|
+
/** the types module and the names it exports */
|
|
12
|
+
types: {
|
|
13
|
+
file: string;
|
|
14
|
+
names: string[];
|
|
15
|
+
} | null;
|
|
16
|
+
helpers: HelperManifest;
|
|
17
|
+
/** absolute paths of the declarations generated per OpenAPI spec, by spec index */
|
|
18
|
+
openapi?: string[];
|
|
19
|
+
}
|
|
20
|
+
/** A run of shadow lines that came from the HTML. */
|
|
21
|
+
export interface Region {
|
|
22
|
+
/** first shadow line, 1-based */
|
|
23
|
+
start: number;
|
|
24
|
+
lines: number;
|
|
25
|
+
loc: CheckLoc;
|
|
26
|
+
/** every line maps to `loc` itself (a `type` attribute in a JSDoc comment) */
|
|
27
|
+
fixed?: boolean;
|
|
28
|
+
}
|
|
29
|
+
export interface Shadow {
|
|
30
|
+
text: string;
|
|
31
|
+
regions: Region[];
|
|
32
|
+
/** first line of the broken expressions (written last); nothing from there on is reported */
|
|
33
|
+
brokenFrom: number | null;
|
|
34
|
+
}
|
|
35
|
+
export declare function shadow(o: ShadowOptions): Shadow;
|
|
36
|
+
/** Maps a 1-based shadow position to the HTML, or null for generated lines. */
|
|
37
|
+
export declare function mapPosition(regions: Region[], line: number, col: number): CheckLoc | null;
|
|
38
|
+
/** `sinter.d.ts`: the types the shadow files refer to. */
|
|
39
|
+
export declare const DECLARATIONS = "// Generated by `sinter check`. Do not edit.\nexport interface Meta {\n loading: boolean;\n error: Err | null;\n loadedAt: number | null;\n pending: number;\n total: number | null;\n page?: number;\n pages?: number | null;\n from?: number | null;\n to?: number | null;\n hasPrev?: boolean;\n hasNext?: boolean;\n}\nexport interface Err { status: number; body: any; message: string }\nexport type Item<T> = NonNullable<T> extends readonly (infer U)[] ? U : NonNullable<T> extends Iterable<infer U> ? U : any;\nexport type El<K extends string> = K extends keyof HTMLElementTagNameMap ? HTMLElementTagNameMap[K] : HTMLElement;\nexport type Ev<K extends string> = K extends keyof HTMLElementEventMap ? HTMLElementEventMap[K] : CustomEvent;\nexport interface Store<R> {\n refresh(name: keyof R & string): void;\n upsert<K extends keyof R & string>(name: K, item: Item<R[K]>): void;\n patch<K extends keyof R & string>(name: K, item: Partial<Item<R[K]>>, set: Partial<Item<R[K]>>): void;\n remove<K extends keyof R & string>(name: K, item: Partial<Item<R[K]>>): void;\n page(name: keyof R & string, to: 'next' | 'prev' | 'first' | 'last' | number): void;\n close(): void;\n reset(): void;\n emit(event: string, detail?: unknown): void;\n}\ntype Style = 'short' | 'medium' | 'long' | 'full';\nexport function date(v: unknown, style?: Style): string;\nexport function time(v: unknown, style?: Style): string;\nexport function datetime(v: unknown, style?: Style): string;\nexport function relative(v: unknown, now?: unknown): string;\nexport function number(v: unknown, opts?: Intl.NumberFormatOptions): string;\nexport function bytes(v: unknown): string;\n";
|
|
40
|
+
/**
|
|
41
|
+
* Maps a 1-based position in an HTML file to the shadow, or null when it is not inside an
|
|
42
|
+
* expression. `lines` are the shadow's lines.
|
|
43
|
+
*/
|
|
44
|
+
export declare function shadowPosition(regions: Region[], lines: string[], file: string, line: number, col: number): {
|
|
45
|
+
line: number;
|
|
46
|
+
col: number;
|
|
47
|
+
} | null;
|