handoff-app 1.2.2-7 → 2.0.0-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/Changelog.md +37 -15
- package/README.md +372 -118
- package/dist/app-builder/build.d.ts +41 -2
- package/dist/app-builder/build.js +455 -18
- package/dist/app-builder/client-config.d.ts +15 -2
- package/dist/app-builder/client-config.js +43 -4
- package/dist/app-builder/config-diff/snapshot.d.ts +1 -6
- package/dist/app-builder/config-diff/snapshot.js +2 -9
- package/dist/app-builder/index.d.ts +1 -1
- package/dist/app-builder/index.js +3 -1
- package/dist/app-builder/nav-shell.d.ts +23 -0
- package/dist/app-builder/nav-shell.js +80 -0
- package/dist/app-builder/static-export.d.ts +17 -0
- package/dist/app-builder/static-export.js +181 -0
- package/dist/app-builder/vercel-output.d.ts +39 -0
- package/dist/app-builder/vercel-output.js +259 -0
- package/dist/app-builder/watchers/utils.d.ts +2 -0
- package/dist/app-builder/watchers.d.ts +14 -4
- package/dist/app-builder/watchers.js +123 -28
- package/dist/app-builder/websocket.js +1 -1
- package/dist/artifacts/index.d.ts +2 -0
- package/dist/artifacts/index.js +13 -0
- package/dist/artifacts/types.d.ts +136 -0
- package/dist/artifacts/types.js +24 -0
- package/dist/artifacts/url.d.ts +49 -0
- package/dist/artifacts/url.js +86 -0
- package/dist/cache/build-cache.js +48 -4
- package/dist/cli.js +0 -0
- package/dist/commands/build/app.js +1 -2
- package/dist/commands/build/index.d.ts +17 -0
- package/dist/commands/build/index.js +66 -0
- package/dist/commands/checkout/index.d.ts +24 -0
- package/dist/commands/checkout/index.js +71 -0
- package/dist/commands/db/migrate.d.ts +11 -0
- package/dist/commands/db/migrate.js +33 -0
- package/dist/commands/index.js +8 -0
- package/dist/commands/publish/index.d.ts +21 -0
- package/dist/commands/publish/index.js +68 -0
- package/dist/config/defaults.js +15 -1
- package/dist/config/loader.js +6 -1
- package/dist/config/normalizers/declaration.js +4 -1
- package/dist/config/normalizers/page.d.ts +18 -0
- package/dist/config/normalizers/page.js +34 -0
- package/dist/config/normalizers/pattern.js +7 -1
- package/dist/config/runtime.d.ts +4 -0
- package/dist/config/runtime.js +53 -5
- package/dist/index.d.ts +65 -1
- package/dist/index.js +123 -3
- package/dist/nav/index.d.ts +83 -0
- package/dist/nav/index.js +9 -0
- package/dist/nav/registry.d.ts +15 -0
- package/dist/nav/registry.js +42 -0
- package/dist/nav/resolver.d.ts +8 -0
- package/dist/nav/resolver.js +192 -0
- package/dist/nav/workspace.d.ts +18 -0
- package/dist/nav/workspace.js +103 -0
- package/dist/pipeline/styles.js +7 -35
- package/dist/pipeline/token-transformers.d.ts +45 -0
- package/dist/pipeline/token-transformers.js +71 -0
- package/dist/registry/asset-storage/adapters/vercel-blob.d.ts +17 -0
- package/dist/registry/asset-storage/adapters/vercel-blob.js +87 -0
- package/dist/registry/asset-storage/define.d.ts +10 -0
- package/dist/registry/asset-storage/define.js +13 -0
- package/dist/registry/asset-storage/resolve.d.ts +45 -0
- package/dist/registry/asset-storage/resolve.js +36 -0
- package/dist/registry/asset-storage/types.d.ts +51 -0
- package/dist/registry/asset-storage/types.js +9 -0
- package/dist/registry/assets/layout.d.ts +22 -0
- package/dist/registry/assets/layout.js +30 -0
- package/dist/registry/assets/sets.d.ts +18 -0
- package/dist/registry/assets/sets.js +44 -0
- package/dist/registry/assets/transfer.d.ts +45 -0
- package/dist/registry/assets/transfer.js +11 -0
- package/dist/registry/checkout/assets.d.ts +16 -0
- package/dist/registry/checkout/assets.js +150 -0
- package/dist/registry/checkout/index.d.ts +33 -0
- package/dist/registry/checkout/index.js +610 -0
- package/dist/registry/checkout/tokens.d.ts +15 -0
- package/dist/registry/checkout/tokens.js +171 -0
- package/dist/registry/client.d.ts +61 -0
- package/dist/registry/client.js +211 -0
- package/dist/registry/connection.d.ts +30 -0
- package/dist/registry/connection.js +36 -0
- package/dist/registry/db/adapter.d.ts +43 -0
- package/dist/registry/db/adapter.js +59 -0
- package/dist/registry/db/client.d.ts +35 -0
- package/dist/registry/db/client.js +94 -0
- package/dist/registry/db/index.d.ts +5 -0
- package/dist/registry/db/index.js +24 -0
- package/dist/registry/db/migrate.d.ts +23 -0
- package/dist/registry/db/migrate.js +64 -0
- package/dist/registry/db/schema.d.ts +4869 -0
- package/dist/registry/db/schema.js +347 -0
- package/dist/registry/path.d.ts +4 -0
- package/dist/registry/path.js +31 -0
- package/dist/registry/publish/artifacts.d.ts +16 -0
- package/dist/registry/publish/artifacts.js +132 -0
- package/dist/registry/publish/assets.d.ts +16 -0
- package/dist/registry/publish/assets.js +129 -0
- package/dist/registry/publish/errors.d.ts +5 -0
- package/dist/registry/publish/errors.js +26 -0
- package/dist/registry/publish/index.d.ts +27 -0
- package/dist/registry/publish/index.js +126 -0
- package/dist/registry/publish/package.d.ts +27 -0
- package/dist/registry/publish/package.js +257 -0
- package/dist/registry/publish/publish-build.d.ts +12 -0
- package/dist/registry/publish/publish-build.js +43 -0
- package/dist/registry/publish/tokens.d.ts +18 -0
- package/dist/registry/publish/tokens.js +112 -0
- package/dist/registry/tokens/sets.d.ts +59 -0
- package/dist/registry/tokens/sets.js +109 -0
- package/dist/registry/tokens/transfer.d.ts +48 -0
- package/dist/registry/tokens/transfer.js +10 -0
- package/dist/registry/transfer.d.ts +75 -0
- package/dist/registry/transfer.js +11 -0
- package/dist/store/filesystem-assets.d.ts +17 -0
- package/dist/store/filesystem-assets.js +147 -0
- package/dist/store/filesystem.d.ts +76 -0
- package/dist/store/filesystem.js +208 -0
- package/dist/store/index.d.ts +20 -0
- package/dist/store/index.js +38 -0
- package/dist/store/registry.d.ts +77 -0
- package/dist/store/registry.js +350 -0
- package/dist/store/source-files.d.ts +24 -0
- package/dist/store/source-files.js +147 -0
- package/dist/store/types.d.ts +185 -0
- package/dist/store/types.js +12 -0
- package/dist/transformers/plugins/csf-render.js +17 -8
- package/dist/transformers/plugins/handlebars-previews.js +10 -3
- package/dist/transformers/plugins/ssr-render.js +116 -49
- package/dist/transformers/preview/component/api.js +3 -1
- package/dist/transformers/preview/component/artifacts.js +8 -1
- package/dist/transformers/preview/component/builder.js +13 -5
- package/dist/transformers/preview/component/css.js +9 -1
- package/dist/transformers/preview/component/javascript.d.ts +9 -3
- package/dist/transformers/preview/component/javascript.js +45 -17
- package/dist/transformers/preview/component/shared-artifacts.d.ts +83 -0
- package/dist/transformers/preview/component/shared-artifacts.js +92 -0
- package/dist/transformers/preview/component.js +5 -2
- package/dist/transformers/preview/pattern/builder.js +28 -9
- package/dist/transformers/preview/pattern/html.d.ts +13 -10
- package/dist/transformers/preview/pattern/html.js +62 -27
- package/dist/transformers/preview/types.d.ts +38 -0
- package/dist/transformers/utils/handlebars.d.ts +3 -0
- package/dist/transformers/utils/handlebars.js +19 -5
- package/dist/types/config.d.ts +118 -25
- package/dist/utils/menu-shell.d.ts +60 -0
- package/dist/utils/menu-shell.js +221 -0
- package/dist/utils/stable-stringify.d.ts +2 -0
- package/dist/utils/stable-stringify.js +8 -0
- package/docs/cli.md +4 -2
- package/drizzle/0000_init_registry_storage.sql +95 -0
- package/drizzle/0001_add_review_metadata.sql +2 -0
- package/drizzle/0002_add_pages.sql +28 -0
- package/drizzle/0003_add_tokens.sql +29 -0
- package/drizzle/0004_add_assets.sql +37 -0
- package/drizzle/meta/0000_snapshot.json +642 -0
- package/drizzle/meta/0001_snapshot.json +654 -0
- package/drizzle/meta/0002_snapshot.json +835 -0
- package/drizzle/meta/0003_snapshot.json +1023 -0
- package/drizzle/meta/0004_snapshot.json +1252 -0
- package/drizzle/meta/_journal.json +41 -0
- package/drizzle.config.ts +16 -0
- package/package.json +10 -1
- package/src/app/components/Component/Preview.tsx +3 -2
- package/src/app/components/Component/RegistryActions.tsx +150 -0
- package/src/app/components/Foundations/DisplayLogo.tsx +5 -3
- package/src/app/components/Layout/Header.tsx +21 -3
- package/src/app/components/Layout/Main.tsx +11 -3
- package/src/app/components/Navigation/MainNav.tsx +4 -2
- package/src/app/components/Navigation/MobileNav.tsx +4 -2
- package/src/app/components/Navigation/SideNav.tsx +46 -11
- package/src/app/components/NotFound.tsx +24 -0
- package/src/app/components/context/NavProvider.tsx +71 -0
- package/src/app/components/old/Header.tsx +3 -3
- package/src/app/components/util/index.ts +203 -380
- package/src/app/components/util/useCollectionAssets.ts +125 -0
- package/src/app/components/util/useFoundationTokens.ts +67 -0
- package/src/app/generated/default-pages.json +1 -0
- package/src/app/generated/nav-shell.json +1 -0
- package/src/app/lib/api/query.ts +6 -0
- package/src/app/lib/api/redact.ts +46 -0
- package/src/app/lib/asset-storage.ts +81 -0
- package/src/app/lib/docs-api/artifacts.ts +116 -0
- package/src/app/lib/docs-api/backend.ts +151 -0
- package/src/app/lib/docs-api/errors.ts +52 -0
- package/src/app/lib/docs-api/handler.ts +34 -0
- package/src/app/lib/docs-api/index.ts +7 -0
- package/src/app/lib/docs-api/records.ts +189 -0
- package/src/app/lib/docs-api/registry.ts +130 -0
- package/src/app/lib/docs-api/runtime-config.ts +173 -0
- package/src/app/lib/docs-api/serve.ts +37 -0
- package/src/app/lib/docs-api/token-detail.ts +19 -0
- package/src/app/lib/registry-api/allowlist.ts +163 -0
- package/src/app/lib/registry-api/asset-transfer.ts +363 -0
- package/src/app/lib/registry-api/auth.ts +68 -0
- package/src/app/lib/registry-api/entity-routes.ts +197 -0
- package/src/app/lib/registry-api/errors.ts +51 -0
- package/src/app/lib/registry-api/files.ts +135 -0
- package/src/app/lib/registry-api/handler.ts +96 -0
- package/src/app/lib/registry-api/index.ts +4 -0
- package/src/app/lib/registry-api/meta.ts +87 -0
- package/src/app/lib/registry-api/revalidate.ts +36 -0
- package/src/app/lib/registry-api/store.ts +348 -0
- package/src/app/lib/registry-api/token-transfer.ts +231 -0
- package/src/app/lib/registry-api/transfer.ts +513 -0
- package/src/app/lib/registry-api/validation.ts +74 -0
- package/src/app/lib/registry-connection.ts +58 -0
- package/src/app/lib/utils.ts +14 -0
- package/src/app/next.config.mjs +72 -13
- package/src/app/pages/[...slug]/index.tsx +74 -20
- package/src/app/pages/_app.tsx +5 -2
- package/src/app/pages/api/docs/artifacts/[...artifactPath].ts +16 -0
- package/src/app/pages/api/docs/assets/[collection]/[...path].ts +50 -0
- package/src/app/pages/api/docs/assets/[collection]/index.ts +22 -0
- package/src/app/pages/api/docs/components/[id]/assets/[asset].ts +33 -0
- package/src/app/pages/api/docs/components/[id]/inspect/[previewId].ts +20 -0
- package/src/app/pages/api/docs/components/[id]/previews/[previewId].ts +21 -0
- package/src/app/pages/api/docs/components/[id].ts +24 -0
- package/src/app/pages/api/docs/components.json.ts +12 -0
- package/src/app/pages/api/docs/nav.json.ts +48 -0
- package/src/app/pages/api/docs/patterns/[id]/preview.ts +19 -0
- package/src/app/pages/api/docs/patterns/[id].ts +24 -0
- package/src/app/pages/api/docs/patterns.json.ts +12 -0
- package/src/app/pages/api/docs/tokens/components/[id].ts +30 -0
- package/src/app/pages/api/docs/tokens/foundations/[type].ts +32 -0
- package/src/app/pages/api/registry/components/[id]/files/[...filePath].ts +19 -0
- package/src/app/pages/api/registry/components/[id]/files/index.ts +19 -0
- package/src/app/pages/api/registry/components/[id].ts +20 -0
- package/src/app/pages/api/registry/components/index.ts +19 -0
- package/src/app/pages/api/registry/health.ts +20 -0
- package/src/app/pages/api/registry/patterns/[id]/files/[...filePath].ts +19 -0
- package/src/app/pages/api/registry/patterns/[id]/files/index.ts +19 -0
- package/src/app/pages/api/registry/patterns/[id].ts +20 -0
- package/src/app/pages/api/registry/patterns/index.ts +19 -0
- package/src/app/pages/api/registry/transfer/[kind]/[id].ts +39 -0
- package/src/app/pages/api/registry/transfer/assets/[collection].ts +24 -0
- package/src/app/pages/api/registry/transfer/assets/blobs/[hash].ts +23 -0
- package/src/app/pages/api/registry/transfer/assets/blobs/have.ts +11 -0
- package/src/app/pages/api/registry/transfer/assets/index.ts +10 -0
- package/src/app/pages/api/registry/transfer/tokens/[...setId].ts +26 -0
- package/src/app/pages/api/registry/transfer/tokens/index.ts +11 -0
- package/src/app/pages/assets/fonts.tsx +41 -17
- package/src/app/pages/assets/index.tsx +2 -2
- package/src/app/pages/assets/logos.tsx +2 -2
- package/src/app/pages/foundations/colors.tsx +18 -8
- package/src/app/pages/foundations/effects.tsx +18 -8
- package/src/app/pages/foundations/icons/[name]/index.tsx +26 -14
- package/src/app/pages/foundations/icons/index.tsx +13 -9
- package/src/app/pages/foundations/index.tsx +1 -1
- package/src/app/pages/foundations/logo.tsx +7 -4
- package/src/app/pages/foundations/typography.tsx +19 -9
- package/src/app/pages/index.tsx +1 -1
- package/src/app/pages/system/component/[component]/index.tsx +87 -17
- package/src/app/pages/system/index.tsx +13 -4
- package/src/app/pages/system/pattern/[pattern]/index.tsx +70 -14
- package/src/app/pages/system/tokens/components/[component]/index.tsx +42 -14
- package/src/app/pages/system/tokens/foundations/colors.tsx +7 -5
- package/src/app/pages/system/tokens/foundations/effects.tsx +7 -5
- package/src/app/pages/system/tokens/foundations/typography.tsx +8 -4
- package/src/app/tsconfig.json +2 -3
- package/src/app-builder/build.ts +555 -11
- package/src/app-builder/client-config.ts +61 -4
- package/src/app-builder/config-diff/snapshot.ts +1 -11
- package/src/app-builder/index.ts +1 -1
- package/src/app-builder/nav-shell.ts +74 -0
- package/src/app-builder/static-export.ts +215 -0
- package/src/app-builder/vercel-output.ts +297 -0
- package/src/app-builder/watchers/utils.ts +2 -0
- package/src/app-builder/watchers.ts +149 -25
- package/src/app-builder/websocket.ts +2 -2
- package/src/artifacts/README.md +14 -0
- package/src/artifacts/index.ts +17 -0
- package/src/artifacts/types.ts +146 -0
- package/src/artifacts/url.ts +90 -0
- package/src/cache/build-cache.ts +47 -2
- package/src/commands/build/app.ts +1 -2
- package/src/commands/build/index.ts +61 -0
- package/src/commands/checkout/index.ts +66 -0
- package/src/commands/db/migrate.ts +25 -0
- package/src/commands/index.ts +8 -0
- package/src/commands/publish/index.ts +63 -0
- package/src/config/defaults.ts +15 -0
- package/src/config/loader.ts +10 -1
- package/src/config/normalizers/declaration.ts +3 -0
- package/src/config/normalizers/page.ts +45 -0
- package/src/config/normalizers/pattern.ts +4 -1
- package/src/config/runtime.ts +48 -2
- package/src/index.ts +126 -3
- package/src/nav/index.ts +94 -0
- package/src/nav/registry.ts +54 -0
- package/src/nav/resolver.ts +217 -0
- package/src/nav/workspace.ts +87 -0
- package/src/pipeline/styles.ts +4 -35
- package/src/pipeline/token-transformers.ts +97 -0
- package/src/registry/asset-storage/adapters/vercel-blob.ts +51 -0
- package/src/registry/asset-storage/define.ts +15 -0
- package/src/registry/asset-storage/resolve.ts +72 -0
- package/src/registry/asset-storage/types.ts +46 -0
- package/src/registry/assets/layout.ts +38 -0
- package/src/registry/assets/sets.ts +48 -0
- package/src/registry/assets/transfer.ts +50 -0
- package/src/registry/checkout/assets.ts +126 -0
- package/src/registry/checkout/index.ts +596 -0
- package/src/registry/checkout/tokens.ts +152 -0
- package/src/registry/client.ts +234 -0
- package/src/registry/connection.ts +50 -0
- package/src/registry/db/adapter.ts +74 -0
- package/src/registry/db/client.ts +88 -0
- package/src/registry/db/index.ts +35 -0
- package/src/registry/db/migrate.ts +56 -0
- package/src/registry/db/schema.ts +444 -0
- package/src/registry/path.ts +26 -0
- package/src/registry/publish/artifacts.ts +147 -0
- package/src/registry/publish/assets.ts +129 -0
- package/src/registry/publish/errors.ts +34 -0
- package/src/registry/publish/index.ts +128 -0
- package/src/registry/publish/package.ts +272 -0
- package/src/registry/publish/publish-build.ts +51 -0
- package/src/registry/publish/tokens.ts +119 -0
- package/src/registry/tokens/sets.ts +132 -0
- package/src/registry/tokens/transfer.ts +53 -0
- package/src/registry/transfer.ts +82 -0
- package/src/store/filesystem-assets.ts +152 -0
- package/src/store/filesystem.ts +237 -0
- package/src/store/index.ts +62 -0
- package/src/store/registry.ts +334 -0
- package/src/store/source-files.ts +155 -0
- package/src/store/types.ts +210 -0
- package/src/transformers/plugins/csf-render.ts +31 -7
- package/src/transformers/plugins/handlebars-previews.ts +22 -3
- package/src/transformers/plugins/ssr-render.ts +145 -57
- package/src/transformers/preview/component/api.ts +2 -1
- package/src/transformers/preview/component/artifacts.ts +10 -1
- package/src/transformers/preview/component/builder.ts +9 -2
- package/src/transformers/preview/component/css.ts +10 -1
- package/src/transformers/preview/component/javascript.ts +55 -20
- package/src/transformers/preview/component/shared-artifacts.ts +146 -0
- package/src/transformers/preview/component.ts +5 -2
- package/src/transformers/preview/pattern/builder.ts +33 -7
- package/src/transformers/preview/pattern/html.ts +71 -27
- package/src/transformers/preview/types.ts +42 -0
- package/src/transformers/utils/handlebars.ts +31 -5
- package/src/types/config.ts +123 -25
- package/src/utils/menu-shell.ts +241 -0
- package/src/utils/stable-stringify.ts +7 -0
- package/templates/default/package.json.tpl +1 -1
- package/handoff.config.js +0 -90
- package/src/app/components/ComponentSearch.tsx +0 -93
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from 'react';
|
|
4
|
+
import type { Types as CoreTypes } from 'handoff-core';
|
|
5
|
+
import type { AssetMetadata } from '@handoff/store';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Client-side hydration for asset pages (icons grid, icon detail, logos). Asset routes are static, so
|
|
9
|
+
* their `getStaticProps` data is build-time only (empty in a registry build). In registry mode these
|
|
10
|
+
* hooks fetch the metadata-only list from the docs read API on mount (and, for detail, one SVG body),
|
|
11
|
+
* so a publish surfaces without a rebuild; workspace/static returns the build-time props unchanged.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const isRegistry = process.env.HANDOFF_RUNTIME_MODE === 'registry';
|
|
15
|
+
const basePath = process.env.HANDOFF_APP_BASE_PATH ?? '';
|
|
16
|
+
|
|
17
|
+
/** A grid/detail-renderable asset: inline SVG body (workspace) or a content URL (registry). */
|
|
18
|
+
export interface DisplayableAsset {
|
|
19
|
+
/** Stable id used for the detail route + sprite symbol. */
|
|
20
|
+
icon: string;
|
|
21
|
+
/** Searchable index string. */
|
|
22
|
+
index: string;
|
|
23
|
+
name: string;
|
|
24
|
+
/** Logical path within the collection (registry) or the asset path (workspace). */
|
|
25
|
+
path: string;
|
|
26
|
+
size?: number;
|
|
27
|
+
/** Optional asset description (logos). */
|
|
28
|
+
description?: string;
|
|
29
|
+
/** Inline SVG body (workspace, or a fetched single-asset body). */
|
|
30
|
+
data?: string;
|
|
31
|
+
/** Individual asset content URL (registry). */
|
|
32
|
+
src?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const contentUrl = (collection: string, logicalPath: string): string => `${basePath}/api/docs/assets/${collection}/${logicalPath}`;
|
|
36
|
+
|
|
37
|
+
const fromWorkspace = (assets?: CoreTypes.IAssetObject[]): DisplayableAsset[] =>
|
|
38
|
+
(assets ?? []).map((asset) => ({
|
|
39
|
+
icon: asset.icon,
|
|
40
|
+
index: asset.index,
|
|
41
|
+
name: asset.name,
|
|
42
|
+
path: asset.path,
|
|
43
|
+
size: asset.size,
|
|
44
|
+
description: asset.description,
|
|
45
|
+
data: asset.data,
|
|
46
|
+
}));
|
|
47
|
+
|
|
48
|
+
/** Map registry metadata rows (individual assets only) to displayable assets. */
|
|
49
|
+
const fromRegistry = (collection: string, rows: AssetMetadata[]): DisplayableAsset[] => {
|
|
50
|
+
const prefix = `assets/${collection}/`;
|
|
51
|
+
return (rows ?? [])
|
|
52
|
+
.filter((row) => typeof row.path === 'string' && row.path.startsWith(prefix))
|
|
53
|
+
.map((row) => ({
|
|
54
|
+
icon: (row.metadata?.icon as string) ?? row.path.slice(prefix.length).replace(/\.[^.]+$/, ''),
|
|
55
|
+
index: (row.metadata?.index as string) ?? row.name ?? '',
|
|
56
|
+
name: row.name,
|
|
57
|
+
path: row.path,
|
|
58
|
+
size: row.size,
|
|
59
|
+
description: row.metadata?.description as string | undefined,
|
|
60
|
+
src: contentUrl(collection, row.path),
|
|
61
|
+
}));
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/** Hydrate a collection's asset list (metadata only), used by the icon grid and logo views. */
|
|
65
|
+
export const useCollectionAssets = (collection: 'icons' | 'logos', initial?: CoreTypes.IAssetObject[]): DisplayableAsset[] => {
|
|
66
|
+
const [assets, setAssets] = useState<DisplayableAsset[]>(() => fromWorkspace(initial));
|
|
67
|
+
|
|
68
|
+
useEffect(() => {
|
|
69
|
+
if (!isRegistry) {
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
let active = true;
|
|
73
|
+
fetch(`${basePath}/api/docs/assets/${collection}`)
|
|
74
|
+
.then((res) => (res.ok ? res.json() : null))
|
|
75
|
+
.then((data) => {
|
|
76
|
+
if (active && data?.assets) setAssets(fromRegistry(collection, data.assets));
|
|
77
|
+
})
|
|
78
|
+
.catch(() => {});
|
|
79
|
+
return () => {
|
|
80
|
+
active = false;
|
|
81
|
+
};
|
|
82
|
+
}, [collection]);
|
|
83
|
+
|
|
84
|
+
return assets;
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
/** Hydrate a single asset (with its SVG body) by id, used by the icon detail view. */
|
|
88
|
+
export const useSingleAsset = (
|
|
89
|
+
collection: 'icons' | 'logos',
|
|
90
|
+
iconId: string | undefined,
|
|
91
|
+
initial?: CoreTypes.IAssetObject
|
|
92
|
+
): DisplayableAsset | null => {
|
|
93
|
+
const [asset, setAsset] = useState<DisplayableAsset | null>(() =>
|
|
94
|
+
initial
|
|
95
|
+
? { icon: initial.icon, index: initial.index, name: initial.name, path: initial.path, size: initial.size, data: initial.data }
|
|
96
|
+
: null
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
useEffect(() => {
|
|
100
|
+
if (!isRegistry || !iconId) {
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
let active = true;
|
|
104
|
+
(async () => {
|
|
105
|
+
const listRes = await fetch(`${basePath}/api/docs/assets/${collection}`);
|
|
106
|
+
if (!listRes.ok) return;
|
|
107
|
+
const data = await listRes.json();
|
|
108
|
+
const entry = fromRegistry(collection, data?.assets ?? []).find((candidate) => candidate.icon === iconId);
|
|
109
|
+
if (!entry) return;
|
|
110
|
+
let body: string | undefined;
|
|
111
|
+
try {
|
|
112
|
+
const bodyRes = await fetch(entry.src as string);
|
|
113
|
+
if (bodyRes.ok) body = await bodyRes.text();
|
|
114
|
+
} catch {
|
|
115
|
+
// Preview falls back to the <img> src if the body cannot be fetched.
|
|
116
|
+
}
|
|
117
|
+
if (active) setAsset({ ...entry, data: body });
|
|
118
|
+
})().catch(() => {});
|
|
119
|
+
return () => {
|
|
120
|
+
active = false;
|
|
121
|
+
};
|
|
122
|
+
}, [collection, iconId]);
|
|
123
|
+
|
|
124
|
+
return asset;
|
|
125
|
+
};
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from 'react';
|
|
4
|
+
import type { Types as CoreTypes } from 'handoff-core';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Client-side hydration for foundation token pages.
|
|
8
|
+
*
|
|
9
|
+
* Foundation routes are static, so their `getStaticProps` data is build-time only — which is empty in
|
|
10
|
+
* a registry build (no local `tokens.json`). In registry mode this hook fetches the mutable token data
|
|
11
|
+
* from the docs read API on mount (mirroring `NavProvider`'s client fetch), so a publish surfaces
|
|
12
|
+
* without a rebuild/redeploy. In workspace/static mode it returns the build-time props unchanged.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const isRegistry = process.env.HANDOFF_RUNTIME_MODE === 'registry';
|
|
16
|
+
const basePath = process.env.HANDOFF_APP_BASE_PATH ?? '';
|
|
17
|
+
|
|
18
|
+
export interface FoundationTokensState {
|
|
19
|
+
design: CoreTypes.IDocumentationObject['localStyles'];
|
|
20
|
+
css: string;
|
|
21
|
+
scss: string;
|
|
22
|
+
styleDictionary: string;
|
|
23
|
+
types: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const emptyDesign = (): CoreTypes.IDocumentationObject['localStyles'] =>
|
|
27
|
+
({ color: [], typography: [], effect: [] }) as CoreTypes.IDocumentationObject['localStyles'];
|
|
28
|
+
|
|
29
|
+
export const useFoundationTokens = (
|
|
30
|
+
type: 'colors' | 'typography' | 'effects',
|
|
31
|
+
initial: Partial<FoundationTokensState>
|
|
32
|
+
): FoundationTokensState => {
|
|
33
|
+
const [state, setState] = useState<FoundationTokensState>({
|
|
34
|
+
design: initial.design ?? emptyDesign(),
|
|
35
|
+
css: initial.css ?? '',
|
|
36
|
+
scss: initial.scss ?? '',
|
|
37
|
+
styleDictionary: initial.styleDictionary ?? '',
|
|
38
|
+
types: initial.types ?? '',
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
if (!isRegistry) {
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
let active = true;
|
|
46
|
+
fetch(`${basePath}/api/docs/tokens/foundations/${type}.json`)
|
|
47
|
+
.then((res) => (res.ok ? res.json() : null))
|
|
48
|
+
.then((data) => {
|
|
49
|
+
if (!active || !data) {
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
setState((prev) => ({
|
|
53
|
+
design: { ...prev.design, ...(data.design ?? {}) },
|
|
54
|
+
css: data.css ?? '',
|
|
55
|
+
scss: data.scss ?? '',
|
|
56
|
+
styleDictionary: data.styleDictionary ?? '',
|
|
57
|
+
types: data.types ?? '',
|
|
58
|
+
}));
|
|
59
|
+
})
|
|
60
|
+
.catch(() => {});
|
|
61
|
+
return () => {
|
|
62
|
+
active = false;
|
|
63
|
+
};
|
|
64
|
+
}, [type]);
|
|
65
|
+
|
|
66
|
+
return state;
|
|
67
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
[]
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export type QueryValue = string | string[] | undefined;
|
|
2
|
+
|
|
3
|
+
export const singleQueryValue = (value: QueryValue): string | undefined => (Array.isArray(value) ? value[0] : value);
|
|
4
|
+
|
|
5
|
+
export const joinedQueryValue = (value: QueryValue): string | undefined =>
|
|
6
|
+
Array.isArray(value) ? (value.length > 0 ? value.join('/') : undefined) : value;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Defensive secret redaction for registry and docs API responses.
|
|
3
|
+
*
|
|
4
|
+
* Config stores only env-var *names*, never secret values, so well-formed records should never
|
|
5
|
+
* carry a token or connection string. This is a belt-and-braces final pass over every response
|
|
6
|
+
* body: any string property whose key looks like a secret has its value replaced with
|
|
7
|
+
* `[redacted]`, so an accidentally-stored secret can never leak through the API.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const SECRET_KEY_PATTERN =
|
|
11
|
+
/(token|secret|password|passwd|api[_-]?key|access[_-]?token|connection[_-]?string|database[_-]?url|authorization|bearer|credential)/i;
|
|
12
|
+
|
|
13
|
+
const REDACTED = '[redacted]';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Recursively clone `value`, replacing string values whose keys look secret with `[redacted]`.
|
|
17
|
+
* Arrays and nested objects are cloned, and cycles are replaced with a serializable marker.
|
|
18
|
+
*/
|
|
19
|
+
export const redactSecrets = <T>(value: T, ancestors: WeakSet<object> = new WeakSet()): T => {
|
|
20
|
+
if (Array.isArray(value)) {
|
|
21
|
+
if (ancestors.has(value)) {
|
|
22
|
+
return '[circular]' as T;
|
|
23
|
+
}
|
|
24
|
+
ancestors.add(value);
|
|
25
|
+
const result = value.map((item) => redactSecrets(item, ancestors));
|
|
26
|
+
ancestors.delete(value);
|
|
27
|
+
return result as T;
|
|
28
|
+
}
|
|
29
|
+
if (value && typeof value === 'object') {
|
|
30
|
+
if (ancestors.has(value as object)) {
|
|
31
|
+
return '[circular]' as T;
|
|
32
|
+
}
|
|
33
|
+
ancestors.add(value as object);
|
|
34
|
+
const result: Record<string, unknown> = {};
|
|
35
|
+
for (const [key, val] of Object.entries(value as Record<string, unknown>)) {
|
|
36
|
+
if (typeof val === 'string' && SECRET_KEY_PATTERN.test(key)) {
|
|
37
|
+
result[key] = REDACTED;
|
|
38
|
+
} else {
|
|
39
|
+
result[key] = redactSecrets(val, ancestors);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
ancestors.delete(value as object);
|
|
43
|
+
return result as T;
|
|
44
|
+
}
|
|
45
|
+
return value;
|
|
46
|
+
};
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-only asset storage runtime. Resolves the active provider from the baked server runtime
|
|
3
|
+
* config and lazily builds/memoizes adapter instances. Writes use the active adapter; reads resolve
|
|
4
|
+
* by each blob's recorded provider id, so DB-backed (`database`, inline bytea) and object-backed
|
|
5
|
+
* blobs coexist and old blobs stay readable after the active provider changes. `database` is the
|
|
6
|
+
* inline default with no adapter (bytes live directly in `asset_blobs.content`).
|
|
7
|
+
*
|
|
8
|
+
* Imported exclusively by registry API route handlers and the registry store.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { createVercelBlobStorage } from '@handoff/registry/asset-storage/adapters/vercel-blob';
|
|
12
|
+
import { resolveAssetStorageSettings, type ResolvedAssetStorage } from '@handoff/registry/asset-storage/resolve';
|
|
13
|
+
import type { AssetStorage, AssetStorageFactory } from '@handoff/registry/asset-storage/types';
|
|
14
|
+
import type { AssetStorageProvider } from '@handoff/registry/db/schema';
|
|
15
|
+
import { getServerRuntimeConfig } from './docs-api/runtime-config';
|
|
16
|
+
|
|
17
|
+
let activeCache: ResolvedAssetStorage | null = null;
|
|
18
|
+
const adapterCache = new Map<string, AssetStorage | null>();
|
|
19
|
+
|
|
20
|
+
/** The active, fully-resolved asset storage settings for new uploads. */
|
|
21
|
+
export const getActiveAssetStorage = (): ResolvedAssetStorage => {
|
|
22
|
+
if (!activeCache) {
|
|
23
|
+
activeCache = resolveAssetStorageSettings(getServerRuntimeConfig().assetStorage);
|
|
24
|
+
}
|
|
25
|
+
return activeCache;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/** Load a custom adapter module and coerce its default export (adapter object or factory) to an adapter. */
|
|
29
|
+
const loadCustomAdapter = async (active: ResolvedAssetStorage): Promise<AssetStorage> => {
|
|
30
|
+
if (!active.module) {
|
|
31
|
+
throw new Error('A custom asset storage adapter is selected but no module path is configured.');
|
|
32
|
+
}
|
|
33
|
+
// Traced into the registry bundle at build time; resolved by Node at runtime (see build tracing).
|
|
34
|
+
const mod: any = await import(/* webpackIgnore: true */ active.module);
|
|
35
|
+
const exported = mod?.default ?? mod;
|
|
36
|
+
const adapter: unknown = typeof exported === 'function' ? await (exported as AssetStorageFactory)({ options: active.options, env: process.env }) : exported;
|
|
37
|
+
const candidate = adapter as Partial<AssetStorage> | null;
|
|
38
|
+
if (!candidate || typeof candidate.put !== 'function' || typeof candidate.get !== 'function' || typeof candidate.delete !== 'function') {
|
|
39
|
+
throw new Error(`Custom asset storage module "${active.module}" must default-export a defineAssetStorage adapter.`);
|
|
40
|
+
}
|
|
41
|
+
return candidate as AssetStorage;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Resolve the {@link AssetStorage} adapter for a provider id, or `null` when the provider is the
|
|
46
|
+
* inline `database` default (there is no adapter; content lives in the DB row). Reading a blob whose
|
|
47
|
+
* provider is no longer configured throws an actionable error rather than silently failing.
|
|
48
|
+
*/
|
|
49
|
+
export const getAssetStorageAdapter = async (provider: AssetStorageProvider): Promise<AssetStorage | null> => {
|
|
50
|
+
if (provider === 'database') {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
if (adapterCache.has(provider)) {
|
|
54
|
+
return adapterCache.get(provider) ?? null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const active = getActiveAssetStorage();
|
|
58
|
+
let adapter: AssetStorage;
|
|
59
|
+
if (provider === 'vercel-blob') {
|
|
60
|
+
adapter = createVercelBlobStorage({ tokenEnv: active.tokenEnv, options: active.options });
|
|
61
|
+
} else if (active.adapterKind === 'custom' && active.provider === provider) {
|
|
62
|
+
adapter = await loadCustomAdapter(active);
|
|
63
|
+
} else {
|
|
64
|
+
throw new Error(
|
|
65
|
+
`No asset storage adapter is configured for provider "${provider}". A blob was stored by that ` +
|
|
66
|
+
'provider but it is no longer selected. Restore its configuration (or migrate its objects) to read it.'
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
adapterCache.set(provider, adapter);
|
|
71
|
+
return adapter;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
/** The adapter for the active provider (used for new uploads), or `null` for the inline database default. */
|
|
75
|
+
export const getActiveAssetStorageAdapter = (): Promise<AssetStorage | null> => getAssetStorageAdapter(getActiveAssetStorage().provider);
|
|
76
|
+
|
|
77
|
+
/** Reset memoized state (test seam only). */
|
|
78
|
+
export const __resetAssetStorageCache = (): void => {
|
|
79
|
+
activeCache = null;
|
|
80
|
+
adapterCache.clear();
|
|
81
|
+
};
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import fs from 'fs-extra';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Canonical artifact resolution for the workspace-mode docs read API.
|
|
6
|
+
*
|
|
7
|
+
* Logical artifact paths (e.g. `component/badge-primary.html`, `pattern/hero.html`,
|
|
8
|
+
* `component/main.css`) map onto files generated by the build under the app's mirrored
|
|
9
|
+
* `public/api` directory. This module owns the single safe mapping from a logical path to a file
|
|
10
|
+
* on disk: it rejects traversal/malformed paths up front, resolves the content type by extension,
|
|
11
|
+
* and never escapes the generated artifact root.
|
|
12
|
+
*
|
|
13
|
+
* Server-only: imported exclusively by API route handlers.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** Outcome of validating a logical artifact path segment list. */
|
|
17
|
+
export interface ArtifactPathValidation {
|
|
18
|
+
ok: boolean;
|
|
19
|
+
/** Decoded, safe path segments when `ok`; empty otherwise. */
|
|
20
|
+
segments: string[];
|
|
21
|
+
/** Human-readable rejection reason when not `ok`. */
|
|
22
|
+
reason?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Content type served for an artifact, keyed by file extension.
|
|
27
|
+
* A stored content type would win when present; in workspace mode the extension is authoritative.
|
|
28
|
+
*/
|
|
29
|
+
const CONTENT_TYPE_BY_EXT: Record<string, string> = {
|
|
30
|
+
'.json': 'application/json',
|
|
31
|
+
'.html': 'text/html; charset=utf-8',
|
|
32
|
+
'.css': 'text/css; charset=utf-8',
|
|
33
|
+
'.js': 'application/javascript; charset=utf-8',
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const DEFAULT_CONTENT_TYPE = 'text/plain; charset=utf-8';
|
|
37
|
+
|
|
38
|
+
/** Resolve the content type for an artifact path by its extension, defaulting to text/plain. */
|
|
39
|
+
export const contentTypeForArtifactPath = (artifactPath: string): string => {
|
|
40
|
+
const ext = path.extname(artifactPath).toLowerCase();
|
|
41
|
+
return CONTENT_TYPE_BY_EXT[ext] ?? DEFAULT_CONTENT_TYPE;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/** Whether a single decoded path segment is structurally safe (no traversal, no separators). */
|
|
45
|
+
const isSafeSegment = (segment: string): boolean =>
|
|
46
|
+
segment.length > 0 && segment !== '.' && segment !== '..' && !segment.includes('/') && !segment.includes('\\');
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Validate the catch-all `artifactPath` segments coming from the route. Rejects:
|
|
50
|
+
* zero segments; any segment that is empty / `.` / `..` / contains a path separator — checked both
|
|
51
|
+
* raw and after `decodeURIComponent` (so encoded `%2E%2E` is rejected too); or any segment that
|
|
52
|
+
* fails to decode.
|
|
53
|
+
*/
|
|
54
|
+
export const validateArtifactSegments = (rawSegments: string[] | undefined): ArtifactPathValidation => {
|
|
55
|
+
if (!rawSegments || rawSegments.length === 0) {
|
|
56
|
+
return { ok: false, segments: [], reason: 'Artifact path has no segments.' };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const decoded: string[] = [];
|
|
60
|
+
for (const raw of rawSegments) {
|
|
61
|
+
if (!isSafeSegment(raw)) {
|
|
62
|
+
return { ok: false, segments: [], reason: `Unsafe artifact path segment "${raw}".` };
|
|
63
|
+
}
|
|
64
|
+
let decodedSegment: string;
|
|
65
|
+
try {
|
|
66
|
+
decodedSegment = decodeURIComponent(raw);
|
|
67
|
+
} catch {
|
|
68
|
+
return { ok: false, segments: [], reason: `Artifact path segment "${raw}" could not be decoded.` };
|
|
69
|
+
}
|
|
70
|
+
if (!isSafeSegment(decodedSegment)) {
|
|
71
|
+
return { ok: false, segments: [], reason: `Unsafe artifact path segment "${raw}".` };
|
|
72
|
+
}
|
|
73
|
+
decoded.push(decodedSegment);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return { ok: true, segments: decoded };
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Absolute path of the generated artifact root (`public/api`) for the running app. Artifacts are
|
|
81
|
+
* mirrored into the app's own public directory by the build (`syncPublicFiles`), so the app reads
|
|
82
|
+
* them from its module-local `.handoff/{projectId}/public/api` — the same location the rest of the
|
|
83
|
+
* app already reads `components.json`/`patterns.json` from.
|
|
84
|
+
*/
|
|
85
|
+
export const getArtifactRoot = (): string =>
|
|
86
|
+
path.resolve(
|
|
87
|
+
process.env.HANDOFF_MODULE_PATH ?? '',
|
|
88
|
+
'.handoff',
|
|
89
|
+
process.env.HANDOFF_PROJECT_ID ?? '',
|
|
90
|
+
'public',
|
|
91
|
+
'api'
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
/** A resolved artifact ready to serve. */
|
|
95
|
+
export interface ResolvedArtifact {
|
|
96
|
+
absolutePath: string;
|
|
97
|
+
contentType: string;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Resolve already-validated logical artifact segments to a readable file under the artifact root,
|
|
102
|
+
* or `null` when the artifact does not exist. The resolved path is re-checked to be contained
|
|
103
|
+
* within the root as defense in depth, even though segments are pre-validated.
|
|
104
|
+
*/
|
|
105
|
+
export const resolveArtifactFile = (segments: string[]): ResolvedArtifact | null => {
|
|
106
|
+
const root = getArtifactRoot();
|
|
107
|
+
const absolutePath = path.resolve(root, ...segments);
|
|
108
|
+
const relative = path.relative(root, absolutePath);
|
|
109
|
+
if (relative.startsWith('..') || path.isAbsolute(relative)) {
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
if (!fs.existsSync(absolutePath) || !fs.statSync(absolutePath).isFile()) {
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
return { absolutePath, contentType: contentTypeForArtifactPath(absolutePath) };
|
|
116
|
+
};
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import fs from 'fs-extra';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import type { ComponentListObject, PageListObject, PatternListObject } from '@handoff/transformers/preview/types';
|
|
4
|
+
import type { TokenSetKind } from '@handoff/registry/tokens/sets';
|
|
5
|
+
import { FilesystemAssetStore } from '@handoff/store/filesystem-assets';
|
|
6
|
+
import type { AssetContentResource, AssetMetadata, TokenArtifactResource } from '@handoff/store';
|
|
7
|
+
import { getArtifactRoot, resolveArtifactFile } from './artifacts';
|
|
8
|
+
import {
|
|
9
|
+
getComponentDetail as getWorkspaceComponentDetail,
|
|
10
|
+
getPageDetail as getWorkspacePageDetail,
|
|
11
|
+
getPatternDetail as getWorkspacePatternDetail,
|
|
12
|
+
getTokenSetDetail as getWorkspaceTokenSetDetail,
|
|
13
|
+
listComponents as listWorkspaceComponents,
|
|
14
|
+
listPages as listWorkspacePages,
|
|
15
|
+
listPatterns as listWorkspacePatterns,
|
|
16
|
+
listTokenSets as listWorkspaceTokenSets,
|
|
17
|
+
type ComponentDetail,
|
|
18
|
+
type PageDetail,
|
|
19
|
+
type PatternDetail,
|
|
20
|
+
} from './records';
|
|
21
|
+
import { getServerRuntimeConfig } from './runtime-config';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Mode-aware data source for the docs read API.
|
|
25
|
+
*
|
|
26
|
+
* The `/api/docs/*` routes depend only on this interface, so they return the *same stable shapes at
|
|
27
|
+
* the same URLs* regardless of which backing resolves them. Workspace mode reads generated
|
|
28
|
+
* filesystem artifacts; registry mode reads the DB-backed docs read-model artifacts
|
|
29
|
+
* published to the registry. The registry backing is loaded lazily so its Drizzle/Postgres
|
|
30
|
+
* dependencies never enter the workspace/static path.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
/** A resolved artifact ready to write to the response: its content type and raw body. */
|
|
34
|
+
export interface ResolvedArtifactBody {
|
|
35
|
+
contentType: string;
|
|
36
|
+
body: Buffer | string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** A minimal token-set listing item (id + kind) for navigation. */
|
|
40
|
+
export interface TokenSetListItem {
|
|
41
|
+
id: string;
|
|
42
|
+
kind: TokenSetKind;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** A token set's detail: its extracted record slice + every generated artifact, served byte-for-byte. */
|
|
46
|
+
export interface TokenSetDetail {
|
|
47
|
+
id: string;
|
|
48
|
+
kind: TokenSetKind;
|
|
49
|
+
/** `IColorObject[]`/…/`IFileComponentObject` — interpreted by the route per `kind`. */
|
|
50
|
+
record: unknown;
|
|
51
|
+
artifacts: TokenArtifactResource[];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The data operations every docs read API route is expressed in terms of. */
|
|
55
|
+
export interface DocsBackend {
|
|
56
|
+
listComponents(): Promise<ComponentListObject[]>;
|
|
57
|
+
listPatterns(): Promise<PatternListObject[]>;
|
|
58
|
+
/**
|
|
59
|
+
* List published pages. The real consumer is registry-mode navigation; the workspace impl is
|
|
60
|
+
* vestigial (workspace nav is baked at build time and the catch-all reads markdown directly).
|
|
61
|
+
*/
|
|
62
|
+
listPages(): Promise<PageListObject[]>;
|
|
63
|
+
getComponentDetail(id: string): Promise<ComponentDetail | null>;
|
|
64
|
+
getPatternDetail(id: string): Promise<PatternDetail | null>;
|
|
65
|
+
/** A page's record + rendered markdown body, or `null` when absent. Read by the catch-all route. */
|
|
66
|
+
getPageDetail(id: string): Promise<PageDetail | null>;
|
|
67
|
+
/** Resolve an already-validated logical artifact path, or `null` when it cannot be served. */
|
|
68
|
+
resolveArtifact(segments: string[]): Promise<ResolvedArtifactBody | null>;
|
|
69
|
+
/** List logical token sets (id + kind). The real consumer is registry-mode token navigation. */
|
|
70
|
+
listTokenSets(): Promise<TokenSetListItem[]>;
|
|
71
|
+
/** A token set's record + generated artifacts by stable id, or `null` when absent. */
|
|
72
|
+
getTokenSetDetail(id: string): Promise<TokenSetDetail | null>;
|
|
73
|
+
/** Lightweight metadata for every asset in a collection (never binary bodies). */
|
|
74
|
+
listAssets(collection: string): Promise<AssetMetadata[]>;
|
|
75
|
+
/** One asset's resolved content (bytes or a provider redirect), or `null` when absent. */
|
|
76
|
+
getAssetContent(collection: string, assetPath: string): Promise<AssetContentResource | null>;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Workspace asset store over the app's mirrored `public/api` tree + copied archives. Registry mode
|
|
81
|
+
* serves assets from the database; this best-effort filesystem view keeps the {@link DocsBackend}
|
|
82
|
+
* contract honest (workspace pages themselves read assets directly, not through this API).
|
|
83
|
+
*/
|
|
84
|
+
const appPublicRoot = (): string =>
|
|
85
|
+
path.resolve(process.env.HANDOFF_MODULE_PATH ?? '', '.handoff', process.env.HANDOFF_PROJECT_ID ?? '', 'public');
|
|
86
|
+
|
|
87
|
+
let workspaceAssetStore: FilesystemAssetStore | null = null;
|
|
88
|
+
const getWorkspaceAssetStore = (): FilesystemAssetStore => {
|
|
89
|
+
if (!workspaceAssetStore) {
|
|
90
|
+
workspaceAssetStore = new FilesystemAssetStore({
|
|
91
|
+
workingPath: appPublicRoot(),
|
|
92
|
+
getAssetsApiPath: () => getArtifactRoot(),
|
|
93
|
+
getIconsZipFilePath: () => path.join(appPublicRoot(), 'icons.zip'),
|
|
94
|
+
getLogosZipFilePath: () => path.join(appPublicRoot(), 'logos.zip'),
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
return workspaceAssetStore;
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
/** Workspace backing: generated filesystem artifacts under the app's mirrored `public/api` root. */
|
|
101
|
+
const workspaceBackend: DocsBackend = {
|
|
102
|
+
async listComponents() {
|
|
103
|
+
return listWorkspaceComponents();
|
|
104
|
+
},
|
|
105
|
+
async listPatterns() {
|
|
106
|
+
return listWorkspacePatterns();
|
|
107
|
+
},
|
|
108
|
+
async listPages() {
|
|
109
|
+
return listWorkspacePages();
|
|
110
|
+
},
|
|
111
|
+
async getComponentDetail(id: string) {
|
|
112
|
+
return getWorkspaceComponentDetail(id);
|
|
113
|
+
},
|
|
114
|
+
async getPatternDetail(id: string) {
|
|
115
|
+
return getWorkspacePatternDetail(id);
|
|
116
|
+
},
|
|
117
|
+
async getPageDetail(id: string) {
|
|
118
|
+
return getWorkspacePageDetail(id);
|
|
119
|
+
},
|
|
120
|
+
async resolveArtifact(segments: string[]) {
|
|
121
|
+
const resolved = resolveArtifactFile(segments);
|
|
122
|
+
if (!resolved) {
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
return { contentType: resolved.contentType, body: fs.readFileSync(resolved.absolutePath) };
|
|
126
|
+
},
|
|
127
|
+
async listTokenSets() {
|
|
128
|
+
return listWorkspaceTokenSets();
|
|
129
|
+
},
|
|
130
|
+
async getTokenSetDetail(id: string) {
|
|
131
|
+
return getWorkspaceTokenSetDetail(id);
|
|
132
|
+
},
|
|
133
|
+
async listAssets(collection: string) {
|
|
134
|
+
return getWorkspaceAssetStore().listAssets(collection);
|
|
135
|
+
},
|
|
136
|
+
async getAssetContent(collection: string, assetPath: string) {
|
|
137
|
+
return getWorkspaceAssetStore().getAssetContent(collection, assetPath);
|
|
138
|
+
},
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Resolve the docs backend for the active runtime mode. Registry mode dynamically imports the
|
|
143
|
+
* DB-backed backing so workspace dev/build never loads the database driver code.
|
|
144
|
+
*/
|
|
145
|
+
export const resolveDocsBackend = async (): Promise<DocsBackend> => {
|
|
146
|
+
if (getServerRuntimeConfig().mode === 'registry') {
|
|
147
|
+
const { createRegistryDocsBackend } = await import('./registry');
|
|
148
|
+
return createRegistryDocsBackend();
|
|
149
|
+
}
|
|
150
|
+
return workspaceBackend;
|
|
151
|
+
};
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { NextApiRequest, NextApiResponse } from 'next';
|
|
2
|
+
import { singleQueryValue } from '../api/query';
|
|
3
|
+
import { redactSecrets } from '../api/redact';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Docs read API error contract.
|
|
7
|
+
*
|
|
8
|
+
* Unlike the registry management API, docs read responses carry a bare `{ error: { code, message } }`
|
|
9
|
+
* envelope (no `meta`). Codes are deliberately narrow: a malformed/traversal/missing route param or
|
|
10
|
+
* record is `not_found`; a missing content artifact (or a missing required HTML reference) is
|
|
11
|
+
* `artifact_not_found`; everything else maps to `method_not_allowed` or `unexpected_error`.
|
|
12
|
+
*/
|
|
13
|
+
export type DocsErrorCode = 'not_found' | 'artifact_not_found' | 'method_not_allowed' | 'unexpected_error';
|
|
14
|
+
|
|
15
|
+
const STATUS_BY_CODE: Record<DocsErrorCode, number> = {
|
|
16
|
+
not_found: 404,
|
|
17
|
+
artifact_not_found: 404,
|
|
18
|
+
method_not_allowed: 405,
|
|
19
|
+
unexpected_error: 500,
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/** Write a docs read API error response with the status mapped from its code. */
|
|
23
|
+
export const sendDocsError = (res: NextApiResponse, code: DocsErrorCode, message: string): void => {
|
|
24
|
+
res.status(STATUS_BY_CODE[code]).json(redactSecrets({ error: { code, message } }));
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Guard a GET-only docs route. Returns `true` when the request is a GET and the handler should
|
|
29
|
+
* proceed; otherwise it writes a `405 method_not_allowed` response and returns `false`.
|
|
30
|
+
*/
|
|
31
|
+
export const ensureGet = (req: NextApiRequest, res: NextApiResponse): boolean => {
|
|
32
|
+
if (req.method === 'GET') {
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
35
|
+
res.setHeader('Allow', 'GET');
|
|
36
|
+
sendDocsError(res, 'method_not_allowed', `Method ${req.method ?? 'unknown'} not allowed; docs read API is GET-only.`);
|
|
37
|
+
return false;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Resolve an entity id from a dynamic route param that carries an explicit `.json` extension
|
|
42
|
+
* (metadata reads use explicit `.json` URLs). Returns the id without the
|
|
43
|
+
* extension, or `undefined` when the param is missing or not a `.json` request.
|
|
44
|
+
*/
|
|
45
|
+
export const idFromJsonParam = (value: string | string[] | undefined): string | undefined => {
|
|
46
|
+
const raw = singleQueryValue(value);
|
|
47
|
+
if (!raw || !raw.endsWith('.json')) {
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
const id = raw.slice(0, -'.json'.length);
|
|
51
|
+
return id.length > 0 ? id : undefined;
|
|
52
|
+
};
|