handoff-app 2.0.0-0 → 2.0.0-1

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.
@@ -50,14 +50,35 @@ const mirrorDirectory = (sourcePath, destinationPath) => __awaiter(void 0, void
50
50
  yield fs_extra_1.default.ensureDir(path_1.default.dirname(destinationPath));
51
51
  yield fs_extra_1.default.copy(sourcePath, destinationPath, { overwrite: true });
52
52
  });
53
+ /**
54
+ * Stage the fetch-produced asset download bundles (`exported/<id>/{collection}.zip`) into the app
55
+ * public root, where the docs read API's workspace asset store serves them at
56
+ * `/api/docs/assets/{collection}/{collection}.zip` (the canonical download URL). `fetch` writes these
57
+ * bundles, but a plain `build`/`start` cleans and re-stages `.handoff` without carrying them, so the
58
+ * download links would 404 until a fetch happened to run. Copying the committed export bundles here
59
+ * makes the route resolve deterministically in workspace dev and static builds. Absent bundles (a
60
+ * project with no logos/icons) are skipped.
61
+ */
62
+ const stageAssetDownloadBundles = (handoff, destinationPublicPath) => __awaiter(void 0, void 0, void 0, function* () {
63
+ const bundles = [
64
+ { src: handoff.getLogosZipFilePath(), name: 'logos.zip' },
65
+ { src: handoff.getIconsZipFilePath(), name: 'icons.zip' },
66
+ ];
67
+ for (const { src, name } of bundles) {
68
+ if (fs_extra_1.default.existsSync(src) && fs_extra_1.default.statSync(src).isFile()) {
69
+ yield fs_extra_1.default.ensureDir(destinationPublicPath);
70
+ yield fs_extra_1.default.copy(src, path_1.default.resolve(destinationPublicPath, name), { overwrite: true });
71
+ }
72
+ }
73
+ });
53
74
  /**
54
75
  * Copy the public dir from the working dir to the module dir.
55
76
  */
56
77
  const syncPublicFiles = (handoff) => __awaiter(void 0, void 0, void 0, function* () {
57
78
  const appPath = (0, exports.getAppPath)(handoff);
79
+ const destinationPublicPath = path_1.default.resolve(appPath, 'public');
58
80
  const workingPublicPath = (0, exports.getWorkingPublicPath)(handoff);
59
81
  if (workingPublicPath) {
60
- const destinationPublicPath = path_1.default.resolve(appPath, 'public');
61
82
  const sourceApiPath = path_1.default.resolve(workingPublicPath, 'api');
62
83
  const destinationApiPath = path_1.default.resolve(destinationPublicPath, 'api');
63
84
  yield fs_extra_1.default.copy(workingPublicPath, destinationPublicPath, {
@@ -69,5 +90,8 @@ const syncPublicFiles = (handoff) => __awaiter(void 0, void 0, void 0, function*
69
90
  });
70
91
  yield mirrorDirectory(sourceApiPath, destinationApiPath);
71
92
  }
93
+ // Stage the asset download bundles regardless of whether the workspace has a `public/` dir — they
94
+ // come from the fetch output, not the workspace public tree.
95
+ yield stageAssetDownloadBundles(handoff, destinationPublicPath);
72
96
  });
73
97
  exports.syncPublicFiles = syncPublicFiles;
@@ -176,6 +176,31 @@ const materializeDocsReadModel = (handoff, outDir) => __awaiter(void 0, void 0,
176
176
  const detail = Object.assign(Object.assign({}, pattern), { build: { status: deriveBuildStatus(artifactRoot, path_1.default.join('pattern', `${pattern.id}.json`)) } });
177
177
  yield fs_extra_1.default.writeJson(path_1.default.join(docsRoot, 'patterns', `${pattern.id}.json`), detail);
178
178
  }
179
+ yield materializeAssetDownloads(handoff, docsRoot);
179
180
  logger_1.Logger.success(`Materialized docs read model (${components.length} component(s), ${patterns.length} pattern(s)) for static export.`);
180
181
  });
181
182
  exports.materializeDocsReadModel = materializeDocsReadModel;
