@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
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { {{grammarImports}} } from "@starci/grammar/common"
|
|
2
|
+
{{blockImports}}
|
|
3
|
+
/** The copy of {{Name}} and whether its subject is still unresolved, supplied by the page owner. */
|
|
4
|
+
export interface {{Name}}BaseProps {
|
|
5
|
+
readonly props: {
|
|
6
|
+
readonly title: string
|
|
7
|
+
readonly breadcrumbLabel: string
|
|
8
|
+
readonly homeLabel: string
|
|
9
|
+
readonly homeHref: string
|
|
10
|
+
{{emptyMember}}{{tabsMember}} /** True for the route's loading view: the same page, its header resting in its own boxes (STATE-8). */
|
|
11
|
+
readonly isSkeleton: boolean
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Draws {{Name}} as one Grammar Page (R325): the header band carries the trail and the heading, and page commands go to
|
|
17
|
+
* `header.actions`, page views to `header.tabs` (R324); the body composes the page's blocks, each owning its own data and loading.
|
|
18
|
+
*/
|
|
19
|
+
export const {{Name}}Base = (props: {{Name}}BaseProps) => (
|
|
20
|
+
<Page
|
|
21
|
+
header={{
|
|
22
|
+
title: props.props.title,
|
|
23
|
+
breadcrumbs: {
|
|
24
|
+
label: props.props.breadcrumbLabel,
|
|
25
|
+
items: [
|
|
26
|
+
{ id: "home", label: props.props.homeLabel, href: props.props.homeHref },
|
|
27
|
+
{ id: "{{routeId}}", label: props.props.title },
|
|
28
|
+
],
|
|
29
|
+
},
|
|
30
|
+
{{tabsHeader}} }}
|
|
31
|
+
isSkeleton={props.props.isSkeleton}
|
|
32
|
+
>
|
|
33
|
+
{{body}}
|
|
34
|
+
</Page>
|
|
35
|
+
)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { Alert, Page } from "@starci/grammar/common"
|
|
2
|
+
|
|
3
|
+
/** The copy of the {{route}} route's failure and the retry its boundary hands over. */
|
|
4
|
+
export interface {{ErrorName}}BaseProps {
|
|
5
|
+
readonly props: {
|
|
6
|
+
readonly title: string
|
|
7
|
+
readonly message: string
|
|
8
|
+
readonly retryLabel: string
|
|
9
|
+
}
|
|
10
|
+
readonly on: { readonly retry: () => void }
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** A failed {{route}} route keeps its page and puts one negative Alert where the first region would be (STATE-8). */
|
|
14
|
+
export const {{ErrorName}}Base = (props: {{ErrorName}}BaseProps) => (
|
|
15
|
+
<Page header={{ title: props.props.title }}>
|
|
16
|
+
<Alert title={props.props.message} tone="negative" action={{ label: props.props.retryLabel, onAction: props.on.retry }} />
|
|
17
|
+
</Page>
|
|
18
|
+
)
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { useTranslations } from "next-intl"
|
|
2
|
+
import { {{ErrorName}}Base } from "./component"
|
|
3
|
+
|
|
4
|
+
/** The public props of the {{route}} error page: the boundary hands it the retry. */
|
|
5
|
+
type {{ErrorName}}Props = { readonly onRetry: () => void }
|
|
6
|
+
|
|
7
|
+
/** A render failure under the {{route}} route shows this page instead of a blank one; retry renders the segment again. */
|
|
8
|
+
export const {{ErrorName}} = (props: {{ErrorName}}Props) => {
|
|
9
|
+
const t = useTranslations("{{nameCamel}}")
|
|
10
|
+
return <{{ErrorName}}Base props={{ title: t("title"), message: t("failed"), retryLabel: t("retry") }} on={{ retry: props.onRetry }} />
|
|
11
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { Metadata } from "next"
|
|
2
|
+
import { getTranslations } from "next-intl/server"
|
|
3
|
+
import { APP_ROUTES } from "@/modules/routes"
|
|
4
|
+
import { {{Name}}Base } from "./component"
|
|
5
|
+
import type { {{Name}}BaseProps } from "./component"
|
|
6
|
+
|
|
7
|
+
/** The document title of {{Name}}, from the catalog of the requested locale. */
|
|
8
|
+
export const {{nameCamel}}Metadata = async (): Promise<Metadata> => {
|
|
9
|
+
const t = await getTranslations("{{nameCamel}}")
|
|
10
|
+
return { title: t("title") }
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** The copy of {{Name}}, resolved once on the server for the page and for its loading view. */
|
|
14
|
+
const copyOf = async (isSkeleton: boolean): Promise<{{Name}}BaseProps["props"]> => {
|
|
15
|
+
const t = await getTranslations("{{nameCamel}}")
|
|
16
|
+
return {
|
|
17
|
+
title: t("title"),
|
|
18
|
+
breadcrumbLabel: t("breadcrumb"),
|
|
19
|
+
homeLabel: t("home"),
|
|
20
|
+
homeHref: APP_ROUTES.home,
|
|
21
|
+
{{emptyResolved}}{{tabsResolved}} isSkeleton,
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** The {{route}} page: a server owner that resolves its copy and composes its blocks; it reads no data. */
|
|
26
|
+
export const {{Name}} = async () => <{{Name}}Base props={await copyOf(false)} />
|
|
27
|
+
|
|
28
|
+
/** The {{route}} route's loading view: the same page drawn in skeleton mode, never a separate layout (STATE-8). */
|
|
29
|
+
export const {{LoadingName}} = async () => <{{Name}}Base props={await copyOf(true)} />
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { {{ErrorName}} } from "@/features/pages/{{ErrorName}}"
|
|
4
|
+
|
|
5
|
+
type ErrorProps = { readonly reset: () => void }
|
|
6
|
+
|
|
7
|
+
/** The {{route}} route's error boundary slot: it mounts the route's error page and hands it the segment's retry. */
|
|
8
|
+
const ErrorBoundary = (props: ErrorProps) => <{{ErrorName}} onRetry={props.reset} />
|
|
9
|
+
|
|
10
|
+
export default ErrorBoundary
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { {{Name}} } from "@/features/pages/{{Name}}"
|
|
2
|
+
|
|
3
|
+
/** The document title of this page, from the catalog of the requested locale. */
|
|
4
|
+
export { {{nameCamel}}Metadata as generateMetadata } from "@/features/pages/{{Name}}"
|
|
5
|
+
|
|
6
|
+
/** The {{route}} route's page slot: it mounts {{Name}} and nothing else. */
|
|
7
|
+
const Page = () => <{{Name}} />
|
|
8
|
+
|
|
9
|
+
export default Page
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react-vite"
|
|
2
|
+
import { {{Name}}Base } from "../../src/features/pages/{{Name}}/component"
|
|
3
|
+
import type { {{Name}}BaseProps } from "../../src/features/pages/{{Name}}/component"
|
|
4
|
+
|
|
5
|
+
/** The copy of the resolved page. */
|
|
6
|
+
const READY: {{Name}}BaseProps["props"] = {
|
|
7
|
+
title: "{{title}}",
|
|
8
|
+
breadcrumbLabel: "Breadcrumb",
|
|
9
|
+
homeLabel: "Home",
|
|
10
|
+
homeHref: "/",
|
|
11
|
+
{{emptyFixture}}{{tabsFixture}} isSkeleton: false,
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** The pure half of {{Name}}: the resolved page and its loading view, paired for the DNA gate. */
|
|
15
|
+
const meta = {
|
|
16
|
+
title: "{{Name}}",
|
|
17
|
+
component: {{Name}}Base,
|
|
18
|
+
args: { props: READY },
|
|
19
|
+
} satisfies Meta<typeof {{Name}}Base>
|
|
20
|
+
|
|
21
|
+
export default meta
|
|
22
|
+
|
|
23
|
+
type Story = StoryObj<typeof meta>
|
|
24
|
+
|
|
25
|
+
/** The resolved page. */
|
|
26
|
+
export const Ready: Story = {
|
|
27
|
+
parameters: { dna: { pair: "Ready", phase: "ready" } },
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The route's loading view: the same page, its header resting in its own boxes. */
|
|
31
|
+
export const ReadySkeleton: Story = {
|
|
32
|
+
args: { props: { ...READY, isSkeleton: true } },
|
|
33
|
+
parameters: { dna: { pair: "Ready", phase: "loading" } },
|
|
34
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { ReactNode } from "react"
|
|
2
|
+
import { EmptyNotice } from "@starci/grammar/common"
|
|
3
|
+
{{slotTypes}}
|
|
4
|
+
/** One region's read, its same-shape placeholder, its resolved status copy and the ready tree it draws. */
|
|
5
|
+
type SlotViewProps<T> = {
|
|
6
|
+
readonly slot: DataSlot<T>
|
|
7
|
+
/** A value of the same shape as the items: while loading, the ready tree draws it with `isSkeleton`. */
|
|
8
|
+
readonly placeholder: T
|
|
9
|
+
readonly labels: SlotLabels
|
|
10
|
+
/** A primary region says it is forbidden in place; a secondary one disappears. */
|
|
11
|
+
readonly forbidden?: "notice" | "hide"
|
|
12
|
+
/** Re-runs exactly the read of this slot. */
|
|
13
|
+
readonly onRetry: () => void
|
|
14
|
+
readonly children: (items: T, isSkeleton: boolean) => ReactNode
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** The one status view (FE-ERROR-1): every region renders its loading, forbidden, error and empty status through here. */
|
|
18
|
+
export const SlotView = <T,>(props: SlotViewProps<T>) => {
|
|
19
|
+
if (props.slot.isLoading === true) return props.children(props.placeholder, true)
|
|
20
|
+
if (props.slot.isForbidden === true) return props.forbidden === "hide" ? null : <EmptyNotice message={props.labels.forbidden} />
|
|
21
|
+
if (props.slot.isError === true) {
|
|
22
|
+
return <EmptyNotice message={props.labels.error} actionLabel={props.labels.retry} onAction={props.onRetry} />
|
|
23
|
+
}
|
|
24
|
+
const items = props.slot.items
|
|
25
|
+
// No items, or an empty list, is the region's empty sentence.
|
|
26
|
+
if (items === undefined || (Array.isArray(items) && items.length === 0)) return <EmptyNotice message={props.labels.empty} />
|
|
27
|
+
return props.children(items, false)
|
|
28
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The data status of one read (FE-ERROR-1). Every field is an atom, so a slot crosses into a pure half; a region with
|
|
3
|
+
* two reads carries two slots, and each one shows its own status. No flag set and no items means empty.
|
|
4
|
+
*/
|
|
5
|
+
export interface DataSlot<T> {
|
|
6
|
+
readonly items: T | undefined
|
|
7
|
+
readonly isLoading?: boolean
|
|
8
|
+
readonly isError?: boolean
|
|
9
|
+
readonly isForbidden?: boolean
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** The resolved copy of the status view for one slot: the shared status sentences plus the region's own empty sentence. */
|
|
13
|
+
export interface SlotLabels {
|
|
14
|
+
readonly empty: string
|
|
15
|
+
readonly forbidden: string
|
|
16
|
+
readonly error: string
|
|
17
|
+
readonly retry: string
|
|
18
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { DataSlot } from "@/modules/types"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The part of an operation's GraphQLResponse a slot reads: whether it succeeded, its value and the refusal kind. Only hooks/swr
|
|
5
|
+
* imports an operation's types, so the slot owner reads the outcome by these fields alone.
|
|
6
|
+
*/
|
|
7
|
+
interface SettledRead<T> {
|
|
8
|
+
readonly success: boolean
|
|
9
|
+
readonly data?: T
|
|
10
|
+
readonly kind?: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** What an SWR read of one operation hands its caller: the operation's outcome, the fetch error and whether it is on its way. */
|
|
14
|
+
interface SwrRead<T> {
|
|
15
|
+
readonly data: SettledRead<T> | undefined
|
|
16
|
+
readonly error?: unknown
|
|
17
|
+
readonly isLoading: boolean
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Folds one SWR read into its slot: a refusal of kind `forbidden` is its own status, every other failure is an error. */
|
|
21
|
+
export const slotOfSwr = <T>(read: SwrRead<T>): DataSlot<T> => {
|
|
22
|
+
if (read.isLoading) return { items: undefined, isLoading: true }
|
|
23
|
+
if (read.data === undefined) return { items: undefined, isError: read.error !== undefined }
|
|
24
|
+
if (read.data.success) return { items: read.data.data }
|
|
25
|
+
return read.data.kind === "forbidden" ? { items: undefined, isForbidden: true } : { items: undefined, isError: true }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Carries a slot's status over to the view made of its items. */
|
|
29
|
+
export const mapSlot = <T, U>(slot: DataSlot<T>, map: (items: T) => U): DataSlot<U> => ({
|
|
30
|
+
...slot,
|
|
31
|
+
items: slot.items === undefined ? undefined : map(slot.items),
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
/** The slot of a read still on its way: what a skeleton story and a region waiting on its owner render. */
|
|
35
|
+
export const loadingSlot = <T>(): DataSlot<T> => ({ items: undefined, isLoading: true })
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { useTranslations } from "next-intl"
|
|
2
|
+
import type { SlotLabels } from "@/modules/types"
|
|
3
|
+
|
|
4
|
+
/** Resolves the shared status copy once; the returned function adds one region's own empty sentence. */
|
|
5
|
+
export const useSlotLabels = () => {
|
|
6
|
+
const t = useTranslations("status")
|
|
7
|
+
return (empty: string): SlotLabels => ({ empty, forbidden: t("forbidden"), error: t("error"), retry: t("retry") })
|
|
8
|
+
}
|