@estiva-app/ui 0.22.0 → 0.24.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/README.md +21 -0
- package/dist/AppShell.d.ts.map +1 -1
- package/dist/AttachmentCard.d.ts.map +1 -1
- package/dist/Banner.d.ts +20 -2
- package/dist/Banner.d.ts.map +1 -1
- package/dist/ContainerHeader.d.ts +25 -0
- package/dist/ContainerHeader.d.ts.map +1 -0
- package/dist/EmptyState.d.ts +10 -1
- package/dist/EmptyState.d.ts.map +1 -1
- package/dist/Form.d.ts.map +1 -1
- package/dist/IconButton.d.ts +9 -1
- package/dist/IconButton.d.ts.map +1 -1
- package/dist/PreviewCard.d.ts +9 -2
- package/dist/PreviewCard.d.ts.map +1 -1
- package/dist/SectionHeader.d.ts +7 -1
- package/dist/SectionHeader.d.ts.map +1 -1
- package/dist/Select.d.ts +11 -3
- package/dist/Select.d.ts.map +1 -1
- package/dist/Tooltip.d.ts +13 -1
- package/dist/Tooltip.d.ts.map +1 -1
- package/dist/eslint/index.js +2 -1
- package/dist/eslint/index.js.map +2 -2
- package/dist/eslint/no-restyled-part.d.ts.map +1 -1
- package/dist/gates/app-checks.d.ts.map +1 -1
- package/dist/gates/{chunk-ZGJ2J5NU.js → chunk-EA33NP5B.js} +275 -11
- package/dist/gates/chunk-EA33NP5B.js.map +7 -0
- package/dist/gates/{chunk-AUXD4GCY.js → chunk-GTQZEHPC.js} +15 -1
- package/dist/gates/chunk-GTQZEHPC.js.map +7 -0
- package/dist/gates/cli.js +1 -1
- package/dist/gates/create-app.d.ts +2 -0
- package/dist/gates/create-app.d.ts.map +1 -1
- package/dist/gates/create-app.js +1 -1
- package/dist/gates/index.js +25 -3
- package/dist/gates/index.js.map +2 -2
- package/dist/gates/status.d.ts +2 -0
- package/dist/gates/status.d.ts.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +196 -156
- package/dist/index.js.map +4 -4
- package/dist/registry/app-DQI6WLHR.js +10 -0
- package/dist/registry/app.d.ts +21 -0
- package/dist/registry/app.d.ts.map +1 -0
- package/dist/registry/build-LGFCCOLR.js +27 -0
- package/dist/registry/build-LGFCCOLR.js.map +7 -0
- package/dist/registry/build.d.ts +68 -2
- package/dist/registry/build.d.ts.map +1 -1
- package/dist/registry/{chunk-MRSBS5OP.js → chunk-E4JNV7PC.js} +68 -12
- package/dist/registry/chunk-E4JNV7PC.js.map +7 -0
- package/dist/registry/{chunk-QDYGB3QN.js → chunk-NJN4MQAM.js} +60 -15
- package/dist/registry/chunk-NJN4MQAM.js.map +7 -0
- package/dist/registry/{chunk-IJNCYVH4.js → chunk-W2B2G7OE.js} +34 -9
- package/dist/registry/chunk-W2B2G7OE.js.map +7 -0
- package/dist/registry/chunk-WMFF3MPP.js +648 -0
- package/dist/registry/chunk-WMFF3MPP.js.map +7 -0
- package/dist/registry/cli.js +81 -26
- package/dist/registry/cli.js.map +3 -3
- package/dist/registry/find.d.ts +12 -1
- package/dist/registry/find.d.ts.map +1 -1
- package/dist/registry/index.d.ts +5 -2
- package/dist/registry/index.d.ts.map +1 -1
- package/dist/registry/index.js +11 -3
- package/dist/registry/schema.d.ts +83 -6
- package/dist/registry/schema.d.ts.map +1 -1
- package/package.json +2 -2
- package/registry.json +283 -95
- package/src/AppShell.mdx +10 -0
- package/src/AppShell.test.tsx +16 -0
- package/src/AppShell.tsx +4 -1
- package/src/AttachmentCard.mdx +9 -0
- package/src/AttachmentCard.test.tsx +10 -0
- package/src/AttachmentCard.tsx +8 -5
- package/src/Avatar.mdx +13 -3
- package/src/AvatarGroup.mdx +4 -0
- package/src/Banner.mdx +30 -7
- package/src/Banner.stories.tsx +22 -0
- package/src/Banner.test.tsx +35 -0
- package/src/Banner.tsx +34 -9
- package/src/Breadcrumb.mdx +10 -0
- package/src/Button.mdx +14 -3
- package/src/Card.mdx +9 -0
- package/src/Checkbox.mdx +10 -0
- package/src/Chip.mdx +4 -0
- package/src/ChipInput.mdx +12 -0
- package/src/CollapsibleSection.mdx +9 -0
- package/src/CommandPalette.mdx +14 -2
- package/src/ConfirmDialog.mdx +11 -0
- package/src/ContainerHeader.mdx +60 -0
- package/src/ContainerHeader.stories.tsx +62 -0
- package/src/ContainerHeader.test.tsx +47 -0
- package/src/ContainerHeader.tsx +45 -0
- package/src/DialogShell.mdx +14 -0
- package/src/Divider.mdx +9 -1
- package/src/EditableText.mdx +11 -0
- package/src/EmptyState.mdx +27 -4
- package/src/EmptyState.stories.tsx +6 -1
- package/src/EmptyState.test.tsx +18 -1
- package/src/EmptyState.tsx +14 -1
- package/src/Field.mdx +10 -1
- package/src/FieldLine.mdx +9 -1
- package/src/FilePicker.mdx +8 -0
- package/src/Form.mdx +10 -0
- package/src/Form.test.tsx +26 -0
- package/src/Form.tsx +7 -0
- package/src/IconButton.mdx +16 -2
- package/src/IconButton.stories.tsx +3 -0
- package/src/IconButton.test.tsx +25 -0
- package/src/IconButton.tsx +61 -46
- package/src/IdentityMenu.mdx +9 -0
- package/src/InlineChip.mdx +8 -0
- package/src/Kbd.mdx +4 -0
- package/src/Link.mdx +10 -0
- package/src/Menu.mdx +16 -1
- package/src/MenuItem.mdx +12 -2
- package/src/NavItem.mdx +8 -0
- package/src/Person.mdx +4 -0
- package/src/PersonTrigger.mdx +9 -0
- package/src/Popover.mdx +21 -0
- package/src/PreviewCard.mdx +19 -4
- package/src/PreviewCard.tsx +11 -4
- package/src/ProgressBar.mdx +8 -0
- package/src/Property.mdx +4 -0
- package/src/Rail.mdx +9 -1
- package/src/RailItem.mdx +8 -0
- package/src/Reaction.mdx +9 -0
- package/src/ReactionPicker.mdx +8 -0
- package/src/ScrollArea.mdx +13 -2
- package/src/SearchInput.mdx +9 -0
- package/src/SectionHeader.mdx +13 -0
- package/src/SectionHeader.stories.tsx +9 -0
- package/src/SectionHeader.test.tsx +9 -0
- package/src/SectionHeader.tsx +8 -2
- package/src/SectionLabel.mdx +4 -0
- package/src/Select.mdx +21 -2
- package/src/Select.stories.tsx +4 -1
- package/src/Select.test.tsx +17 -0
- package/src/Select.tsx +34 -13
- package/src/Sidebar.mdx +8 -0
- package/src/Skeleton.mdx +4 -0
- package/src/Tabs.mdx +12 -1
- package/src/TextInput.mdx +8 -0
- package/src/Textarea.mdx +8 -0
- package/src/Toast.mdx +12 -1
- package/src/Toolbar.mdx +11 -0
- package/src/Tooltip.mdx +26 -2
- package/src/Tooltip.stories.tsx +26 -0
- package/src/Tooltip.test.tsx +29 -0
- package/src/Tooltip.tsx +17 -4
- package/src/TopBar.mdx +4 -0
- package/src/eslint/no-rebuilt-behaviour.ts +1 -1
- package/src/eslint/no-restyled-part.ts +1 -0
- package/src/gates/app-checks.ts +23 -1
- package/src/gates/create-app.test.ts +77 -6
- package/src/gates/create-app.ts +288 -15
- package/src/gates/status.ts +16 -0
- package/src/index.ts +1 -0
- package/src/pages.test.ts +142 -0
- package/src/registry/app.test.ts +562 -0
- package/src/registry/app.ts +854 -0
- package/src/registry/build.ts +114 -25
- package/src/registry/cli.ts +105 -42
- package/src/registry/find.ts +45 -8
- package/src/registry/index.ts +8 -1
- package/src/registry/registry.test.ts +5 -5
- package/src/registry/schema.ts +163 -12
- package/tailwind-preset.js +22 -1
- package/dist/gates/chunk-AUXD4GCY.js.map +0 -7
- package/dist/gates/chunk-ZGJ2J5NU.js.map +0 -7
- package/dist/registry/build-GOVLABI6.js +0 -13
- package/dist/registry/chunk-IJNCYVH4.js.map +0 -7
- package/dist/registry/chunk-MRSBS5OP.js.map +0 -7
- package/dist/registry/chunk-QDYGB3QN.js.map +0 -7
- /package/dist/registry/{build-GOVLABI6.js.map → app-DQI6WLHR.js.map} +0 -0
|
@@ -1,14 +1,22 @@
|
|
|
1
|
-
import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
|
|
2
|
-
import { join } from 'node:path'
|
|
1
|
+
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { dirname, join } from 'node:path'
|
|
3
3
|
import { afterAll, describe, expect, it } from 'vitest'
|
|
4
|
-
import {
|
|
4
|
+
import { buildAppRegistry } from '../registry/app'
|
|
5
|
+
import { validateRegistry } from '../registry/schema'
|
|
6
|
+
import { appFiles, ASKED_OF_NPM, createApp, themes } from './create-app'
|
|
5
7
|
|
|
6
8
|
/**
|
|
7
9
|
* `create-estiva-app` (UIG-10). What the app does when it runs is proved on a
|
|
8
10
|
* throwaway app (docs/GATES.md, UIG-10: building it); these hold what it is made
|
|
9
11
|
* of, and that it is made from this package alone.
|
|
10
12
|
*/
|
|
11
|
-
const versions = {
|
|
13
|
+
const versions = {
|
|
14
|
+
'@estiva-app/identity': '^0.2.2',
|
|
15
|
+
'@estiva-app/interop': '^0.23.0',
|
|
16
|
+
'@estiva-app/platform': '^0.1.0',
|
|
17
|
+
'@estiva-app/protocol': '^0.21.0',
|
|
18
|
+
'eslint-plugin-react-hooks': '^7.0.1',
|
|
19
|
+
}
|
|
12
20
|
const files = appFiles({ name: 'probe-app', title: 'Probe', theme: 'dark', versions })
|
|
13
21
|
const scratch = mkdtempSync(join(process.cwd(), 'node_modules', '.create-app-test-'))
|
|
14
22
|
afterAll(() => rmSync(scratch, { recursive: true, force: true }))
|
|
@@ -25,7 +33,8 @@ describe('create-estiva-app', () => {
|
|
|
25
33
|
'.claude/settings.json', '.env.example', '.gates-count.json', '.github/workflows/deploy.yml', '.gitignore', '.storybook/main.ts', '.storybook/preview.tsx',
|
|
26
34
|
'CLAUDE.md', 'README.md', 'docs/GATES-DEBT.md', 'eslint.config.js', 'eslint.gates.config.js', 'eslint.tokens.config.js', 'index.html', 'package.json',
|
|
27
35
|
'postcss.config.js', 'scripts/gates-checks.mjs', 'src/App.test.tsx', 'src/App.tsx', 'src/auth/AuthShell.tsx', 'src/auth/boot.ts', 'src/auth/estivaId.ts',
|
|
28
|
-
'src/config.ts', 'src/index.css', 'src/main.tsx', 'src/pages/HomePage.stories.tsx', 'src/pages/HomePage.tsx',
|
|
36
|
+
'src/config.ts', 'src/index.css', 'src/main.tsx', 'src/pages/HomePage.stories.tsx', 'src/pages/HomePage.tsx',
|
|
37
|
+
'src/relay/client.test.ts', 'src/relay/client.ts', 'src/relay/useRelayState.ts', 'src/vite-env.d.ts', 'tailwind.config.js',
|
|
29
38
|
'tsconfig.app.json', 'tsconfig.json', 'tsconfig.node.json', 'vite.config.ts',
|
|
30
39
|
].sort(),
|
|
31
40
|
)
|
|
@@ -53,7 +62,7 @@ describe('create-estiva-app', () => {
|
|
|
53
62
|
expect(Object.values(files).join('\n')).not.toContain('id.estiva.app')
|
|
54
63
|
})
|
|
55
64
|
|
|
56
|
-
it('takes its tool versions from this package, and the
|
|
65
|
+
it('takes its tool versions from this package, and the ones it does not use from what it is given', () => {
|
|
57
66
|
const own = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8'))
|
|
58
67
|
const made = JSON.parse(files['package.json'])
|
|
59
68
|
expect(made.dependencies['@estiva-app/ui']).toBe(`^${own.version}`)
|
|
@@ -62,6 +71,68 @@ describe('create-estiva-app', () => {
|
|
|
62
71
|
expect(made.dependencies['@estiva-app/identity']).toBe('^0.2.2')
|
|
63
72
|
})
|
|
64
73
|
|
|
74
|
+
it('asks npm for exactly what this package does not use itself', () => {
|
|
75
|
+
const own = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8'))
|
|
76
|
+
const made = JSON.parse(files['package.json'])
|
|
77
|
+
const notOurs = [...Object.keys(made.dependencies), ...Object.keys(made.devDependencies)].filter((n) => n !== '@estiva-app/ui' && !own.devDependencies[n])
|
|
78
|
+
expect(notOurs.sort()).toEqual(ASKED_OF_NPM.filter((n) => !own.devDependencies[n]).sort())
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('bakes in protocol, platform and interop', () => {
|
|
82
|
+
const made = JSON.parse(files['package.json'])
|
|
83
|
+
expect(made.dependencies['@estiva-app/protocol']).toBe('^0.21.0')
|
|
84
|
+
expect(made.dependencies['@estiva-app/platform']).toBe('^0.1.0')
|
|
85
|
+
expect(made.dependencies['@estiva-app/interop']).toBe('^0.23.0')
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('never points at the real relay by itself', () => {
|
|
89
|
+
expect(files['.env.example']).toContain('VITE_RELAY_URL=\n')
|
|
90
|
+
expect(files['src/config.ts']).toMatch(/export const RELAY_URL: string \| null = [^\n]*\|\| null/)
|
|
91
|
+
expect(Object.values(files).join('\n')).not.toContain('estiva.estiva.app')
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('holds one relay client per tab, signs with the wrong-person check, and leaves KINDS blank', () => {
|
|
95
|
+
const client = files['src/relay/client.ts']
|
|
96
|
+
expect(client.match(/^const holder = createLiveClientHolder\(\)$/gm)).toHaveLength(1)
|
|
97
|
+
expect(client).not.toMatch(/\bcreateLiveClient\(|\bcreateLiveRelay\(|new WebSocket\(/)
|
|
98
|
+
expect(client).toContain('signViaEstivaId(unsigned, { base, token, expectedPubkey })')
|
|
99
|
+
expect(client).toContain('export const KINDS: number[] = []')
|
|
100
|
+
expect(client).toContain("subscriptionPrefix: 'probe-app-'")
|
|
101
|
+
expect(files['src/relay/client.test.ts']).toContain('holds one connection per tab')
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('describes every part it writes, and refuses one without in the job gate (UIG-13)', () => {
|
|
105
|
+
const made = mkdtempSync(join(scratch, 'described-'))
|
|
106
|
+
for (const [path, text] of Object.entries(files)) {
|
|
107
|
+
mkdirSync(dirname(join(made, path)), { recursive: true })
|
|
108
|
+
writeFileSync(join(made, path), text)
|
|
109
|
+
}
|
|
110
|
+
const registry = buildAppRegistry({ root: made })
|
|
111
|
+
expect(validateRegistry(registry)).toEqual([])
|
|
112
|
+
expect(registry.entries.map((entry) => `${entry.name}: ${entry.app?.class}`).sort()).toEqual(['App: one-off', 'AuthShell: one-off', 'HomePage: one-off'])
|
|
113
|
+
expect(registry.filesWithoutParts.map((file) => file.file)).toEqual(['src/main.tsx'])
|
|
114
|
+
const scripts = JSON.parse(files['package.json']).scripts
|
|
115
|
+
expect(scripts).toMatchObject({ 'ui:find': 'estiva-ui find', registry: 'estiva-ui build', 'registry:check': 'estiva-ui check' })
|
|
116
|
+
expect(files['.github/workflows/deploy.yml']).toMatch(/\n {2}gate:\n[\s\S]*npm run registry:check/)
|
|
117
|
+
expect(files['.gitignore'].split('\n')).toContain('registry.json')
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it("puts the page's empty state straight into the frame, with no box around it", () => {
|
|
121
|
+
// EmptyState's own page: in a flex column (the frame's main) it takes the room left and
|
|
122
|
+
// centres both ways "with nothing to add". A box around it placed it instead: 64px from
|
|
123
|
+
// the top, from UIG-10 until 18 September, and no gate reads a box.
|
|
124
|
+
const page = files['src/pages/HomePage.tsx']
|
|
125
|
+
expect(page).toMatch(/return \(\n {4}<>\n {6}<EmptyState message="Nothing here yet\." \/>/)
|
|
126
|
+
expect(page).not.toMatch(/py-16|justify-center|items-center/)
|
|
127
|
+
expect(files['src/pages/HomePage.stories.tsx']).toContain('<div className="flex h-screen flex-col">')
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
it('shows the connection on the home page, with a story for each state', () => {
|
|
131
|
+
expect(files['src/App.tsx']).toContain('<HomePage relay={RELAY_URL} state={relayState} name={me.name} />')
|
|
132
|
+
expect(files['src/pages/HomePage.tsx']).toContain('Running alone')
|
|
133
|
+
for (const story of ['RunningAlone', 'Connecting', 'Connected']) expect(files['src/pages/HomePage.stories.tsx']).toContain(`export const ${story}: Story`)
|
|
134
|
+
})
|
|
135
|
+
|
|
65
136
|
it('refuses a name an app cannot have, a theme the package has not got, and a folder that exists', () => {
|
|
66
137
|
expect(themes()).toEqual(expect.arrayContaining(['light', 'dark', 'signal', 'ship']))
|
|
67
138
|
expect(() => appFiles({ name: 'Leaf', versions })).toThrow(/not a name/)
|
package/src/gates/create-app.ts
CHANGED
|
@@ -15,13 +15,20 @@
|
|
|
15
15
|
* import them, and a gate added later arrives with a version bump.
|
|
16
16
|
*
|
|
17
17
|
* What only a new app has is written here: its first page in the sidebar frame
|
|
18
|
-
* (ruled the same day), its one theme, sign-in the way Ship signs in, one
|
|
19
|
-
*
|
|
18
|
+
* (ruled the same day), its one theme, sign-in the way Ship signs in, its one
|
|
19
|
+
* relay connection the way Peek and Ship hold theirs, its tests, its stories,
|
|
20
|
+
* the CI workflow with the job `gate`, and its README and CLAUDE.md.
|
|
21
|
+
*
|
|
22
|
+
* The relay (UIG-10, reopened 18 September): a made app gets `protocol`,
|
|
23
|
+
* `platform` and `interop` and is connected from its first commit, or runs alone
|
|
24
|
+
* when no relay is set. The wiring is the app's own file, using `platform` as it
|
|
25
|
+
* is — not a helper in `platform` — because a package takes code a real app has
|
|
26
|
+
* already run (ADR 0002 §10), and Peek and Ship each hold theirs the same way.
|
|
20
27
|
*
|
|
21
28
|
* Tool versions are this package's own (its devDependencies build and test the
|
|
22
|
-
* same tools). The
|
|
23
|
-
*
|
|
24
|
-
*
|
|
29
|
+
* same tools). The ones it does not use itself ({@link ASKED_OF_NPM}) are asked of
|
|
30
|
+
* the npm registry when the app is made. The lockfile is the app's first
|
|
31
|
+
* `npm install`; make it on Linux.
|
|
25
32
|
*/
|
|
26
33
|
import { execFileSync } from 'node:child_process'
|
|
27
34
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
@@ -52,6 +59,9 @@ export interface CreateAppOptions {
|
|
|
52
59
|
const here = dirname(fileURLToPath(import.meta.url))
|
|
53
60
|
const packageRoot = resolve(here, '..', '..')
|
|
54
61
|
|
|
62
|
+
/** What a made app depends on that this package does not use itself: asked of npm when the app is made. */
|
|
63
|
+
export const ASKED_OF_NPM = ['@estiva-app/identity', '@estiva-app/interop', '@estiva-app/platform', '@estiva-app/protocol', 'eslint-plugin-react-hooks']
|
|
64
|
+
|
|
55
65
|
/** The package's themes, read from its tokens.css: `light` is the block with no attribute. */
|
|
56
66
|
export function themes(): string[] {
|
|
57
67
|
const css = readFileSync(join(packageRoot, 'tokens.css'), 'utf8')
|
|
@@ -87,12 +97,18 @@ export function appFiles({ name, title = name, theme = 'light', ui, versions = {
|
|
|
87
97
|
'lint:rules': 'eslint --config eslint.gates.config.js .',
|
|
88
98
|
'postlint:rules': `estiva-gates count --repo ${name}`,
|
|
89
99
|
'gates:status': 'estiva-gates status',
|
|
100
|
+
'ui:find': 'estiva-ui find',
|
|
101
|
+
registry: 'estiva-ui build',
|
|
102
|
+
'registry:check': 'estiva-ui check',
|
|
90
103
|
test: 'vitest run',
|
|
91
104
|
storybook: 'storybook dev -p 6006',
|
|
92
105
|
'build-storybook': 'storybook build',
|
|
93
106
|
},
|
|
94
107
|
dependencies: {
|
|
95
108
|
'@estiva-app/identity': own('@estiva-app/identity'),
|
|
109
|
+
'@estiva-app/interop': own('@estiva-app/interop'),
|
|
110
|
+
'@estiva-app/platform': own('@estiva-app/platform'),
|
|
111
|
+
'@estiva-app/protocol': own('@estiva-app/protocol'),
|
|
96
112
|
'@estiva-app/ui': ui ?? `^${pkg.version}`,
|
|
97
113
|
...deps(['@tabler/icons-react', 'react', 'react-dom']),
|
|
98
114
|
},
|
|
@@ -127,7 +143,9 @@ export function appFiles({ name, title = name, theme = 'light', ui, versions = {
|
|
|
127
143
|
return {
|
|
128
144
|
'package.json': json(packageJson),
|
|
129
145
|
|
|
130
|
-
|
|
146
|
+
// registry.json: the app's catalogue, written only when `npm run registry` is asked
|
|
147
|
+
// to. It is built fresh from the code every time it is read, so it is never kept.
|
|
148
|
+
'.gitignore': ['node_modules', 'dist', 'storybook-static', '*.local', '*.log', '*.tsbuildinfo', '.DS_Store', 'registry.json', ''].join('\n'),
|
|
131
149
|
|
|
132
150
|
'.env.example': `# Estiva ID, for signing in. Copy this file to .env.local and fill both in.
|
|
133
151
|
#
|
|
@@ -140,6 +158,16 @@ export function appFiles({ name, title = name, theme = 'light', ui, versions = {
|
|
|
140
158
|
# server; a local one (http://localhost:8787) in its database.
|
|
141
159
|
VITE_ESTIVA_ID_ORIGIN=
|
|
142
160
|
VITE_ESTIVA_ID_CLIENT_ID=${name}
|
|
161
|
+
|
|
162
|
+
# The relay: the workspace this app reads and writes.
|
|
163
|
+
#
|
|
164
|
+
# Left empty, the app runs alone. It opens no connection, and its home page says
|
|
165
|
+
# so. It also needs sign-in above: the relay refuses anything before sign-in, so
|
|
166
|
+
# with a relay and no sign-in there is still no connection. And Estiva ID must
|
|
167
|
+
# allow this app to sign in to that relay (the README says how).
|
|
168
|
+
#
|
|
169
|
+
# A relay running on this machine is http://localhost:3000.
|
|
170
|
+
VITE_RELAY_URL=
|
|
143
171
|
`,
|
|
144
172
|
|
|
145
173
|
'index.html': `<!doctype html>
|
|
@@ -354,6 +382,10 @@ jobs:
|
|
|
354
382
|
- run: npm ci
|
|
355
383
|
- name: Gate lint
|
|
356
384
|
run: npm run lint:rules
|
|
385
|
+
# Every part of the app says in one line what it is for, so the catalogue
|
|
386
|
+
# (\`npm run ui:find\`) can offer it before someone builds it again.
|
|
387
|
+
- name: Every part is described
|
|
388
|
+
run: npm run registry:check
|
|
357
389
|
`,
|
|
358
390
|
|
|
359
391
|
'src/index.css': `@import '@estiva-app/ui/tokens.css';
|
|
@@ -374,6 +406,7 @@ body,
|
|
|
374
406
|
interface ImportMetaEnv {
|
|
375
407
|
readonly VITE_ESTIVA_ID_ORIGIN?: string
|
|
376
408
|
readonly VITE_ESTIVA_ID_CLIENT_ID?: string
|
|
409
|
+
readonly VITE_RELAY_URL?: string
|
|
377
410
|
}
|
|
378
411
|
`,
|
|
379
412
|
'src/config.ts': `/** What the app is called on screen. */
|
|
@@ -388,6 +421,22 @@ export const ID_CONFIG: { base: string; clientId: string } | null =
|
|
|
388
421
|
import.meta.env.VITE_ESTIVA_ID_ORIGIN && import.meta.env.VITE_ESTIVA_ID_CLIENT_ID
|
|
389
422
|
? { base: import.meta.env.VITE_ESTIVA_ID_ORIGIN.replace(/\\/+$/, ''), clientId: import.meta.env.VITE_ESTIVA_ID_CLIENT_ID }
|
|
390
423
|
: null
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* The relay, the workspace this app reads and writes, or \`null\` when this build
|
|
427
|
+
* has none. Empty is a real mode too: the app runs alone and opens no connection,
|
|
428
|
+
* and a local build can never reach the real relay by accident. See .env.example.
|
|
429
|
+
*/
|
|
430
|
+
export const RELAY_URL: string | null = import.meta.env.VITE_RELAY_URL?.trim().replace(/\\/+$/, '') || null
|
|
431
|
+
|
|
432
|
+
/** The relay as a person would name it: host and port, no scheme. */
|
|
433
|
+
export function relayLabel(url: string): string {
|
|
434
|
+
try {
|
|
435
|
+
return new URL(url).host
|
|
436
|
+
} catch {
|
|
437
|
+
return url
|
|
438
|
+
}
|
|
439
|
+
}
|
|
391
440
|
`,
|
|
392
441
|
'src/auth/estivaId.ts': `import { createEstivaId, type ShellReason, type StoredToken } from '@estiva-app/identity'
|
|
393
442
|
import { ID_CONFIG } from '../config'
|
|
@@ -572,6 +621,131 @@ export function AuthShell({ state, onContinue }: AuthShellProps) {
|
|
|
572
621
|
</div>
|
|
573
622
|
)
|
|
574
623
|
}
|
|
624
|
+
`,
|
|
625
|
+
'src/relay/client.ts': `import { signViaEstivaId } from '@estiva-app/identity'
|
|
626
|
+
import { browserOnlineSource, createLiveClientHolder, type LiveClient } from '@estiva-app/platform'
|
|
627
|
+
import { currentToken } from '../auth/estivaId'
|
|
628
|
+
import { ID_CONFIG, RELAY_URL } from '../config'
|
|
629
|
+
|
|
630
|
+
/**
|
|
631
|
+
* The event kinds this app reads. They are the app's own decision, and the one
|
|
632
|
+
* blank a new app fills in: Ship reads seven (projects, issues, changes,
|
|
633
|
+
* messages, comments, deletions and edits), Peek its own. Until there are kinds
|
|
634
|
+
* here the app is connected and reads nothing, which is a correct state.
|
|
635
|
+
*/
|
|
636
|
+
export const KINDS: number[] = []
|
|
637
|
+
|
|
638
|
+
/**
|
|
639
|
+
* The tab's one relay client. \`@estiva-app/platform\` hands out a holder and
|
|
640
|
+
* keeps nothing, so this line is where "one connection per tab" lives. The relay
|
|
641
|
+
* signs a connection in once and caps subscriptions per connection, so a
|
|
642
|
+
* connection per component is wrong, not only wasteful; React's StrictMode,
|
|
643
|
+
* which mounts everything twice, is what would show it.
|
|
644
|
+
*
|
|
645
|
+
* Peek and Ship hold theirs the same way. Everything the client needs is handed
|
|
646
|
+
* in and nothing here reads the app's own data, so it could move into
|
|
647
|
+
* \`@estiva-app/platform\` as it is, if that is ever worth doing.
|
|
648
|
+
*/
|
|
649
|
+
const holder = createLiveClientHolder()
|
|
650
|
+
|
|
651
|
+
/**
|
|
652
|
+
* The client, or \`null\` when this build cannot have one: no relay is set, or
|
|
653
|
+
* there is no sign-in. The relay refuses anything before sign-in, so a socket
|
|
654
|
+
* without it would only connect, be refused and retry.
|
|
655
|
+
*/
|
|
656
|
+
export function relayClient(): LiveClient | null {
|
|
657
|
+
const base = ID_CONFIG?.base
|
|
658
|
+
if (!RELAY_URL || !base) return null
|
|
659
|
+
return holder.get({
|
|
660
|
+
relayUrl: RELAY_URL,
|
|
661
|
+
// Read on every connect, never kept: a token kept from the first connect
|
|
662
|
+
// outlives a silent renewal as a dead one, and the next reconnect fails.
|
|
663
|
+
getCredential: () => {
|
|
664
|
+
const token = currentToken()
|
|
665
|
+
return token ? { accessToken: token.accessToken, pubkey: token.pubkey } : null
|
|
666
|
+
},
|
|
667
|
+
// \`expectedPubkey\` is a check, not a request. Estiva ID signs as the token's
|
|
668
|
+
// owner whatever it is handed, so a mismatch comes back as a valid event
|
|
669
|
+
// signed by somebody else. Always pass it.
|
|
670
|
+
sign: (unsigned, token, expectedPubkey) => signViaEstivaId(unsigned, { base, token, expectedPubkey }),
|
|
671
|
+
online: typeof window === 'undefined' ? undefined : browserOnlineSource(window),
|
|
672
|
+
// Names this app in the relay's own logs.
|
|
673
|
+
subscriptionPrefix: '${name}-',
|
|
674
|
+
log: (message, detail) => console.debug('[relay]', message, detail ?? ''),
|
|
675
|
+
})
|
|
676
|
+
}
|
|
677
|
+
`,
|
|
678
|
+
'src/relay/useRelayState.ts': `import type { RelayState } from '@estiva-app/protocol'
|
|
679
|
+
import { useSyncExternalStore } from 'react'
|
|
680
|
+
import { relayClient } from './client'
|
|
681
|
+
|
|
682
|
+
const subscribe = (onChange: () => void): (() => void) => relayClient()?.onState(onChange) ?? (() => {})
|
|
683
|
+
const snapshot = (): RelayState | 'off' => relayClient()?.state() ?? 'off'
|
|
684
|
+
|
|
685
|
+
/** The connection's state, for a page to show: \`'off'\` when this build has no client. */
|
|
686
|
+
export function useRelayState(): RelayState | 'off' {
|
|
687
|
+
return useSyncExternalStore(subscribe, snapshot, () => 'off')
|
|
688
|
+
}
|
|
689
|
+
`,
|
|
690
|
+
'src/relay/client.test.ts': `import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
691
|
+
|
|
692
|
+
/**
|
|
693
|
+
* The tab's one relay connection. A fake socket stands in for the relay, so this
|
|
694
|
+
* runs with no network, and counts the sockets opened.
|
|
695
|
+
*/
|
|
696
|
+
class FakeSocket {
|
|
697
|
+
static opened = 0
|
|
698
|
+
onopen: (() => void) | null = null
|
|
699
|
+
onmessage: ((event: { data: unknown }) => void) | null = null
|
|
700
|
+
onclose: (() => void) | null = null
|
|
701
|
+
onerror: (() => void) | null = null
|
|
702
|
+
constructor() {
|
|
703
|
+
FakeSocket.opened += 1
|
|
704
|
+
}
|
|
705
|
+
send() {}
|
|
706
|
+
close() {}
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
afterEach(() => {
|
|
710
|
+
vi.unstubAllEnvs()
|
|
711
|
+
vi.unstubAllGlobals()
|
|
712
|
+
vi.resetModules()
|
|
713
|
+
FakeSocket.opened = 0
|
|
714
|
+
})
|
|
715
|
+
|
|
716
|
+
describe('the relay client', () => {
|
|
717
|
+
it('opens no connection when no relay is set', async () => {
|
|
718
|
+
vi.stubEnv('VITE_RELAY_URL', '')
|
|
719
|
+
vi.stubEnv('VITE_ESTIVA_ID_ORIGIN', 'http://localhost:8787')
|
|
720
|
+
vi.stubEnv('VITE_ESTIVA_ID_CLIENT_ID', '${name}')
|
|
721
|
+
vi.stubGlobal('WebSocket', FakeSocket)
|
|
722
|
+
const { relayClient } = await import('./client')
|
|
723
|
+
expect(relayClient()).toBeNull()
|
|
724
|
+
expect(FakeSocket.opened).toBe(0)
|
|
725
|
+
})
|
|
726
|
+
|
|
727
|
+
it('opens no connection with a relay and no sign-in', async () => {
|
|
728
|
+
vi.stubEnv('VITE_RELAY_URL', 'http://localhost:3000')
|
|
729
|
+
vi.stubEnv('VITE_ESTIVA_ID_ORIGIN', '')
|
|
730
|
+
vi.stubGlobal('WebSocket', FakeSocket)
|
|
731
|
+
const { relayClient } = await import('./client')
|
|
732
|
+
expect(relayClient()).toBeNull()
|
|
733
|
+
expect(FakeSocket.opened).toBe(0)
|
|
734
|
+
})
|
|
735
|
+
|
|
736
|
+
it('holds one connection per tab, however often it is asked', async () => {
|
|
737
|
+
vi.stubEnv('VITE_RELAY_URL', 'http://localhost:3000')
|
|
738
|
+
vi.stubEnv('VITE_ESTIVA_ID_ORIGIN', 'http://localhost:8787')
|
|
739
|
+
vi.stubEnv('VITE_ESTIVA_ID_CLIENT_ID', '${name}')
|
|
740
|
+
vi.stubGlobal('WebSocket', FakeSocket)
|
|
741
|
+
const { relayClient } = await import('./client')
|
|
742
|
+
const first = relayClient()
|
|
743
|
+
expect(first).not.toBeNull()
|
|
744
|
+
expect(relayClient()).toBe(first)
|
|
745
|
+
expect(FakeSocket.opened).toBe(1)
|
|
746
|
+
first?.close()
|
|
747
|
+
})
|
|
748
|
+
})
|
|
575
749
|
`,
|
|
576
750
|
'src/main.tsx': `import { StrictMode } from 'react'
|
|
577
751
|
import { createRoot } from 'react-dom/client'
|
|
@@ -605,13 +779,15 @@ void (async () => {
|
|
|
605
779
|
import { IconHome } from '@tabler/icons-react'
|
|
606
780
|
import { useEffect, useState } from 'react'
|
|
607
781
|
import { beginSignOut, currentToken, whoAmI } from './auth/estivaId'
|
|
608
|
-
import { APP_TITLE, ID_CONFIG } from './config'
|
|
782
|
+
import { APP_TITLE, ID_CONFIG, RELAY_URL } from './config'
|
|
609
783
|
import { HomePage } from './pages/HomePage'
|
|
784
|
+
import { useRelayState } from './relay/useRelayState'
|
|
610
785
|
|
|
611
786
|
/** The frame: the package's AppShell with a sidebar, and the one page. */
|
|
612
787
|
export function App() {
|
|
613
788
|
const signedIn = currentToken() !== null
|
|
614
789
|
const [me, setMe] = useState<Identity>({})
|
|
790
|
+
const relayState = useRelayState()
|
|
615
791
|
|
|
616
792
|
useEffect(() => {
|
|
617
793
|
if (!signedIn) return
|
|
@@ -634,19 +810,53 @@ export function App() {
|
|
|
634
810
|
</Sidebar>
|
|
635
811
|
}
|
|
636
812
|
>
|
|
637
|
-
<HomePage />
|
|
813
|
+
<HomePage relay={RELAY_URL} state={relayState} name={me.name} />
|
|
638
814
|
</AppShell>
|
|
639
815
|
)
|
|
640
816
|
}
|
|
641
817
|
`,
|
|
642
|
-
'src/pages/HomePage.tsx': `import {
|
|
818
|
+
'src/pages/HomePage.tsx': `import type { RelayState } from '@estiva-app/protocol'
|
|
819
|
+
import { EmptyState } from '@estiva-app/ui'
|
|
820
|
+
import { relayLabel } from '../config'
|
|
821
|
+
|
|
822
|
+
export interface HomePageProps {
|
|
823
|
+
/** The relay this build is set to, or \`null\` when the app runs alone. */
|
|
824
|
+
relay: string | null
|
|
825
|
+
/** The connection's state: \`'off'\` when this build has no client. */
|
|
826
|
+
state: RelayState | 'off'
|
|
827
|
+
/** The signed-in person's name, once Estiva ID has said it. */
|
|
828
|
+
name?: string
|
|
829
|
+
}
|
|
643
830
|
|
|
644
|
-
|
|
645
|
-
|
|
831
|
+
const SAYS: Record<RelayState, (host: string, name?: string) => string> = {
|
|
832
|
+
connecting: (host) => \`Connecting to \${host}…\`,
|
|
833
|
+
authenticating: (host) => \`Signing in to \${host}…\`,
|
|
834
|
+
live: (host, name) => (name ? \`Connected to \${host} as \${name}.\` : \`Connected to \${host}.\`),
|
|
835
|
+
reconnecting: (host) => \`Reconnecting to \${host}…\`,
|
|
836
|
+
failed: (host) => \`Could not connect to \${host}.\`,
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
/**
|
|
840
|
+
* The first page. What it becomes is this app's own work; until then it says
|
|
841
|
+
* whether the app is connected to the relay, and as whom.
|
|
842
|
+
*
|
|
843
|
+
* The empty state goes straight into the frame's \`main\`, with no box around it:
|
|
844
|
+
* \`main\` is a flex column, so the empty state takes the room left and centres
|
|
845
|
+
* in it both ways, as its own page says. A box around it would place it
|
|
846
|
+
* instead, and no gate reads a box. The connection is a quiet caption after it,
|
|
847
|
+
* at the foot of the page.
|
|
848
|
+
*/
|
|
849
|
+
export function HomePage({ relay, state, name }: HomePageProps) {
|
|
850
|
+
const line = !relay
|
|
851
|
+
? 'Running alone: no relay is set. Set VITE_RELAY_URL in .env.local to connect.'
|
|
852
|
+
: state === 'off'
|
|
853
|
+
? \`Not connected: \${relayLabel(relay)} needs sign-in, and this build has none.\`
|
|
854
|
+
: SAYS[state](relayLabel(relay), name)
|
|
646
855
|
return (
|
|
647
|
-
|
|
856
|
+
<>
|
|
648
857
|
<EmptyState message="Nothing here yet." />
|
|
649
|
-
|
|
858
|
+
<p className="px-6 pb-6 text-center text-caption text-text-muted">{line}</p>
|
|
859
|
+
</>
|
|
650
860
|
)
|
|
651
861
|
}
|
|
652
862
|
`,
|
|
@@ -657,12 +867,28 @@ const meta = {
|
|
|
657
867
|
title: 'Pages/Home',
|
|
658
868
|
component: HomePage,
|
|
659
869
|
parameters: { layout: 'fullscreen' },
|
|
870
|
+
// Stands in for the frame's main: a flex column the height of the screen,
|
|
871
|
+
// which is what the page is drawn into in the app.
|
|
872
|
+
decorators: [
|
|
873
|
+
(Story) => (
|
|
874
|
+
<div className="flex h-screen flex-col">
|
|
875
|
+
<Story />
|
|
876
|
+
</div>
|
|
877
|
+
),
|
|
878
|
+
],
|
|
660
879
|
} satisfies Meta<typeof HomePage>
|
|
661
880
|
|
|
662
881
|
export default meta
|
|
663
882
|
type Story = StoryObj<typeof meta>
|
|
664
883
|
|
|
665
|
-
|
|
884
|
+
/** No relay set: the app runs alone, the way it starts. */
|
|
885
|
+
export const RunningAlone: Story = { args: { relay: null, state: 'off' } }
|
|
886
|
+
|
|
887
|
+
/** A relay set, before it has signed the connection in. */
|
|
888
|
+
export const Connecting: Story = { args: { relay: 'http://localhost:3000', state: 'connecting' } }
|
|
889
|
+
|
|
890
|
+
/** Connected, as the person who signed in. */
|
|
891
|
+
export const Connected: Story = { args: { relay: 'http://localhost:3000', state: 'live', name: 'Alex Kim' } }
|
|
666
892
|
`,
|
|
667
893
|
'src/App.test.tsx': `import { render, screen } from '@testing-library/react'
|
|
668
894
|
import { describe, expect, it } from 'vitest'
|
|
@@ -674,6 +900,7 @@ describe('${title}', () => {
|
|
|
674
900
|
render(<App />)
|
|
675
901
|
expect(screen.getAllByText(APP_TITLE).length).toBeGreaterThan(0)
|
|
676
902
|
expect(screen.getByText('Nothing here yet.')).toBeTruthy()
|
|
903
|
+
expect(screen.getByText(/^Running alone/)).toBeTruthy()
|
|
677
904
|
expect(screen.getByRole('link', { name: 'Home' })).toBeTruthy()
|
|
678
905
|
})
|
|
679
906
|
})
|
|
@@ -719,6 +946,40 @@ With no settings it runs **anonymous**: no sign-in is offered, and nothing reach
|
|
|
719
946
|
the real Estiva ID. To sign in, copy \`.env.example\` to \`.env.local\` and fill it in.
|
|
720
947
|
The app must first be registered with that Estiva ID as its own app.
|
|
721
948
|
|
|
949
|
+
## The relay
|
|
950
|
+
|
|
951
|
+
${title} is connected to the relay, the workspace, from its first commit, as
|
|
952
|
+
whoever signed in. \`src/relay/client.ts\` holds the tab's one connection: ask it
|
|
953
|
+
for \`relayClient()\`, and never open a socket of your own. The home page shows the
|
|
954
|
+
connection's state.
|
|
955
|
+
|
|
956
|
+
With \`VITE_RELAY_URL\` empty it runs alone and opens no connection. The relay
|
|
957
|
+
also needs sign-in, so with a relay and no sign-in there is no connection either.
|
|
958
|
+
|
|
959
|
+
Three packages come with it: \`@estiva-app/protocol\` (the wire: events, ids,
|
|
960
|
+
signing, the relay clients), \`@estiva-app/platform\` (the one connection a tab
|
|
961
|
+
holds) and \`@estiva-app/interop\` (showing another app's objects, from the
|
|
962
|
+
manifest that app publishes).
|
|
963
|
+
|
|
964
|
+
## What you fill in
|
|
965
|
+
|
|
966
|
+
1. \`.env.local\`: Estiva ID and the relay (see \`.env.example\`).
|
|
967
|
+
2. \`KINDS\` in \`src/relay/client.ts\`: the event kinds ${title} reads. Until then
|
|
968
|
+
it is connected and reads nothing.
|
|
969
|
+
3. The product: its pages, and the fold that turns what arrives on the
|
|
970
|
+
connection into the app's state.
|
|
971
|
+
|
|
972
|
+
Before ${title} can sign in on the real Estiva ID it has to be registered there
|
|
973
|
+
as its own app, with:
|
|
974
|
+
|
|
975
|
+
- its exact redirect address
|
|
976
|
+
- every event kind it will sign, **including 22242**, the relay's sign-in handshake
|
|
977
|
+
- the relay's address, allowed for that handshake. Estiva ID signs a handshake
|
|
978
|
+
only for a relay both the app and the deployment allow.
|
|
979
|
+
|
|
980
|
+
Anything left out fails at the very last step: every screen looks right, and
|
|
981
|
+
nothing arrives.
|
|
982
|
+
|
|
722
983
|
## The checks
|
|
723
984
|
|
|
724
985
|
| command | what |
|
|
@@ -729,6 +990,8 @@ The app must first be registered with that Estiva ID as its own app.
|
|
|
729
990
|
| \`npm test\` | the tests |
|
|
730
991
|
| \`npm run build\` | the build |
|
|
731
992
|
| \`npm run gates:status\` | which gates are on, read from the code |
|
|
993
|
+
| \`npm run ui:find <words>\` | what the package and this app already have for it |
|
|
994
|
+
| \`npm run registry:check\` | every part says what it is for — CI's job \`gate\` |
|
|
732
995
|
| \`npm run storybook\` | the stories |
|
|
733
996
|
|
|
734
997
|
The gates are the package's, imported rather than copied, so a rule written later
|
|
@@ -757,6 +1020,16 @@ only with its reason on the line above, \`// @estiva-escape: <reason>\`, never w
|
|
|
757
1020
|
|
|
758
1021
|
**Tokens only.** Colours, type, corners and shadows come from the package's preset.
|
|
759
1022
|
|
|
1023
|
+
**One relay connection per tab.** \`relayClient()\` in \`src/relay/client.ts\` is the
|
|
1024
|
+
connection. Never open a socket and never make a second holder: the relay signs a
|
|
1025
|
+
connection in once, and caps subscriptions per connection.
|
|
1026
|
+
|
|
1027
|
+
**Look before you build.** \`npm run ui:find <what it does>\` searches the package's
|
|
1028
|
+
parts and this app's own. Use what it finds.
|
|
1029
|
+
|
|
1030
|
+
**Every part says what it is for.** A new part gets a one-line \`/** … */\` comment
|
|
1031
|
+
directly above it. \`npm run registry:check\` and CI's job \`gate\` refuse a part without one.
|
|
1032
|
+
|
|
760
1033
|
**The count starts at zero and stays there** (\`.gates-count.json\`, \`docs/GATES-DEBT.md\`).
|
|
761
1034
|
|
|
762
1035
|
What each gate is and how it is wired: the package's README,
|
|
@@ -785,7 +1058,7 @@ export function createApp(options: CreateAppOptions): string {
|
|
|
785
1058
|
const dir = resolve(options.parent ?? process.cwd(), options.name)
|
|
786
1059
|
if (existsSync(dir)) throw new Error(`${dir} already exists: create-estiva-app never writes into a folder that is there`)
|
|
787
1060
|
const pkg = JSON.parse(readFileSync(join(packageRoot, 'package.json'), 'utf8')) as PackageJson
|
|
788
|
-
const missing =
|
|
1061
|
+
const missing = ASKED_OF_NPM.filter((n) => !pkg.devDependencies[n] && !options.versions?.[n])
|
|
789
1062
|
const versions = { ...(missing.length ? askNpm(missing) : {}), ...options.versions }
|
|
790
1063
|
const files = appFiles({ ...options, versions })
|
|
791
1064
|
for (const [rel, text] of Object.entries(files)) {
|
package/src/gates/status.ts
CHANGED
|
@@ -71,6 +71,8 @@ export interface GateHelpers {
|
|
|
71
71
|
protectedBranch(pattern: RegExp): CheckResult
|
|
72
72
|
gh(args: string[], label: string): CheckResult
|
|
73
73
|
share(files: string[], test: (file: string) => boolean, label: string): CheckResult
|
|
74
|
+
/** Build the catalogue of the app in `appDir` (UIG-13): passes when every part is described and sorted. */
|
|
75
|
+
catalogue(appDir: string): Promise<CheckResult>
|
|
74
76
|
}
|
|
75
77
|
|
|
76
78
|
export interface GateCheck {
|
|
@@ -161,6 +163,20 @@ export function helpers(ROOT: string): GateHelpers {
|
|
|
161
163
|
|
|
162
164
|
return {
|
|
163
165
|
PASS, FAIL, PART, UNKNOWN, exists, read, listFiles,
|
|
166
|
+
// The registry bundle's own builder, loaded only when a check asks for it:
|
|
167
|
+
// it reads TypeScript with TypeScript, which the other checks never need.
|
|
168
|
+
async catalogue(appDir) {
|
|
169
|
+
try {
|
|
170
|
+
const { buildAppRegistry, validateRegistry } = await import('../registry/index')
|
|
171
|
+
const registry = buildAppRegistry({ root: abs(appDir) })
|
|
172
|
+
const problems = validateRegistry(registry)
|
|
173
|
+
if (problems.length) return FAIL(`${problems.length} problems, the first: ${problems[0]}`)
|
|
174
|
+
return PASS(`${registry.entries.length} parts in ${registry.builtFrom.files} files, each described and sorted`)
|
|
175
|
+
} catch (error) {
|
|
176
|
+
const lines = String(error instanceof Error ? error.message : error).split('\n')
|
|
177
|
+
return FAIL(lines.length > 1 ? `${lines.length - 1} problems, the first: ${lines[1].trim()}` : lines[0])
|
|
178
|
+
}
|
|
179
|
+
},
|
|
164
180
|
|
|
165
181
|
file(rel) {
|
|
166
182
|
return exists(rel) ? PASS(`${rel} exists`) : FAIL(`${rel} does not exist`)
|
package/src/index.ts
CHANGED
|
@@ -80,6 +80,7 @@ export { Property, type PropertyProps } from './Property'
|
|
|
80
80
|
export { Reaction, type ReactionProps } from './Reaction'
|
|
81
81
|
export { SearchInput, type SearchInputProps } from './SearchInput'
|
|
82
82
|
export { SectionHeader, type SectionAction, type SectionHeaderProps } from './SectionHeader'
|
|
83
|
+
export { ContainerHeader, type ContainerHeaderProps } from './ContainerHeader'
|
|
83
84
|
export { CollapsibleSection, type CollapsibleSectionProps } from './CollapsibleSection'
|
|
84
85
|
export { SectionLabel } from './SectionLabel'
|
|
85
86
|
export { Tabs, type TabDef, type TabsProps } from './Tabs'
|