183
+ /**
184
+ * Materialize the whole-collection asset download bundles at the canonical asset route
185
+ * (`api/docs/assets/{collection}/{collection}.zip`) — the same URL the docs read API serves in
186
+ * registry mode, so the "Download" links resolve identically on a static host. The bundles come from
187
+ * the fetch output (`exported/<id>/{collection}.zip`); `next build` never re-creates them, and the
188
+ * fetch-time copy into the app `public/` is wiped by the build's clean+restage, so they are copied
189
+ * here. Absent bundles (a project with no logos/icons) are skipped, not an error.
190
+ */
191
+ const materializeAssetDownloads = (handoff, docsRoot) => __awaiter(void 0, void 0, void 0, function* () {
192
+ const bundles = [
193
+ { collection: 'logos', src: handoff.getLogosZipFilePath() },
194
+ { collection: 'icons', src: handoff.getIconsZipFilePath() },
195
+ ];
196
+ let copied = 0;
197
+ for (const { collection, src } of bundles) {
198
+ if (fs_extra_1.default.existsSync(src) && fs_extra_1.default.statSync(src).isFile()) {
199
+ yield fs_extra_1.default.copy(src, path_1.default.join(docsRoot, 'assets', collection, `${collection}.zip`), { overwrite: true });
200
+ copied += 1;
201
+ }
202
+ }
203
+ if (copied > 0) {
204
+ logger_1.Logger.info(`Materialized ${copied} asset download bundle(s) for static export.`);
205
+ }
206
+ });
@@ -1,2 +1,2 @@
1
1
  export { ARTIFACT_METADATA_PATH, REACT_PREVIEW_FORMAT_VERSION, SHARED_MAIN_CSS_ARTIFACT_PATH, SHARED_MAIN_JS_ARTIFACT_PATH, SHARED_STYLES_CSS_ARTIFACT_PATH, type ArtifactBuildMetadata, type ArtifactBuildStatus, type ArtifactDescriptor, type ArtifactKind, type ArtifactOwnerKind, type ArtifactReference, type ArtifactReferenceKind, type ArtifactSizeDiagnostics, } from './types';
2
- export { ARTIFACTS_ROUTE_SEGMENT, buildArtifactUrl, normalizeBasePath } from './url';
2
+ export { ARTIFACTS_ROUTE_SEGMENT, ASSETS_ROUTE_SEGMENT, buildArtifactUrl, buildAssetDownloadUrl, buildAssetUrl, normalizeBasePath, } from './url';
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.normalizeBasePath = exports.buildArtifactUrl = exports.ARTIFACTS_ROUTE_SEGMENT = exports.SHARED_STYLES_CSS_ARTIFACT_PATH = exports.SHARED_MAIN_JS_ARTIFACT_PATH = exports.SHARED_MAIN_CSS_ARTIFACT_PATH = exports.REACT_PREVIEW_FORMAT_VERSION = exports.ARTIFACT_METADATA_PATH = void 0;
3
+ exports.normalizeBasePath = exports.buildAssetUrl = exports.buildAssetDownloadUrl = exports.buildArtifactUrl = exports.ASSETS_ROUTE_SEGMENT = exports.ARTIFACTS_ROUTE_SEGMENT = exports.SHARED_STYLES_CSS_ARTIFACT_PATH = exports.SHARED_MAIN_JS_ARTIFACT_PATH = exports.SHARED_MAIN_CSS_ARTIFACT_PATH = exports.REACT_PREVIEW_FORMAT_VERSION = exports.ARTIFACT_METADATA_PATH = void 0;
4
4
  var types_1 = require("./types");
5
5
  Object.defineProperty(exports, "ARTIFACT_METADATA_PATH", { enumerable: true, get: function () { return types_1.ARTIFACT_METADATA_PATH; } });
6
6
  Object.defineProperty(exports, "REACT_PREVIEW_FORMAT_VERSION", { enumerable: true, get: function () { return types_1.REACT_PREVIEW_FORMAT_VERSION; } });
