@starci/hfs 4.9.6 → 4.9.9
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/CHANGELOG.md +27 -0
- package/README.md +24 -0
- package/package.json +1 -1
- package/runtime/knowledge/hfs/canon-pins.yaml +6 -6
- package/scaffold/add-fe-layout.mjs +46 -0
- package/scaffold/add-fe-overlay.mjs +108 -0
- package/scaffold/add-fe-page.mjs +137 -0
- package/scaffold/add-fe-unit.mjs +288 -0
- package/scaffold/add.mjs +20 -1
- package/scaffold/app.mjs +3 -1
- package/src/main.mjs +11 -5
- package/templates/app/skeleton-lite/supabase/migrations/__timestamp___baseline.sql +41 -0
- package/templates/be/skeleton/apps/__app__/src/compose/platform.ts +14 -2
- package/templates/be/skeleton/apps/cli/src/compose/platform.ts +3 -2
- package/templates/be/skeleton/src/modules/platform/http-security/index.ts +1 -0
- package/templates/be/skeleton/src/modules/platform/http-security/persistence/connection.ts +8 -0
- package/templates/be/skeleton/src/modules/platform/http-security/persistence/entities/rate-window.entity.ts +17 -0
- package/templates/be/skeleton/src/modules/platform/http-security/persistence/migrations/1789900000000-create-rate-windows.ts +24 -0
- package/templates/be/skeleton/src/modules/platform/http-security/persistence/rate-limit.rows.ts +5 -0
- package/templates/be/skeleton/src/modules/platform/http-security/persistence/rate-limit.sql.ts +20 -0
- package/templates/be/skeleton/src/modules/platform/http-security/rate-limit.guard.spec.ts +72 -46
- package/templates/be/skeleton/src/modules/platform/http-security/rate-limit.guard.ts +20 -25
- package/templates/be/skeleton-lite/src/modules/platform/database/errors/database.error.ts +3 -0
- package/templates/be/skeleton-lite/src/modules/platform/database/index.ts +2 -1
- package/templates/fe/skeleton/apps/app/src/features/pages/AppHomePage/component.tsx +7 -5
- package/templates/fe/skeleton/apps/landing/src/features/pages/LandingPage/component.tsx +7 -6
- package/templates/fe/skeleton-lite/apps/web/src/features/pages/AppHomePage/component.tsx +4 -5
- package/templates/fe/units/block/component.tsx.tpl +35 -0
- package/templates/fe/units/block/index.tsx.tpl +29 -0
- package/templates/fe/units/block/stories.tsx.tpl +29 -0
- package/templates/fe/units/branch/index.tsx.tpl +23 -0
- package/templates/fe/units/composite/index.tsx.tpl +18 -0
- package/templates/fe/units/layout/index.tsx.tpl +11 -0
- package/templates/fe/units/layout/route-layout.tsx.tpl +9 -0
- package/templates/fe/units/leaf/index.tsx.tpl +10 -0
- package/templates/fe/units/overlay/component.tsx.tpl +27 -0
- package/templates/fe/units/overlay/index.tsx.tpl +23 -0
- package/templates/fe/units/overlay/stories.tsx.tpl +29 -0
- package/templates/fe/units/page/component.tsx.tpl +35 -0
- package/templates/fe/units/page/error-component.tsx.tpl +18 -0
- package/templates/fe/units/page/error-index.tsx.tpl +11 -0
- package/templates/fe/units/page/index.tsx.tpl +29 -0
- package/templates/fe/units/page/route-error.tsx.tpl +10 -0
- package/templates/fe/units/page/route-loading.tsx.tpl +6 -0
- package/templates/fe/units/page/route-page.tsx.tpl +9 -0
- package/templates/fe/units/page/stories.tsx.tpl +34 -0
- package/templates/fe/units/slot/SlotView.tsx.tpl +28 -0
- package/templates/fe/units/slot/data-slot.ts.tpl +18 -0
- package/templates/fe/units/slot/slot.shared.ts.tpl +35 -0
- package/templates/fe/units/slot/useSlotLabels.ts.tpl +8 -0
|
@@ -3,29 +3,34 @@ import type { ExecutionContext, Type } from "@nestjs/common"
|
|
|
3
3
|
import type { Request } from "express"
|
|
4
4
|
import { Reflector } from "@nestjs/core"
|
|
5
5
|
import { Test } from "@nestjs/testing"
|
|
6
|
-
import { FakeClock, mock } from "@starci/jest-preset"
|
|
6
|
+
import { FakeClock, mock, mockEntityManager } from "@starci/jest-preset"
|
|
7
|
+
import type { MockEntityManager } from "@starci/jest-preset"
|
|
7
8
|
import { CLOCK } from "@modules/platform/clock"
|
|
9
|
+
import { PRIMARY_ENTITY_MANAGER } from "@modules/platform/database"
|
|
8
10
|
import { HttpSecurityErrorCode } from "./errors/http-security.error"
|
|
9
11
|
import { HTTP_SECURITY_OPTIONS } from "./http-security.decorators"
|
|
10
12
|
import type { HttpSecurityOptions } from "./http-security.options"
|
|
13
|
+
import { HIT_RATE_WINDOW, PRUNE_RATE_WINDOWS } from "./persistence/rate-limit.sql"
|
|
11
14
|
import { RateLimit, RateLimitGuard, RateTier } from "./rate-limit.guard"
|
|
12
15
|
|
|
13
16
|
const AT = "2026-02-03T04:05:06.000Z"
|
|
17
|
+
const RESET_AT = "2026-02-03T04:06:06.000Z"
|
|
14
18
|
const options: HttpSecurityOptions = {
|
|
15
19
|
allowedOrigins: [],
|
|
16
20
|
rateLimit: { windowMs: 60_000, defaultLimit: 2, strictLimit: 1 },
|
|
17
21
|
webhooks: {},
|
|
18
22
|
}
|
|
19
23
|
|
|
20
|
-
const build = async (
|
|
24
|
+
const build = async (entityManager: MockEntityManager, tier?: RateTier) => {
|
|
21
25
|
const reflector = mock<Reflector>()
|
|
22
26
|
reflector.getAllAndOverride.mockReturnValue(tier)
|
|
23
27
|
const moduleRef = await Test.createTestingModule({
|
|
24
28
|
providers: [
|
|
25
29
|
RateLimitGuard,
|
|
26
30
|
{ provide: Reflector, useValue: reflector },
|
|
27
|
-
{ provide: CLOCK, useValue:
|
|
31
|
+
{ provide: CLOCK, useValue: new FakeClock(AT) },
|
|
28
32
|
{ provide: HTTP_SECURITY_OPTIONS, useValue: options },
|
|
33
|
+
{ provide: PRIMARY_ENTITY_MANAGER, useValue: entityManager },
|
|
29
34
|
],
|
|
30
35
|
}).compile()
|
|
31
36
|
return { guard: moduleRef.get(RateLimitGuard), reflector }
|
|
@@ -33,79 +38,100 @@ const build = async (clock: FakeClock, tier?: RateTier) => {
|
|
|
33
38
|
|
|
34
39
|
/** An execution context of one POST from `ip` on the door; the handler and the class carry the tier metadata. */
|
|
35
40
|
const contextOf = (ip: string | undefined, handler = () => undefined, door: Type<unknown> = class Door {}) => {
|
|
36
|
-
let address = ip
|
|
37
41
|
const http = mock<ReturnType<ExecutionContext["switchToHttp"]>>()
|
|
38
|
-
http.getRequest.mockImplementation(() => mock<Request>({ headers: {}, method: "POST", ip
|
|
42
|
+
http.getRequest.mockImplementation(() => mock<Request>({ headers: {}, method: "POST", ip }))
|
|
39
43
|
const context = mock<ExecutionContext>()
|
|
40
44
|
context.switchToHttp.mockReturnValue(http)
|
|
41
45
|
context.getHandler.mockReturnValue(handler)
|
|
42
46
|
context.getClass.mockReturnValue(door)
|
|
43
|
-
return
|
|
47
|
+
return context
|
|
44
48
|
}
|
|
45
49
|
|
|
46
50
|
describe("RateLimit", () => {
|
|
47
51
|
it("marks a door with the tier it declares, and the guard counts it under that tier's limit", async () => {
|
|
48
52
|
class Door {}
|
|
49
53
|
RateLimit(RateTier.Strict)(Door)
|
|
50
|
-
const {
|
|
54
|
+
const entityManager = mockEntityManager({ query: [HIT_RATE_WINDOW, [{ hits: 2 }]] })
|
|
55
|
+
const { guard, reflector } = await build(entityManager, RateTier.Strict)
|
|
51
56
|
const handler = () => undefined
|
|
52
|
-
const { context } = contextOf("127.0.0.1", handler, Door)
|
|
53
57
|
|
|
54
58
|
expect(Reflect.getMetadata("platform.http-security.rate-tier", Door)).toBe(RateTier.Strict)
|
|
55
|
-
expect(guard.canActivate(
|
|
56
|
-
|
|
59
|
+
await expect(guard.canActivate(contextOf("127.0.0.1", handler, Door))).rejects.toMatchObject({
|
|
60
|
+
code: HttpSecurityErrorCode.RateLimited,
|
|
61
|
+
})
|
|
57
62
|
expect(reflector.getAllAndOverride).toHaveBeenCalledWith("platform.http-security.rate-tier", [handler, Door])
|
|
63
|
+
expect(entityManager.query).toHaveBeenCalledWith(HIT_RATE_WINDOW, [
|
|
64
|
+
"strict:127.0.0.1",
|
|
65
|
+
new Date(AT),
|
|
66
|
+
new Date(RESET_AT),
|
|
67
|
+
])
|
|
58
68
|
})
|
|
59
69
|
})
|
|
60
70
|
|
|
61
71
|
describe("RateLimitGuard", () => {
|
|
62
|
-
it("
|
|
63
|
-
const {
|
|
64
|
-
const {
|
|
65
|
-
|
|
66
|
-
expect(guard.canActivate(
|
|
67
|
-
expect(
|
|
68
|
-
|
|
72
|
+
it("admits a caller under the default limit of an unmarked door, counting in the shared window", async () => {
|
|
73
|
+
const entityManager = mockEntityManager({ query: [HIT_RATE_WINDOW, [{ hits: 2 }]] })
|
|
74
|
+
const { guard } = await build(entityManager)
|
|
75
|
+
|
|
76
|
+
await expect(guard.canActivate(contextOf("127.0.0.1"))).resolves.toBe(true)
|
|
77
|
+
expect(entityManager.query).toHaveBeenCalledWith(HIT_RATE_WINDOW, [
|
|
78
|
+
"default:127.0.0.1",
|
|
79
|
+
new Date(AT),
|
|
80
|
+
new Date(RESET_AT),
|
|
81
|
+
])
|
|
69
82
|
})
|
|
70
83
|
|
|
71
|
-
it("
|
|
72
|
-
const
|
|
73
|
-
const { guard } = await build(clock)
|
|
74
|
-
const { context } = contextOf("127.0.0.1")
|
|
75
|
-
|
|
76
|
-
guard.canActivate(context)
|
|
77
|
-
guard.canActivate(context)
|
|
78
|
-
expect(() => guard.canActivate(context)).toThrow(HttpSecurityErrorCode.RateLimited)
|
|
84
|
+
it("refuses a caller whose shared window is over the default limit", async () => {
|
|
85
|
+
const { guard } = await build(mockEntityManager({ query: [HIT_RATE_WINDOW, [{ hits: 3 }]] }))
|
|
79
86
|
|
|
80
|
-
|
|
87
|
+
await expect(guard.canActivate(contextOf("127.0.0.1"))).rejects.toMatchObject({
|
|
88
|
+
code: HttpSecurityErrorCode.RateLimited,
|
|
89
|
+
})
|
|
90
|
+
})
|
|
81
91
|
|
|
82
|
-
|
|
92
|
+
it("prunes the expired windows when a hit opens a fresh one", async () => {
|
|
93
|
+
const entityManager = mockEntityManager({
|
|
94
|
+
query: [
|
|
95
|
+
[HIT_RATE_WINDOW, [{ hits: 1 }]],
|
|
96
|
+
[PRUNE_RATE_WINDOWS, []],
|
|
97
|
+
],
|
|
98
|
+
})
|
|
99
|
+
const { guard } = await build(entityManager)
|
|
100
|
+
|
|
101
|
+
await expect(guard.canActivate(contextOf("127.0.0.1"))).resolves.toBe(true)
|
|
102
|
+
expect(entityManager.query).toHaveBeenCalledWith(PRUNE_RATE_WINDOWS, [new Date(AT)])
|
|
83
103
|
})
|
|
84
104
|
|
|
85
|
-
it("counts a request without an address under the unknown
|
|
86
|
-
const {
|
|
87
|
-
const {
|
|
105
|
+
it("counts a request without an address under the unknown key", async () => {
|
|
106
|
+
const entityManager = mockEntityManager({ query: [HIT_RATE_WINDOW, [{ hits: 2 }]] })
|
|
107
|
+
const { guard } = await build(entityManager)
|
|
88
108
|
|
|
89
|
-
guard.canActivate(
|
|
90
|
-
|
|
91
|
-
|
|
109
|
+
await expect(guard.canActivate(contextOf(undefined))).resolves.toBe(true)
|
|
110
|
+
expect(entityManager.query).toHaveBeenCalledWith(HIT_RATE_WINDOW, [
|
|
111
|
+
"default:unknown",
|
|
112
|
+
new Date(AT),
|
|
113
|
+
new Date(RESET_AT),
|
|
114
|
+
])
|
|
92
115
|
})
|
|
93
116
|
|
|
94
|
-
it("
|
|
95
|
-
const
|
|
96
|
-
const { guard } = await build(
|
|
97
|
-
const { context, setIp } = contextOf("0.0.0.0")
|
|
117
|
+
it("bounds the window key to the length the table stores", async () => {
|
|
118
|
+
const entityManager = mockEntityManager({ query: [HIT_RATE_WINDOW, [{ hits: 2 }]] })
|
|
119
|
+
const { guard } = await build(entityManager)
|
|
98
120
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
121
|
+
await guard.canActivate(contextOf("f".repeat(300)))
|
|
122
|
+
|
|
123
|
+
expect(entityManager.query).toHaveBeenCalledWith(HIT_RATE_WINDOW, [
|
|
124
|
+
`default:${"f".repeat(120)}`,
|
|
125
|
+
new Date(AT),
|
|
126
|
+
new Date(RESET_AT),
|
|
127
|
+
])
|
|
128
|
+
})
|
|
103
129
|
|
|
104
|
-
|
|
105
|
-
|
|
130
|
+
it("refuses the request when the store answers no window", async () => {
|
|
131
|
+
const { guard } = await build(mockEntityManager({ query: [HIT_RATE_WINDOW, []] }))
|
|
106
132
|
|
|
107
|
-
expect(guard.canActivate(
|
|
108
|
-
|
|
109
|
-
|
|
133
|
+
await expect(guard.canActivate(contextOf("127.0.0.1"))).rejects.toMatchObject({
|
|
134
|
+
code: HttpSecurityErrorCode.RateLimited,
|
|
135
|
+
})
|
|
110
136
|
})
|
|
111
137
|
})
|
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
import { Injectable, SetMetadata } from "@nestjs/common"
|
|
2
2
|
import type { CanActivate, ExecutionContext } from "@nestjs/common"
|
|
3
3
|
import type { Reflector } from "@nestjs/core"
|
|
4
|
+
import type { EntityManager } from "typeorm"
|
|
4
5
|
import { InjectClock } from "@modules/platform/clock"
|
|
5
6
|
import type { Clock } from "@modules/platform/clock"
|
|
6
7
|
import { InjectReflector } from "@modules/platform/composition"
|
|
8
|
+
import { InjectPrimaryEntityManager } from "@modules/platform/database"
|
|
7
9
|
import { HttpSecurityError, HttpSecurityErrorCode } from "./errors/http-security.error"
|
|
8
10
|
import { requestOf } from "./execution-request.mapper"
|
|
9
11
|
import { InjectHttpSecurityOptions } from "./http-security.decorators"
|
|
10
12
|
import type { HttpSecurityOptions } from "./http-security.options"
|
|
13
|
+
import type { RateWindowRow } from "./persistence/rate-limit.rows"
|
|
14
|
+
import { HIT_RATE_WINDOW, PRUNE_RATE_WINDOWS } from "./persistence/rate-limit.sql"
|
|
11
15
|
|
|
12
16
|
/** The rate limit tiers of a door. */
|
|
13
17
|
export enum RateTier {
|
|
@@ -18,29 +22,28 @@ export enum RateTier {
|
|
|
18
22
|
}
|
|
19
23
|
|
|
20
24
|
const RATE_TIER_KEY = "platform.http-security.rate-tier"
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
interface Bucket {
|
|
24
|
-
count: number
|
|
25
|
-
resetAt: number
|
|
26
|
-
}
|
|
25
|
+
/** The longest window key the table stores: the tier, a colon and the caller address. */
|
|
26
|
+
const KEY_MAX = 128
|
|
27
27
|
|
|
28
28
|
/** Marks a door with a rate limit tier other than the default. */
|
|
29
29
|
export const RateLimit = (tier: RateTier): ReturnType<typeof SetMetadata> => SetMetadata(RATE_TIER_KEY, tier)
|
|
30
30
|
|
|
31
31
|
@Injectable()
|
|
32
|
-
/**
|
|
32
|
+
/**
|
|
33
|
+
* The first app guard: a fixed-window counter per caller address and tier, stamped by the Clock and kept in the primary
|
|
34
|
+
* database, so every replica counts against the same window. A hit that opens a fresh window also prunes a bounded batch of
|
|
35
|
+
* expired windows.
|
|
36
|
+
*/
|
|
33
37
|
export class RateLimitGuard implements CanActivate {
|
|
34
|
-
private readonly buckets = new Map<string, Bucket>()
|
|
35
|
-
|
|
36
38
|
constructor(
|
|
37
39
|
@InjectReflector() private readonly reflector: Reflector,
|
|
38
40
|
@InjectClock() private readonly clock: Clock,
|
|
39
41
|
@InjectHttpSecurityOptions() private readonly options: HttpSecurityOptions,
|
|
42
|
+
@InjectPrimaryEntityManager() private readonly entityManager: EntityManager,
|
|
40
43
|
) {}
|
|
41
44
|
|
|
42
45
|
/** Counts the request and refuses it when the caller is over the limit of the door tier. */
|
|
43
|
-
canActivate(context: ExecutionContext): boolean {
|
|
46
|
+
async canActivate(context: ExecutionContext): Promise<boolean> {
|
|
44
47
|
const tier =
|
|
45
48
|
this.reflector.getAllAndOverride<RateTier | undefined>(RATE_TIER_KEY, [
|
|
46
49
|
context.getHandler(),
|
|
@@ -48,21 +51,13 @@ export class RateLimitGuard implements CanActivate {
|
|
|
48
51
|
]) ?? RateTier.Default
|
|
49
52
|
const limit =
|
|
50
53
|
tier === RateTier.Strict ? this.options.rateLimit.strictLimit : this.options.rateLimit.defaultLimit
|
|
51
|
-
const now = this.clock.now()
|
|
52
|
-
const key = `${tier}:${requestOf(context).ip ?? "unknown"}
|
|
53
|
-
const
|
|
54
|
-
const
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
if (this.buckets.size > BUCKETS_MAX) this.prune(now)
|
|
59
|
-
if (bucket.count > limit) throw new HttpSecurityError({ code: HttpSecurityErrorCode.RateLimited })
|
|
54
|
+
const now = this.clock.now()
|
|
55
|
+
const key = `${tier}:${requestOf(context).ip ?? "unknown"}`.slice(0, KEY_MAX)
|
|
56
|
+
const resetAt = new Date(now.getTime() + this.options.rateLimit.windowMs)
|
|
57
|
+
const rows: Array<RateWindowRow> = await this.entityManager.query(HIT_RATE_WINDOW, [key, now, resetAt])
|
|
58
|
+
const hits = rows[0]?.hits ?? Number.POSITIVE_INFINITY
|
|
59
|
+
if (hits === 1) await this.entityManager.query(PRUNE_RATE_WINDOWS, [now])
|
|
60
|
+
if (hits > limit) throw new HttpSecurityError({ code: HttpSecurityErrorCode.RateLimited })
|
|
60
61
|
return true
|
|
61
62
|
}
|
|
62
|
-
|
|
63
|
-
private prune(now: number): void {
|
|
64
|
-
for (const [key, bucket] of this.buckets) {
|
|
65
|
-
if (bucket.resetAt <= now) this.buckets.delete(key)
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
63
|
}
|
|
@@ -5,11 +5,14 @@ import type { ErrorKind } from "@modules/platform/errors"
|
|
|
5
5
|
export enum DatabaseErrorCode {
|
|
6
6
|
/** A configured connection has no declared EntityManager injection token. */
|
|
7
7
|
ConnectionUndeclared = "DATABASE_CONNECTION_UNDECLARED",
|
|
8
|
+
/** A dynamic SQL identifier was not on the names its caller allows. */
|
|
9
|
+
IdentifierRejected = "DATABASE_IDENTIFIER_REJECTED",
|
|
8
10
|
}
|
|
9
11
|
|
|
10
12
|
/** How each database code travels. */
|
|
11
13
|
export const DATABASE_ERROR_KINDS: Record<DatabaseErrorCode, ErrorKind> = {
|
|
12
14
|
[DatabaseErrorCode.ConnectionUndeclared]: "internal",
|
|
15
|
+
[DatabaseErrorCode.IdentifierRejected]: "internal",
|
|
13
16
|
}
|
|
14
17
|
|
|
15
18
|
/** The one error class of the database capability. */
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
export { DatabaseModule } from "./database.module"
|
|
2
|
-
export { DATABASE_PROBE, InjectDatabaseProbe } from "./primary.decorators"
|
|
2
|
+
export { DATABASE_PROBE, InjectDatabaseProbe, InjectPrimaryEntityManager, PRIMARY_ENTITY_MANAGER } from "./primary.decorators"
|
|
3
3
|
export { DatabaseProbeService } from "./database-probe.service"
|
|
4
4
|
export type { DatabaseConnectionOptions } from "./database.options"
|
|
5
5
|
export { PRIMARY_DATA_SOURCE } from "./database.port"
|
|
6
|
+
export { sql } from "./database.sql"
|
|
6
7
|
export { DATABASE_ERROR_KINDS } from "./errors/database.error"
|
|
7
8
|
export { parsePrimaryDatabaseConfig } from "./primary.config"
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { Page } from "@starci/grammar/common"
|
|
2
2
|
import { SignInForm } from "@/components/blocks/SignInForm"
|
|
3
3
|
import { SignOutButton } from "@/components/blocks/SignOutButton"
|
|
4
4
|
|
|
@@ -8,11 +8,13 @@ export type AppHomePageBaseProps = {
|
|
|
8
8
|
readonly props: { readonly title: string }
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
-
/**
|
|
11
|
+
/**
|
|
12
|
+
* Draw the front door of the product app as one Grammar Page (R325): the title heads the page band, and the body holds
|
|
13
|
+
* the sign-in form for a signed-out reader, the reader and sign-out once signed in.
|
|
14
|
+
*/
|
|
12
15
|
export const AppHomePageBase = (props: AppHomePageBaseProps) => (
|
|
13
|
-
|
|
14
|
-
<Heading level={1}>{props.props.title}</Heading>
|
|
16
|
+
<Page header={{ title: props.props.title }}>
|
|
15
17
|
<SignInForm />
|
|
16
18
|
<SignOutButton />
|
|
17
|
-
|
|
19
|
+
</Page>
|
|
18
20
|
)
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Button,
|
|
1
|
+
import { Button, Page } from "@starci/grammar/common"
|
|
2
2
|
|
|
3
3
|
/** Props for {@link LandingPageBase}. */
|
|
4
4
|
export type LandingPageBaseProps = {
|
|
@@ -12,13 +12,14 @@ export type LandingPageBaseProps = {
|
|
|
12
12
|
}
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
-
/**
|
|
15
|
+
/**
|
|
16
|
+
* Draw the public front door as one Grammar Page (R325): the welcome heads the band with one sentence about the product
|
|
17
|
+
* under it, and the body holds the way into the product app.
|
|
18
|
+
*/
|
|
16
19
|
export const LandingPageBase = (props: LandingPageBaseProps) => (
|
|
17
|
-
|
|
18
|
-
<Heading level={1}>{props.props.title}</Heading>
|
|
19
|
-
<Text tone="muted">{props.props.lede}</Text>
|
|
20
|
+
<Page header={{ title: props.props.title, description: props.props.lede }}>
|
|
20
21
|
<Button href={props.props.appHref} intent="commit">
|
|
21
22
|
{props.props.openApp}
|
|
22
23
|
</Button>
|
|
23
|
-
|
|
24
|
+
</Page>
|
|
24
25
|
)
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { Page } from "@starci/grammar/common"
|
|
2
2
|
import { SignInForm } from "@/components/blocks/SignInForm"
|
|
3
3
|
import { SignOutButton } from "@/components/blocks/SignOutButton"
|
|
4
4
|
|
|
@@ -7,11 +7,10 @@ export type AppHomePageBaseProps = {
|
|
|
7
7
|
readonly props: { readonly title: string }
|
|
8
8
|
}
|
|
9
9
|
|
|
10
|
-
/** Draws the front door: the sign-in form
|
|
10
|
+
/** Draws the front door as one Grammar Page (R325): the title heads the band, the body the sign-in form or the reader and sign-out. */
|
|
11
11
|
export const AppHomePageBase = (props: AppHomePageBaseProps) => (
|
|
12
|
-
|
|
13
|
-
<Heading level={1}>{props.props.title}</Heading>
|
|
12
|
+
<Page header={{ title: props.props.title }}>
|
|
14
13
|
<SignInForm />
|
|
15
14
|
<SignOutButton />
|
|
16
|
-
|
|
15
|
+
</Page>
|
|
17
16
|
)
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { SurfaceCard, Text } from "@starci/grammar/common"
|
|
2
|
+
import { SlotView } from "{{slotViewFrom}}"
|
|
3
|
+
import type { DataSlot, SlotLabels } from "@/modules/types"
|
|
4
|
+
{{shapeDeclarations}}
|
|
5
|
+
/** One read value as {{Name}} draws it. */
|
|
6
|
+
export interface {{Name}}Item {
|
|
7
|
+
readonly id: string
|
|
8
|
+
{{itemShapeMember}}}
|
|
9
|
+
|
|
10
|
+
/** Copy, the one read and the retry the connected {{Name}} supplies; atoms only. */
|
|
11
|
+
export interface {{Name}}BaseProps {
|
|
12
|
+
{{shapeMember}} readonly props: {
|
|
13
|
+
readonly title: string
|
|
14
|
+
{{shapeLabelsMember}} readonly item: DataSlot<{{Name}}Item>
|
|
15
|
+
readonly slotLabels: SlotLabels
|
|
16
|
+
}
|
|
17
|
+
readonly on: { readonly retry: () => void }
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** A value of the same shape as the read: the skeleton draws the ready tree from it{{placeholderShapeDoc}}. */
|
|
21
|
+
export const {{NAME_UPPER}}_PLACEHOLDER: {{Name}}Item = { id: ""{{placeholderShape}} }
|
|
22
|
+
{{shapeBody}}
|
|
23
|
+
/** Draws {{Name}} from atoms: its read renders every status through SlotView, inside the unchanged surface. */
|
|
24
|
+
export const {{Name}}Base = (props: {{Name}}BaseProps) => (
|
|
25
|
+
<SurfaceCard label={props.props.title}>
|
|
26
|
+
<SlotView
|
|
27
|
+
slot={props.props.item}
|
|
28
|
+
placeholder={{{NAME_UPPER}}_PLACEHOLDER}
|
|
29
|
+
labels={props.props.slotLabels}
|
|
30
|
+
onRetry={props.on.retry}
|
|
31
|
+
>
|
|
32
|
+
{(item, isSkeleton) => {{readyTree}}}
|
|
33
|
+
</SlotView>
|
|
34
|
+
</SurfaceCard>
|
|
35
|
+
)
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { useTranslations } from "next-intl"
|
|
4
|
+
import { mapSlot, slotOfSwr, useSlotLabels } from "@/hooks/slot"
|
|
5
|
+
import { useQuery{{Read}}Swr } from "@/hooks/swr"
|
|
6
|
+
import { {{componentImports}} } from "./component"
|
|
7
|
+
{{componentTypeImport}}
|
|
8
|
+
/** Input of {{Name}}: the id of the value it reads. */
|
|
9
|
+
type {{Name}}Props = {
|
|
10
|
+
readonly id: string
|
|
11
|
+
}
|
|
12
|
+
{{shapeOfHelper}}
|
|
13
|
+
/** The connected {{Name}}: folds its one read into a slot, {{connectedDoc}}resolves every label and the retry, and renders its pure half. */
|
|
14
|
+
export const {{Name}} = (props: {{Name}}Props) => {
|
|
15
|
+
const t = useTranslations("{{nameCamel}}")
|
|
16
|
+
const slotLabels = useSlotLabels()
|
|
17
|
+
const read = useQuery{{Read}}Swr(props.id)
|
|
18
|
+
const item = mapSlot(slotOfSwr(read), (value) => ({ id: value.id{{itemShapeMapped}} }))
|
|
19
|
+
return (
|
|
20
|
+
<{{Name}}Base
|
|
21
|
+
{{shapeAttribute}} props={{
|
|
22
|
+
title: t("title"),
|
|
23
|
+
{{shapeLabelsResolved}} item,
|
|
24
|
+
slotLabels: slotLabels(t("empty")),
|
|
25
|
+
}}
|
|
26
|
+
on={{ retry: () => void read.mutate() }}
|
|
27
|
+
/>
|
|
28
|
+
)
|
|
29
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react-vite"
|
|
2
|
+
import { {{Name}}Base } from "../../src/components/blocks/{{Name}}/component"
|
|
3
|
+
import type { {{Name}}BaseProps } from "../../src/components/blocks/{{Name}}/component"
|
|
4
|
+
import { loadingSlot } from "../../src/hooks/slot/slot.shared"
|
|
5
|
+
|
|
6
|
+
/** The ready read every story draws: one value, settled. */
|
|
7
|
+
const READY: {{Name}}BaseProps["props"] = {
|
|
8
|
+
title: "{{title}}",
|
|
9
|
+
{{shapeLabelsFixture}} item: { items: { id: "{{nameKebab}}-1"{{fixtureShape}} } },
|
|
10
|
+
slotLabels: { empty: "Nothing yet", forbidden: "Not allowed", error: "Could not load", retry: "Try again" },
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** The pure half of {{Name}} drawn from fixtures: {{storiesDoc}} and the loading twin of the first. */
|
|
14
|
+
const meta = {
|
|
15
|
+
title: "{{Name}}",
|
|
16
|
+
component: {{Name}}Base,
|
|
17
|
+
args: { {{shapeArg}}props: READY, on: { retry: () => undefined } },
|
|
18
|
+
} satisfies Meta<typeof {{Name}}Base>
|
|
19
|
+
|
|
20
|
+
export default meta
|
|
21
|
+
|
|
22
|
+
type Story = StoryObj<typeof meta>
|
|
23
|
+
{{readyStories}}
|
|
24
|
+
|
|
25
|
+
/** The loading twin of {{firstStory}}: the same tree drawn from the placeholder with every leaf resting. */
|
|
26
|
+
export const {{firstStory}}Skeleton: Story = {
|
|
27
|
+
args: { {{shapeArg}}props: { ...READY, item: loadingSlot() } },
|
|
28
|
+
parameters: { dna: { pair: "{{firstStory}}", phase: "loading" } },
|
|
29
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { useState } from "react"
|
|
2
|
+
import type { ReactNode } from "react"
|
|
3
|
+
import { Button } from "@starci/grammar/common"
|
|
4
|
+
|
|
5
|
+
/** The resolved labels of the toggle and the content it shows; the branch owns only whether it is open. */
|
|
6
|
+
type {{Name}}Props = {
|
|
7
|
+
readonly showLabel: string
|
|
8
|
+
readonly hideLabel: string
|
|
9
|
+
readonly children: ReactNode
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Holds its intrinsic state alone (open or closed); it reads no data, resolves no copy and renders below a client block. */
|
|
13
|
+
export const {{Name}} = (props: {{Name}}Props) => {
|
|
14
|
+
const [isOpen, setIsOpen] = useState(false)
|
|
15
|
+
return (
|
|
16
|
+
<>
|
|
17
|
+
<Button intent="neutral" onPress={() => setIsOpen((current) => !current)}>
|
|
18
|
+
{isOpen ? props.hideLabel : props.showLabel}
|
|
19
|
+
</Button>
|
|
20
|
+
{isOpen ? props.children : null}
|
|
21
|
+
</>
|
|
22
|
+
)
|
|
23
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { Text } from "@starci/grammar/common"
|
|
2
|
+
|
|
3
|
+
/** The resolved fields {{Name}} draws; its block resolves the copy and passes the waiting flag down. */
|
|
4
|
+
type {{Name}}Props = {
|
|
5
|
+
readonly term: string
|
|
6
|
+
readonly value: string
|
|
7
|
+
readonly isSkeleton: boolean
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** A closed arrangement of Grammar parts: a term and its value, each resting as itself while its block waits. */
|
|
11
|
+
export const {{Name}} = (props: {{Name}}Props) => (
|
|
12
|
+
<>
|
|
13
|
+
<Text tone="muted" isSkeleton={props.isSkeleton}>
|
|
14
|
+
{props.term}
|
|
15
|
+
</Text>
|
|
16
|
+
<Text isSkeleton={props.isSkeleton}>{props.value}</Text>
|
|
17
|
+
</>
|
|
18
|
+
)
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { ReactNode } from "react"
|
|
2
|
+
import { PrimaryRailLayout } from "@starci/grammar/common"
|
|
3
|
+
{{blockImports}}
|
|
4
|
+
/** Framework-layout boundary input: the routed page of the {{segment}} segment and nothing else. */
|
|
5
|
+
type {{Name}}Props = { readonly content: ReactNode }
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The {{segment}} segment's frame: Grammar's PrimaryRailLayout places the routed page and {{railDoc}}. A frame only places
|
|
9
|
+
* regions and never insets a page; the Page inside owns its inset and rhythm.
|
|
10
|
+
*/
|
|
11
|
+
export const {{Name}} = (props: {{Name}}Props) => {{frame}}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { ReactNode } from "react"
|
|
2
|
+
import { {{Name}} } from "@/features/layouts/{{Name}}"
|
|
3
|
+
|
|
4
|
+
type LayoutProps = { readonly children: ReactNode }
|
|
5
|
+
|
|
6
|
+
/** The {{segment}} segment's layout slot: it mounts {{Name}} around the routed page and nothing else. */
|
|
7
|
+
const Layout = (props: LayoutProps) => <{{Name}} content={props.children} />
|
|
8
|
+
|
|
9
|
+
export default Layout
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { Badge } from "@starci/grammar/common"
|
|
2
|
+
|
|
3
|
+
/** One resolved label and the waiting flag of the block that draws it. */
|
|
4
|
+
type {{Name}}Props = {
|
|
5
|
+
readonly label: string
|
|
6
|
+
readonly isSkeleton: boolean
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** A fixed semantic unit over one Grammar leaf; it rests as itself by passing `isSkeleton` on. */
|
|
10
|
+
export const {{Name}} = (props: {{Name}}Props) => <Badge isSkeleton={props.isSkeleton}>{props.label}</Badge>
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { {{grammarImports}} } from "@starci/grammar/common"
|
|
2
|
+
|
|
3
|
+
/** The copy, the open state and the actions the connected {{Name}} supplies; atoms only. */
|
|
4
|
+
export interface {{Name}}BaseProps {
|
|
5
|
+
readonly props: {
|
|
6
|
+
readonly isOpen: boolean
|
|
7
|
+
readonly triggerLabel: string
|
|
8
|
+
readonly title: string
|
|
9
|
+
{{labelMembers}} readonly cancelLabel: string
|
|
10
|
+
readonly submitLabel: string
|
|
11
|
+
}
|
|
12
|
+
readonly on: {
|
|
13
|
+
readonly openChange: (isOpen: boolean) => void
|
|
14
|
+
{{submitMember}} }
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** {{doc}} */
|
|
18
|
+
export const {{Name}}Base = (props: {{Name}}BaseProps) => (
|
|
19
|
+
<{{shell}}
|
|
20
|
+
isOpen={props.props.isOpen}
|
|
21
|
+
onOpenChange={props.on.openChange}
|
|
22
|
+
title={props.props.title}
|
|
23
|
+
{{shellAttributes}} trigger={<Button intent="neutral">{props.props.triggerLabel}</Button>}
|
|
24
|
+
{{footer}} >
|
|
25
|
+
{{body}}
|
|
26
|
+
</{{shell}}>
|
|
27
|
+
)
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { useState } from "react"
|
|
4
|
+
import { useTranslations } from "next-intl"
|
|
5
|
+
import { {{Name}}Base } from "./component"
|
|
6
|
+
|
|
7
|
+
/** The connected {{Name}}: owns whether it is open, resolves every label and renders its pure half. */
|
|
8
|
+
export const {{Name}} = () => {
|
|
9
|
+
const t = useTranslations("{{nameCamel}}")
|
|
10
|
+
const [isOpen, setIsOpen] = useState(false)
|
|
11
|
+
return (
|
|
12
|
+
<{{Name}}Base
|
|
13
|
+
props={{
|
|
14
|
+
isOpen,
|
|
15
|
+
triggerLabel: t("open"),
|
|
16
|
+
title: t("title"),
|
|
17
|
+
{{labelsResolved}} cancelLabel: t("cancel"),
|
|
18
|
+
submitLabel: t("submit"),
|
|
19
|
+
}}
|
|
20
|
+
on={{ openChange: setIsOpen, {{submitHandler}} }}
|
|
21
|
+
/>
|
|
22
|
+
)
|
|
23
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react-vite"
|
|
2
|
+
import { {{Name}}Base } from "../../src/features/overlays/{{Name}}/component"
|
|
3
|
+
import type { {{Name}}BaseProps } from "../../src/features/overlays/{{Name}}/component"
|
|
4
|
+
|
|
5
|
+
/** The copy of the overlay. */
|
|
6
|
+
const COPY: {{Name}}BaseProps["props"] = {
|
|
7
|
+
isOpen: false,
|
|
8
|
+
triggerLabel: "Open",
|
|
9
|
+
title: "{{title}}",
|
|
10
|
+
{{labelsFixture}} cancelLabel: "Cancel",
|
|
11
|
+
submitLabel: "Save",
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** The pure half of {{Name}}: closed behind its trigger, and open. */
|
|
15
|
+
const meta = {
|
|
16
|
+
title: "{{Name}}",
|
|
17
|
+
component: {{Name}}Base,
|
|
18
|
+
args: { props: COPY, on: { openChange: () => undefined, {{submitFixture}} } },
|
|
19
|
+
} satisfies Meta<typeof {{Name}}Base>
|
|
20
|
+
|
|
21
|
+
export default meta
|
|
22
|
+
|
|
23
|
+
type Story = StoryObj<typeof meta>
|
|
24
|
+
|
|
25
|
+
/** Closed: only its trigger shows. */
|
|
26
|
+
export const Closed: Story = {}
|
|
27
|
+
|
|
28
|
+
/** Open above the page. */
|
|
29
|
+
export const Open: Story = { args: { props: { ...COPY, isOpen: true } } }
|