@@ -9,5 +9,8 @@ Object.defineProperty(exports, "SHARED_MAIN_JS_ARTIFACT_PATH", { enumerable: tru
9
9
  Object.defineProperty(exports, "SHARED_STYLES_CSS_ARTIFACT_PATH", { enumerable: true, get: function () { return types_1.SHARED_STYLES_CSS_ARTIFACT_PATH; } });
10
10
  var url_1 = require("./url");
11
11
  Object.defineProperty(exports, "ARTIFACTS_ROUTE_SEGMENT", { enumerable: true, get: function () { return url_1.ARTIFACTS_ROUTE_SEGMENT; } });
12
+ Object.defineProperty(exports, "ASSETS_ROUTE_SEGMENT", { enumerable: true, get: function () { return url_1.ASSETS_ROUTE_SEGMENT; } });
12
13
  Object.defineProperty(exports, "buildArtifactUrl", { enumerable: true, get: function () { return url_1.buildArtifactUrl; } });
14
+ Object.defineProperty(exports, "buildAssetDownloadUrl", { enumerable: true, get: function () { return url_1.buildAssetDownloadUrl; } });
15
+ Object.defineProperty(exports, "buildAssetUrl", { enumerable: true, get: function () { return url_1.buildAssetUrl; } });
13
16
  Object.defineProperty(exports, "normalizeBasePath", { enumerable: true, get: function () { return url_1.normalizeBasePath; } });
@@ -17,6 +17,8 @@
17
17
  export declare const ARTIFACTS_ROUTE_SEGMENT = "api/docs/artifacts";
18
18
  /** Route prefix for docs read API metadata (`.json`) reads. */
19
19
  export declare const DOCS_ROUTE_SEGMENT = "api/docs";
20
+ /** Canonical route prefix all asset content URLs are served under (mode-independent). */
21
+ export declare const ASSETS_ROUTE_SEGMENT = "api/docs/assets";
20
22
  /**
21
23
  * Normalize a configured basePath to either an empty string or a single-leading-slash,
22
24
  * no-trailing-slash form. Mirrors the Next app's `resolveBasePath` so builder output composes
@@ -47,3 +49,19 @@ export declare const buildComponentDetailUrl: (id: string, basePath?: string | n
47
49
  * (`{basePath}/api/docs/patterns/{id}.json`). The id is URL-encoded; `basePath` is preserved.
48
50
  */
49
51
  export declare const buildPatternDetailUrl: (id: string, basePath?: string | null) => string;
52
+ /**
53
+ * Build the canonical, basePath-aware content URL for one asset within a collection
54
+ * (`{basePath}/api/docs/assets/{collection}/{logicalPath}`). This is the same route the docs read API
55
+ * serves in every runtime mode: from the database in registry mode, and from statically materialized
56
+ * files in a static export. Each segment is individually encoded and traversal segments are rejected,
57
+ * mirroring {@link buildArtifactUrl}.
58
+ *
59
+ * @throws If the collection or logical path has no usable segments, or contains a `.`/`..` segment.
60
+ */
61
+ export declare const buildAssetUrl: (collection: string, logicalPath: string, basePath?: string | null) => string;
62
+ /**
63
+ * Build the canonical download URL for a collection's whole-collection zip bundle
64
+ * (`{basePath}/api/docs/assets/{collection}/{collection}.zip`) — the default target for the docs
65
+ * "Download" links, overridable via `assets_zip_links`.
66
+ */
67
+ export declare const buildAssetDownloadUrl: (collection: string, basePath?: string | null) => string;
@@ -15,11 +15,13 @@
15
15
  * Next app can both import it.
16
16
  */
17
17
  Object.defineProperty(exports, "__esModule", { value: true });
18
- exports.buildPatternDetailUrl = exports.buildComponentDetailUrl = exports.buildArtifactUrl = exports.normalizeBasePath = exports.DOCS_ROUTE_SEGMENT = exports.ARTIFACTS_ROUTE_SEGMENT = void 0;
18
+ exports.buildAssetDownloadUrl = exports.buildAssetUrl = exports.buildPatternDetailUrl = exports.buildComponentDetailUrl = exports.buildArtifactUrl = exports.normalizeBasePath = exports.ASSETS_ROUTE_SEGMENT = exports.DOCS_ROUTE_SEGMENT = exports.ARTIFACTS_ROUTE_SEGMENT = void 0;
19
19
  /** Canonical route prefix all artifact URLs are served under. */
20
20
  exports.ARTIFACTS_ROUTE_SEGMENT = 'api/docs/artifacts';
21
21
  /** Route prefix for docs read API metadata (`.json`) reads. */
22
22
  exports.DOCS_ROUTE_SEGMENT = 'api/docs';
23
+ /** Canonical route prefix all asset content URLs are served under (mode-independent). */
24
+ exports.ASSETS_ROUTE_SEGMENT = 'api/docs/assets';
23
25
  /**
24
26
  * Normalize a configured basePath to either an empty string or a single-leading-slash,
25
27
  * no-trailing-slash form. Mirrors the Next app's `resolveBasePath` so builder output composes
@@ -84,3 +86,31 @@ exports.buildComponentDetailUrl = buildComponentDetailUrl;
84
86
  */
85
87
  const buildPatternDetailUrl = (id, basePath) => `${(0, exports.normalizeBasePath)(basePath)}/${exports.DOCS_ROUTE_SEGMENT}/patterns/${encodeURIComponent(id)}.json`;
86
88
  exports.buildPatternDetailUrl = buildPatternDetailUrl;
89
+ /**
90
+ * Build the canonical, basePath-aware content URL for one asset within a collection
91
+ * (`{basePath}/api/docs/assets/{collection}/{logicalPath}`). This is the same route the docs read API
92
+ * serves in every runtime mode: from the database in registry mode, and from statically materialized
93
+ * files in a static export. Each segment is individually encoded and traversal segments are rejected,
94
+ * mirroring {@link buildArtifactUrl}.
95
+ *
96
+ * @throws If the collection or logical path has no usable segments, or contains a `.`/`..` segment.
97
+ */
98
+ const buildAssetUrl = (collection, logicalPath, basePath) => {
99
+ const segments = [...toArtifactSegments(collection), ...toArtifactSegments(logicalPath)];
100
+ if (segments.length < 2) {
101
+ throw new Error(`Cannot build asset URL: empty collection ("${collection}") or path ("${logicalPath}").`);
102
+ }
103
+ if (segments.some(isTraversalSegment)) {
104
+ throw new Error(`Cannot build asset URL: path traversal is not allowed in "${collection}/${logicalPath}".`);
105
+ }
106
+ const encoded = segments.map((segment) => encodeURIComponent(segment)).join('/');
107
+ return `${(0, exports.normalizeBasePath)(basePath)}/${exports.ASSETS_ROUTE_SEGMENT}/${encoded}`;
108
+ };
109
+ exports.buildAssetUrl = buildAssetUrl;
110
+ /**
111
+ * Build the canonical download URL for a collection's whole-collection zip bundle
112
+ * (`{basePath}/api/docs/assets/{collection}/{collection}.zip`) — the default target for the docs
113
+ * "Download" links, overridable via `assets_zip_links`.
114
+ */
115
+ const buildAssetDownloadUrl = (collection, basePath) => (0, exports.buildAssetUrl)(collection, `${collection}.zip`, basePath);
116
+ exports.buildAssetDownloadUrl = buildAssetDownloadUrl;
@@ -214,18 +214,18 @@ export interface Config {
214
214
  */
215
215
  entries?: ConfigEntries;
216
216
  /**
217
- * Configuration for asset zip file download links
218
- * @default { icons: "/icons.zip", logos: "/logos.zip" }
217
+ * Override URLs for the asset zip download links. When unset, each link defaults to the canonical,
218
+ * basePath-aware asset route `{basePath}/api/docs/assets/{collection}/{collection}.zip`, served by
219
+ * the docs read API in every runtime mode (from the DB in registry mode, from statically
220
+ * materialized files in a static export).
219
221
  */
220
222
  assets_zip_links?: {
221
223
  /**
222
- * Path to the icons zip file
223
- * @default "/icons.zip"
224
+ * Override URL for the icons zip. Defaults to `/api/docs/assets/icons/icons.zip` (basePath-aware).
224
225
  */
225
226
  icons?: string;
226
227
  /**
227
- * Path to the logos zip file
228
- * @default "/logos.zip"
228
+ * Override URL for the logos zip. Defaults to `/api/docs/assets/logos/logos.zip` (basePath-aware).
229
229
  */
230
230
  logos?: string;
231
231
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "handoff-app",
3
- "version": "2.0.0-0",
3
+ "version": "2.0.0-1",
4
4
  "description": "Automated documentation toolchain for building client side documentation from figma",
5
5
  "author ": {
6
6
  "name": "Convertiv",
@@ -2,11 +2,17 @@ import React from 'react';
2
2
  import type { DisplayableAsset } from '../util/useCollectionAssets';
3
3
 
4
4
  export const DisplayLogo: React.FC<{ logo: DisplayableAsset; content?: string; correct?: boolean }> = ({ logo, content }) => {
5
- // Registry serves the individual asset content URL; workspace/static serves the mirrored file.
5
+ // Workspace/static ships the SVG body inline in tokens.json (`data`) — render it directly so the
6
+ // logo has no dependency on the fetch-only `/api/assets/logos/*` files (absent on a static host).
7
+ // Registry has no inline body; it falls back to the individual asset content URL (`src`).
6
8
  const src = logo.src ?? `${process.env.HANDOFF_APP_BASE_PATH ?? ''}/api/assets/logos/${logo.path}`;
7
9
  return (
8
10
  <div className="flex flex-col gap-5">
9
- <img src={src} alt="Usage Cards" className="rounded-3xl" />
11
+ {logo.data ? (
12
+ <div className="rounded-3xl" dangerouslySetInnerHTML={{ __html: logo.data }} />
13
+ ) : (
14
+ <img src={src} alt={logo.name} className="rounded-3xl" />
15
+ )}
10
16
  <div className="flex flex-row gap-4">
11
17
  <img src={`${process.env.HANDOFF_APP_BASE_PATH ?? ''}/assets/images/check-circle.svg`} alt="Do" className="h-6 w-6" />
12
18
  <p className="leading-normal text-gray-500">
@@ -6,6 +6,7 @@ import rehypeRaw from 'rehype-raw';
6
6
  import remarkGfm from 'remark-gfm';
7
7
  import Footer from '../../components/Footer';
8
8
  import { MarkdownComponents, remarkCodeMeta } from '../../components/Markdown/MarkdownComponents';
9
+ import { buildAssetDownloadUrl } from '@handoff/artifacts/url';
9
10
  import Header from '../../components/old/Header';
10
11
  import { DocumentationProps, fetchDocPageMarkdown, getClientRuntimeConfig } from '../../components/util';
11
12
 
@@ -52,7 +53,9 @@ const AssetsLogosPage = ({ content, menu, metadata, config }: DocumentationProps
52
53
  <h4>{config?.app?.client} Logo</h4>
53
54
  <p>Vector files of approved {config?.app?.client} logos.</p>
54
55
  <p>
55
- <a href={config?.assets_zip_links?.logos ?? '/logos.zip'}>Download Logos</a>
56
+ <a href={config?.assets_zip_links?.logos ?? buildAssetDownloadUrl('logos', process.env.HANDOFF_APP_BASE_PATH)} download>
57
+ Download Logos
58
+ </a>
56
59
  </p>
57
60
  </div>
58
61
  </div>
@@ -124,13 +124,13 @@ export default function SingleIcon({ menu, metadata, current, config, assets }:
124
124
  Copy SVG <Code strokeWidth={1.5} />
125
125
  </Link>
126
126
 
127
- <Link
127
+ <a
128
128
  href={icon.src ?? 'data:text/plain;charset=utf-8,' + encodeURIComponent(icon.data ?? '')}
129
129
  download={icon.name}
130
130
  className={buttonVariants({ variant: 'outline', size: 'sm' }) + ' font-normal [&_svg]:size-3!'}
131
131
  >
132
132
  Download SVG <Download strokeWidth={1.5} />
133
- </Link>
133
+ </a>
134
134
  </div>
135
135
  </div>
136
136
 
@@ -12,6 +12,7 @@ import { MarkdownComponents, remarkCodeMeta } from '../../../components/Markdown
12
12
  import HeadersType from '../../../components/Typography/Headers';
13
13
  import { buttonVariants } from '../../../components/ui/button';
14
14
  import { Input } from '../../../components/ui/input';
15
+ import { buildAssetDownloadUrl } from '@handoff/artifacts/url';
15
16
  import { AssetDocumentationProps, buildTimeAssets, fetchDocPageMarkdown, getClientRuntimeConfig } from '../../../components/util';
16
17
  import { useCollectionAssets, type DisplayableAsset } from '../../../components/util/useCollectionAssets';
17
18
 
@@ -94,12 +95,13 @@ const IconsPage = ({ content, menu, metadata, current, config, assets }: AssetDo
94
95
  <HeadersType.H1>{metadata.title}</HeadersType.H1>
95
96
  <p className="max-w-[800px] text-lg font-light text-gray-500 dark:text-gray-300">{metadata.description}</p>
96
97
  <div className="mt-3 flex flex-row gap-3">
97
- <Link
98
+ <a
98
99
  className={buttonVariants({ variant: 'outline', size: 'sm' }) + ' font-normal [&_svg]:size-3!'}
99
- href={config?.assets_zip_links?.icons ?? '/icons.zip'}
100
+ href={config?.assets_zip_links?.icons ?? buildAssetDownloadUrl('icons', process.env.HANDOFF_APP_BASE_PATH)}
101
+ download
100
102
  >
101
103
  Download Icons <Download strokeWidth={1.5} />
102
- </Link>
104
+ </a>
103
105
  </div>
104
106
  </div>
105
107
  <hr className="mb-10" />
@@ -1,6 +1,6 @@
1
1
  import type { GetStaticProps } from 'next';
2
- import Link from 'next/link';
3
2
 
3
+ import { buildAssetDownloadUrl } from '@handoff/artifacts/url';
4
4
  import { AssetDocumentationProps, buildTimeAssets, fetchDocPageMarkdown, getClientRuntimeConfig } from '../../components/util';
5
5
  import { useCollectionAssets } from '../../components/util/useCollectionAssets';
6
6
 
@@ -56,12 +56,13 @@ const LogoPage = ({ content, menu, metadata, current, config, assets }: AssetDoc
56
56
  <HeadersType.H1>{metadata.title}</HeadersType.H1>
57
57
  <p className="text-lg leading-relaxed text-gray-600 dark:text-gray-300">{metadata.description}</p>
58
58
  <div className="mt-3 flex flex-row gap-3">
59
- <Link
59
+ <a
60
60
  className={buttonVariants({ variant: 'outline', size: 'sm' }) + ' font-normal [&_svg]:size-3!'}
61
- href={config?.assets_zip_links?.logos ?? '/logos.zip'}
61
+ href={config?.assets_zip_links?.logos ?? buildAssetDownloadUrl('logos', process.env.HANDOFF_APP_BASE_PATH)}
62
+ download
62
63
  >
63
64
  Download Logos <Download strokeWidth={1.5} />
64
- </Link>
65
+ </a>
65
66
  </div>
66
67
  </div>
67
68
 
@@ -41,14 +41,36 @@ const mirrorDirectory = async (sourcePath: string, destinationPath: string): Pro
41
41
  await fs.copy(sourcePath, destinationPath, { overwrite: true });
42
42
  };
43
43
 
44
+ /**
45
+ * Stage the fetch-produced asset download bundles (`exported/<id>/{collection}.zip`) into the app
46
+ * public root, where the docs read API's workspace asset store serves them at
47
+ * `/api/docs/assets/{collection}/{collection}.zip` (the canonical download URL). `fetch` writes these
48
+ * bundles, but a plain `build`/`start` cleans and re-stages `.handoff` without carrying them, so the
49
+ * download links would 404 until a fetch happened to run. Copying the committed export bundles here
50
+ * makes the route resolve deterministically in workspace dev and static builds. Absent bundles (a
51
+ * project with no logos/icons) are skipped.
52
+ */
53
+ const stageAssetDownloadBundles = async (handoff: Handoff, destinationPublicPath: string): Promise<void> => {
54
+ const bundles: Array<{ src: string; name: string }> = [
55
+ { src: handoff.getLogosZipFilePath(), name: 'logos.zip' },
56
+ { src: handoff.getIconsZipFilePath(), name: 'icons.zip' },
57
+ ];
58
+ for (const { src, name } of bundles) {
59
+ if (fs.existsSync(src) && fs.statSync(src).isFile()) {
60
+ await fs.ensureDir(destinationPublicPath);
61
+ await fs.copy(src, path.resolve(destinationPublicPath, name), { overwrite: true });
62
+ }
63
+ }
64
+ };
65
+
44
66
  /**
45
67
  * Copy the public dir from the working dir to the module dir.
46
68
  */
47
69
  export const syncPublicFiles = async (handoff: Handoff): Promise<void> => {
48
70
  const appPath = getAppPath(handoff);
71
+ const destinationPublicPath = path.resolve(appPath, 'public');
49
72
  const workingPublicPath = getWorkingPublicPath(handoff);
50
73
  if (workingPublicPath) {
51
- const destinationPublicPath = path.resolve(appPath, 'public');
52
74
  const sourceApiPath = path.resolve(workingPublicPath, 'api');
53
75
  const destinationApiPath = path.resolve(destinationPublicPath, 'api');
54
76
 
@@ -62,4 +84,8 @@ export const syncPublicFiles = async (handoff: Handoff): Promise<void> => {
62
84
 
63
85
  await mirrorDirectory(sourceApiPath, destinationApiPath);
64
86
  }
87
+
88
+ // Stage the asset download bundles regardless of whether the workspace has a `public/` dir — they
89
+ // come from the fetch output, not the workspace public tree.
90
+ await stageAssetDownloadBundles(handoff, destinationPublicPath);
65
91
  };
@@ -209,7 +209,34 @@ export const materializeDocsReadModel = async (handoff: Handoff, outDir: string)
209
209
  await fs.writeJson(path.join(docsRoot, 'patterns', `${pattern.id}.json`), detail);
210
210
  }
211
211
 
212
+ await materializeAssetDownloads(handoff, docsRoot);
213
+
212
214
  Logger.success(
213
215
  `Materialized docs read model (${components.length} component(s), ${patterns.length} pattern(s)) for static export.`
214
216
  );
217
+ }
218
+
219
+ /**
220
+ * Materialize the whole-collection asset download bundles at the canonical asset route
221
+ * (`api/docs/assets/{collection}/{collection}.zip`) — the same URL the docs read API serves in
222
+ * registry mode, so the "Download" links resolve identically on a static host. The bundles come from
223
+ * the fetch output (`exported/<id>/{collection}.zip`); `next build` never re-creates them, and the
224
+ * fetch-time copy into the app `public/` is wiped by the build's clean+restage, so they are copied
225
+ * here. Absent bundles (a project with no logos/icons) are skipped, not an error.
226
+ */
227
+ const materializeAssetDownloads = async (handoff: Handoff, docsRoot: string): Promise<void> => {
228
+ const bundles: Array<{ collection: string; src: string }> = [
229
+ { collection: 'logos', src: handoff.getLogosZipFilePath() },
230
+ { collection: 'icons', src: handoff.getIconsZipFilePath() },
231
+ ];
232
+ let copied = 0;
233
+ for (const { collection, src } of bundles) {
234
+ if (fs.existsSync(src) && fs.statSync(src).isFile()) {
235
+ await fs.copy(src, path.join(docsRoot, 'assets', collection, `${collection}.zip`), { overwrite: true });
236
+ copied += 1;
237
+ }
238
+ }
239
+ if (copied > 0) {
240
+ Logger.info(`Materialized ${copied} asset download bundle(s) for static export.`);
241
+ }
215
242
  };
@@ -14,4 +14,11 @@ export {
14
14
  type ArtifactSizeDiagnostics,
15
15
  } from './types';
16
16
 
17
- export { ARTIFACTS_ROUTE_SEGMENT, buildArtifactUrl, normalizeBasePath } from './url';
17
+ export {
18
+ ARTIFACTS_ROUTE_SEGMENT,
19
+ ASSETS_ROUTE_SEGMENT,
20
+ buildArtifactUrl,
21
+ buildAssetDownloadUrl,
22
+ buildAssetUrl,
23
+ normalizeBasePath,
24
+ } from './url';
@@ -20,6 +20,9 @@ export const ARTIFACTS_ROUTE_SEGMENT = 'api/docs/artifacts';
20
20
  /** Route prefix for docs read API metadata (`.json`) reads. */
21
21
  export const DOCS_ROUTE_SEGMENT = 'api/docs';
22
22
 
23
+ /** Canonical route prefix all asset content URLs are served under (mode-independent). */
24
+ export const ASSETS_ROUTE_SEGMENT = 'api/docs/assets';
25
+
23
26
  /**
24
27
  * Normalize a configured basePath to either an empty string or a single-leading-slash,
25
28
  * no-trailing-slash form. Mirrors the Next app's `resolveBasePath` so builder output composes
@@ -88,3 +91,32 @@ export const buildComponentDetailUrl = (id: string, basePath?: string | null): s
88
91
  */
89
92
  export const buildPatternDetailUrl = (id: string, basePath?: string | null): string =>
90
93
  `${normalizeBasePath(basePath)}/${DOCS_ROUTE_SEGMENT}/patterns/${encodeURIComponent(id)}.json`;
94
+
95
+ /**
96
+ * Build the canonical, basePath-aware content URL for one asset within a collection
97
+ * (`{basePath}/api/docs/assets/{collection}/{logicalPath}`). This is the same route the docs read API
98
+ * serves in every runtime mode: from the database in registry mode, and from statically materialized
99
+ * files in a static export. Each segment is individually encoded and traversal segments are rejected,
100
+ * mirroring {@link buildArtifactUrl}.
101
+ *
102
+ * @throws If the collection or logical path has no usable segments, or contains a `.`/`..` segment.
103
+ */
104
+ export const buildAssetUrl = (collection: string, logicalPath: string, basePath?: string | null): string => {
105
+ const segments = [...toArtifactSegments(collection), ...toArtifactSegments(logicalPath)];
106
+ if (segments.length < 2) {
107
+ throw new Error(`Cannot build asset URL: empty collection ("${collection}") or path ("${logicalPath}").`);
108
+ }
109
+ if (segments.some(isTraversalSegment)) {
110
+ throw new Error(`Cannot build asset URL: path traversal is not allowed in "${collection}/${logicalPath}".`);
111
+ }
112
+ const encoded = segments.map((segment) => encodeURIComponent(segment)).join('/');
113
+ return `${normalizeBasePath(basePath)}/${ASSETS_ROUTE_SEGMENT}/${encoded}`;
114
+ };
115
+
116
+ /**
117
+ * Build the canonical download URL for a collection's whole-collection zip bundle
118
+ * (`{basePath}/api/docs/assets/{collection}/{collection}.zip`) — the default target for the docs
119
+ * "Download" links, overridable via `assets_zip_links`.
120
+ */
121
+ export const buildAssetDownloadUrl = (collection: string, basePath?: string | null): string =>
122
+ buildAssetUrl(collection, `${collection}.zip`, basePath);
@@ -214,18 +214,18 @@ export interface Config {
214
214
  */
215
215
  entries?: ConfigEntries;
216
216
  /**
217
- * Configuration for asset zip file download links
218
- * @default { icons: "/icons.zip", logos: "/logos.zip" }
217
+ * Override URLs for the asset zip download links. When unset, each link defaults to the canonical,
218
+ * basePath-aware asset route `{basePath}/api/docs/assets/{collection}/{collection}.zip`, served by
219
+ * the docs read API in every runtime mode (from the DB in registry mode, from statically
220
+ * materialized files in a static export).
219
221
  */
220
222
  assets_zip_links?: {
221
223
  /**
222
- * Path to the icons zip file
223
- * @default "/icons.zip"
224
+ * Override URL for the icons zip. Defaults to `/api/docs/assets/icons/icons.zip` (basePath-aware).
224
225
  */
225
226
  icons?: string;
226
227
  /**
227
- * Path to the logos zip file
228
- * @default "/logos.zip"
228
+ * Override URL for the logos zip. Defaults to `/api/docs/assets/logos/logos.zip` (basePath-aware).
229
229
  */
230
230
  logos?: string;
231
231
  };