@tomako/tools-runtime 0.1.6 → 0.1.8
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/package.json +1 -1
- package/src/features/tools/app-store-screenshot-generator/widget/app-store-screenshot-generator.tsx +44 -10
- package/src/features/tools/app-store-screenshot-generator/widget/asset-slots.test.ts +52 -0
- package/src/features/tools/app-store-screenshot-generator/widget/asset-slots.ts +3 -2
- package/src/features/tools/app-store-screenshot-generator/widget/input-media.test.ts +81 -0
- package/src/features/tools/app-store-screenshot-generator/widget/input-media.ts +20 -8
- package/src/lib/static-asset.test.ts +36 -0
- package/src/lib/static-asset.ts +7 -10
package/package.json
CHANGED
package/src/features/tools/app-store-screenshot-generator/widget/app-store-screenshot-generator.tsx
CHANGED
|
@@ -44,7 +44,7 @@ import {
|
|
|
44
44
|
buildGeneratedAssetSlots,
|
|
45
45
|
clampAssetCount,
|
|
46
46
|
dimensionsForAssetFormat,
|
|
47
|
-
|
|
47
|
+
gridClassForAssetFormat,
|
|
48
48
|
} from "./asset-slots";
|
|
49
49
|
import {
|
|
50
50
|
LOGO_REFERENCE_MAX_DATA_URL_LENGTH,
|
|
@@ -160,9 +160,12 @@ export function AppStoreScreenshotGenerator() {
|
|
|
160
160
|
() => downloadableAssets.filter((asset) => downloadSelectionIds.has(asset.id)),
|
|
161
161
|
[downloadSelectionIds, downloadableAssets],
|
|
162
162
|
);
|
|
163
|
+
const soleDownloadableAsset = downloadableAssets.length === 1 ? downloadableAssets[0] : null;
|
|
163
164
|
const downloadButtonLabel = downloadSelectionMode
|
|
164
165
|
? `${t("download")} ${selectedDownloadAssets.length}/${downloadableAssets.length}`
|
|
165
|
-
:
|
|
166
|
+
: soleDownloadableAsset
|
|
167
|
+
? t("downloadPng")
|
|
168
|
+
: t("download");
|
|
166
169
|
|
|
167
170
|
async function submit(event: FormEvent<HTMLFormElement>) {
|
|
168
171
|
event.preventDefault();
|
|
@@ -489,6 +492,14 @@ export function AppStoreScreenshotGenerator() {
|
|
|
489
492
|
if (!downloadableAssets.length || downloading) return;
|
|
490
493
|
setDownloadError(null);
|
|
491
494
|
|
|
495
|
+
if (soleDownloadableAsset) {
|
|
496
|
+
await downloadAssets([soleDownloadableAsset], {
|
|
497
|
+
forceZip: false,
|
|
498
|
+
zipName: buildScreenshotsZipName(draft, "selected"),
|
|
499
|
+
});
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
502
|
+
|
|
492
503
|
if (!downloadSelectionMode) {
|
|
493
504
|
setDownloadSelectionMode(true);
|
|
494
505
|
setDownloadSelectionIds(
|
|
@@ -521,6 +532,15 @@ export function AppStoreScreenshotGenerator() {
|
|
|
521
532
|
});
|
|
522
533
|
}
|
|
523
534
|
|
|
535
|
+
function handleDownloadSvgClick(asset: AppStoreScreenshotAsset) {
|
|
536
|
+
if (!asset.svg || downloading) return;
|
|
537
|
+
setDownloadError(null);
|
|
538
|
+
downloadBlob(
|
|
539
|
+
new Blob([asset.svg], { type: "image/svg+xml;charset=utf-8" }),
|
|
540
|
+
`${assetFileBase(draft, asset)}.svg`,
|
|
541
|
+
);
|
|
542
|
+
}
|
|
543
|
+
|
|
524
544
|
function cancelDownloadSelection() {
|
|
525
545
|
setDownloadSelectionMode(false);
|
|
526
546
|
setDownloadSelectionIds(new Set());
|
|
@@ -998,6 +1018,18 @@ export function AppStoreScreenshotGenerator() {
|
|
|
998
1018
|
{downloading ? t("downloading") : downloadButtonLabel}
|
|
999
1019
|
</span>
|
|
1000
1020
|
</Button>
|
|
1021
|
+
{soleDownloadableAsset?.svg ? (
|
|
1022
|
+
<Button
|
|
1023
|
+
type="button"
|
|
1024
|
+
variant="outline"
|
|
1025
|
+
onClick={() => handleDownloadSvgClick(soleDownloadableAsset)}
|
|
1026
|
+
disabled={downloading}
|
|
1027
|
+
className="h-10 rounded-lg border-[#D8D4CA] bg-white px-4 text-xs font-semibold text-[#111111] shadow-none hover:bg-[#F4F4F4] disabled:pointer-events-none disabled:opacity-55"
|
|
1028
|
+
>
|
|
1029
|
+
<Download aria-hidden className="size-4" />
|
|
1030
|
+
{t("downloadSvg")}
|
|
1031
|
+
</Button>
|
|
1032
|
+
) : null}
|
|
1001
1033
|
{downloadSelectionMode ? (
|
|
1002
1034
|
<Button
|
|
1003
1035
|
type="button"
|
|
@@ -1095,7 +1127,13 @@ export function AppStoreScreenshotGenerator() {
|
|
|
1095
1127
|
</div>
|
|
1096
1128
|
</div>
|
|
1097
1129
|
) : null}
|
|
1098
|
-
<div
|
|
1130
|
+
<div
|
|
1131
|
+
className={`grid items-start gap-4 ${
|
|
1132
|
+
resultSlots.length === 1
|
|
1133
|
+
? "grid-cols-1"
|
|
1134
|
+
: "grid-cols-1 sm:grid-cols-2 xl:grid-cols-3"
|
|
1135
|
+
}`}
|
|
1136
|
+
>
|
|
1099
1137
|
{resultSlots.map(({ asset, format, slot }) => {
|
|
1100
1138
|
if (!asset) {
|
|
1101
1139
|
const dimensions = dimensionsForAssetFormat(format);
|
|
@@ -1104,11 +1142,8 @@ export function AppStoreScreenshotGenerator() {
|
|
|
1104
1142
|
key={`pending-${slot}`}
|
|
1105
1143
|
aria-label={t("pendingImageLoading", { index: slot })}
|
|
1106
1144
|
role="status"
|
|
1107
|
-
style={{
|
|
1108
|
-
|
|
1109
|
-
width: widthForAssetFormat(format),
|
|
1110
|
-
}}
|
|
1111
|
-
className="flex max-w-full flex-col items-center justify-center rounded-lg border border-dashed border-[#D8D4CA] bg-white p-4 text-center shadow-[0_8px_22px_rgba(17,17,17,0.04)]"
|
|
1145
|
+
style={{ aspectRatio: `${dimensions.width} / ${dimensions.height}` }}
|
|
1146
|
+
className={`flex w-full max-w-full flex-col items-center justify-center rounded-lg border border-dashed border-[#D8D4CA] bg-white p-4 text-center shadow-[0_8px_22px_rgba(17,17,17,0.04)] ${gridClassForAssetFormat(format, resultSlots.length === 1)}`}
|
|
1112
1147
|
>
|
|
1113
1148
|
<span className="flex size-10 items-center justify-center rounded-lg bg-[#F4F4F4] text-[#111111]">
|
|
1114
1149
|
<Loader2 aria-hidden className="size-4 animate-spin" />
|
|
@@ -1130,8 +1165,7 @@ export function AppStoreScreenshotGenerator() {
|
|
|
1130
1165
|
return (
|
|
1131
1166
|
<div
|
|
1132
1167
|
key={asset.id}
|
|
1133
|
-
|
|
1134
|
-
className="relative max-w-full"
|
|
1168
|
+
className={`relative w-full max-w-full ${gridClassForAssetFormat(asset.format, resultSlots.length === 1)}`}
|
|
1135
1169
|
>
|
|
1136
1170
|
<button
|
|
1137
1171
|
type="button"
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
|
|
4
|
+
import type { AppStoreScreenshotAsset } from "../../../../lib/tools/app-store-screenshot-generator-schema";
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
buildGeneratedAssetSlots,
|
|
8
|
+
gridClassForAssetFormat,
|
|
9
|
+
} from "./asset-slots";
|
|
10
|
+
|
|
11
|
+
function asset(overrides: Partial<AppStoreScreenshotAsset>): AppStoreScreenshotAsset {
|
|
12
|
+
return {
|
|
13
|
+
id: "asset-1",
|
|
14
|
+
platform: "google",
|
|
15
|
+
format: "googlePhone",
|
|
16
|
+
width: 1080,
|
|
17
|
+
height: 1920,
|
|
18
|
+
imageUrl: "https://example.com/generated.png",
|
|
19
|
+
...overrides,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe("app store screenshot result slots", () => {
|
|
24
|
+
it("keeps completed and pending Google assets in the same ordered slot model", () => {
|
|
25
|
+
const slots = buildGeneratedAssetSlots({
|
|
26
|
+
assets: [asset({ id: "phone-2", slot: 2 })],
|
|
27
|
+
expectedCount: 3,
|
|
28
|
+
includePending: true,
|
|
29
|
+
platform: "google",
|
|
30
|
+
totalCount: 3,
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
assert.deepEqual(
|
|
34
|
+
slots.map(({ asset: result, format, slot }) => ({ id: result?.id, format, slot })),
|
|
35
|
+
[
|
|
36
|
+
{ id: undefined, format: "featureGraphic", slot: 1 },
|
|
37
|
+
{ id: "phone-2", format: "googlePhone", slot: 2 },
|
|
38
|
+
{ id: undefined, format: "googlePhone", slot: 3 },
|
|
39
|
+
],
|
|
40
|
+
);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("lets feature graphics span the responsive result grid", () => {
|
|
44
|
+
assert.equal(gridClassForAssetFormat("featureGraphic"), "sm:col-span-2 xl:col-span-3");
|
|
45
|
+
assert.equal(gridClassForAssetFormat("iphone"), "");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("centers a sole result at an inspectable width without creating implicit grid columns", () => {
|
|
49
|
+
assert.equal(gridClassForAssetFormat("featureGraphic", true), "mx-auto max-w-[340px]");
|
|
50
|
+
assert.equal(gridClassForAssetFormat("iphone", true), "mx-auto max-w-[340px]");
|
|
51
|
+
});
|
|
52
|
+
});
|
|
@@ -54,8 +54,9 @@ export function sortGeneratedAssets(assets: AppStoreScreenshotAsset[]) {
|
|
|
54
54
|
});
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
-
export function
|
|
58
|
-
|
|
57
|
+
export function gridClassForAssetFormat(format: AssetFormat, isSoleResult = false) {
|
|
58
|
+
if (isSoleResult) return "mx-auto max-w-[340px]";
|
|
59
|
+
return format === "featureGraphic" ? "sm:col-span-2 xl:col-span-3" : "";
|
|
59
60
|
}
|
|
60
61
|
|
|
61
62
|
export function dimensionsForAssetFormat(format: AssetFormat) {
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
|
|
4
|
+
import type {
|
|
5
|
+
AppStoreScreenshotAsset,
|
|
6
|
+
AppStoreScreenshotGeneratorInput,
|
|
7
|
+
} from "../../../../lib/tools/app-store-screenshot-generator-schema";
|
|
8
|
+
|
|
9
|
+
import { createGeneratedAssetZipEntries } from "./input-media";
|
|
10
|
+
|
|
11
|
+
const input = {
|
|
12
|
+
source: "upload",
|
|
13
|
+
productUrl: "",
|
|
14
|
+
hasScreenshot: true,
|
|
15
|
+
screenshotName: "source.png",
|
|
16
|
+
screenshotDataUrl: "",
|
|
17
|
+
screenshotReferences: [],
|
|
18
|
+
logoName: "",
|
|
19
|
+
logoDataUrl: "",
|
|
20
|
+
productName: "Tomako",
|
|
21
|
+
productBenefits: "",
|
|
22
|
+
assetCount: 1,
|
|
23
|
+
outputLanguage: "en",
|
|
24
|
+
platform: "apple",
|
|
25
|
+
style: "clean",
|
|
26
|
+
palette: "auto",
|
|
27
|
+
layout: "mixed",
|
|
28
|
+
headline: "",
|
|
29
|
+
subheadline: "",
|
|
30
|
+
} satisfies AppStoreScreenshotGeneratorInput;
|
|
31
|
+
|
|
32
|
+
describe("app store screenshot ZIP entries", () => {
|
|
33
|
+
it("includes the PNG and existing SVG wrapper for each generated asset", async () => {
|
|
34
|
+
const svg = `<svg xmlns="http://www.w3.org/2000/svg"><metadata>${"prompt ".repeat(20)}</metadata></svg>`;
|
|
35
|
+
const asset = {
|
|
36
|
+
id: "hero",
|
|
37
|
+
platform: "apple",
|
|
38
|
+
format: "iphone",
|
|
39
|
+
title: "Core benefit",
|
|
40
|
+
width: 1290,
|
|
41
|
+
height: 2796,
|
|
42
|
+
imageUrl: "https://example.com/hero.png",
|
|
43
|
+
svg,
|
|
44
|
+
} satisfies AppStoreScreenshotAsset;
|
|
45
|
+
|
|
46
|
+
const files = await createGeneratedAssetZipEntries(
|
|
47
|
+
[asset],
|
|
48
|
+
input,
|
|
49
|
+
async () => new Blob([new Uint8Array([137, 80, 78, 71])], { type: "image/png" }),
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
assert.deepEqual(
|
|
53
|
+
files.map((file) => file.path),
|
|
54
|
+
[
|
|
55
|
+
"01-tomako-apple-iphone-core-benefit.png",
|
|
56
|
+
"01-tomako-apple-iphone-core-benefit.svg",
|
|
57
|
+
],
|
|
58
|
+
);
|
|
59
|
+
assert.deepEqual([...files[0]!.data], [137, 80, 78, 71]);
|
|
60
|
+
assert.equal(new TextDecoder().decode(files[1]!.data), svg);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("does not advertise a missing SVG by adding an empty file", async () => {
|
|
64
|
+
const asset = {
|
|
65
|
+
id: "hero",
|
|
66
|
+
platform: "apple",
|
|
67
|
+
format: "iphone",
|
|
68
|
+
width: 1290,
|
|
69
|
+
height: 2796,
|
|
70
|
+
imageUrl: "https://example.com/hero.png",
|
|
71
|
+
} satisfies AppStoreScreenshotAsset;
|
|
72
|
+
|
|
73
|
+
const files = await createGeneratedAssetZipEntries(
|
|
74
|
+
[asset],
|
|
75
|
+
input,
|
|
76
|
+
async () => new Blob([new Uint8Array([1])], { type: "image/png" }),
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
assert.deepEqual(files.map((file) => file.path), ["01-tomako-apple-iphone-hero.png"]);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
@@ -5,7 +5,7 @@ import type {
|
|
|
5
5
|
|
|
6
6
|
import { renderAssetToPngBlob } from "./generation";
|
|
7
7
|
import type { SourceKey } from "./types";
|
|
8
|
-
import { createZipBlob, slugForFileName, type ZipFileInput } from "./zip";
|
|
8
|
+
import { createZipBlob, encodeText, slugForFileName, type ZipFileInput } from "./zip";
|
|
9
9
|
|
|
10
10
|
export function inferSource(input: AppStoreScreenshotGeneratorInput): SourceKey {
|
|
11
11
|
const hasUrl = input.productUrl.trim().length > 0;
|
|
@@ -137,22 +137,34 @@ export async function createGeneratedAssetsZip(
|
|
|
137
137
|
assets: readonly AppStoreScreenshotAsset[],
|
|
138
138
|
input: AppStoreScreenshotGeneratorInput,
|
|
139
139
|
): Promise<Blob> {
|
|
140
|
+
return createZipBlob(await createGeneratedAssetZipEntries(assets, input));
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export async function createGeneratedAssetZipEntries(
|
|
144
|
+
assets: readonly AppStoreScreenshotAsset[],
|
|
145
|
+
input: AppStoreScreenshotGeneratorInput,
|
|
146
|
+
renderPng: (asset: AppStoreScreenshotAsset) => Promise<Blob> = renderAssetToPngBlob,
|
|
147
|
+
): Promise<ZipFileInput[]> {
|
|
140
148
|
const files: ZipFileInput[] = [];
|
|
141
149
|
const usedPaths = new Set<string>();
|
|
142
150
|
|
|
143
151
|
for (const [index, asset] of assets.entries()) {
|
|
144
|
-
const
|
|
145
|
-
const
|
|
146
|
-
usedPaths,
|
|
147
|
-
`${String(index + 1).padStart(2, "0")}-${assetFileBase(input, asset)}-${slugForFileName(asset.title ?? asset.id)}.png`,
|
|
148
|
-
);
|
|
152
|
+
const fileStem = `${String(index + 1).padStart(2, "0")}-${assetFileBase(input, asset)}-${slugForFileName(asset.title ?? asset.id)}`;
|
|
153
|
+
const blob = await renderPng(asset);
|
|
149
154
|
files.push({
|
|
150
|
-
path,
|
|
155
|
+
path: uniqueZipPath(usedPaths, `${fileStem}.png`),
|
|
151
156
|
data: new Uint8Array(await blob.arrayBuffer()),
|
|
152
157
|
});
|
|
158
|
+
|
|
159
|
+
if (asset.svg) {
|
|
160
|
+
files.push({
|
|
161
|
+
path: uniqueZipPath(usedPaths, `${fileStem}.svg`),
|
|
162
|
+
data: encodeText(asset.svg),
|
|
163
|
+
});
|
|
164
|
+
}
|
|
153
165
|
}
|
|
154
166
|
|
|
155
|
-
return
|
|
167
|
+
return files;
|
|
156
168
|
}
|
|
157
169
|
|
|
158
170
|
export function uniqueZipPath(usedPaths: Set<string>, path: string) {
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
|
|
4
|
+
import { staticAsset } from "./static-asset.ts";
|
|
5
|
+
|
|
6
|
+
function withStaticOrigin(value: string | undefined, run: () => void) {
|
|
7
|
+
const previous = process.env.NEXT_PUBLIC_STATIC_ORIGIN;
|
|
8
|
+
if (value === undefined) delete process.env.NEXT_PUBLIC_STATIC_ORIGIN;
|
|
9
|
+
else process.env.NEXT_PUBLIC_STATIC_ORIGIN = value;
|
|
10
|
+
try {
|
|
11
|
+
run();
|
|
12
|
+
} finally {
|
|
13
|
+
if (previous === undefined) delete process.env.NEXT_PUBLIC_STATIC_ORIGIN;
|
|
14
|
+
else process.env.NEXT_PUBLIC_STATIC_ORIGIN = previous;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe("staticAsset", () => {
|
|
19
|
+
it("keeps public assets same-origin until a CDN origin is explicitly configured", () => {
|
|
20
|
+
withStaticOrigin(undefined, () => {
|
|
21
|
+
assert.equal(
|
|
22
|
+
staticAsset("/tools/mrr-calculator-section-primary-8bit.webp"),
|
|
23
|
+
"/tools/mrr-calculator-section-primary-8bit.webp",
|
|
24
|
+
);
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("uses the configured CDN origin when it is available", () => {
|
|
29
|
+
withStaticOrigin("https://static.tomako.ai/", () => {
|
|
30
|
+
assert.equal(
|
|
31
|
+
staticAsset("/tools/mrr-calculator-section-primary-8bit.webp"),
|
|
32
|
+
"https://static.tomako.ai/static/tools/mrr-calculator-section-primary-8bit.webp",
|
|
33
|
+
);
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
});
|
package/src/lib/static-asset.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Resolve root-relative marketing asset paths to
|
|
2
|
+
* Resolve root-relative marketing asset paths to an explicitly configured static CDN.
|
|
3
3
|
* Mirrors `@tomako/public-chrome` staticAsset so tools-runtime stays free of
|
|
4
4
|
* a chrome peer dependency.
|
|
5
5
|
*
|
|
@@ -10,8 +10,6 @@
|
|
|
10
10
|
const STYLE_SUFFIX_PATTERN = /!(default|256|640|960|1600|2560)$/;
|
|
11
11
|
const RASTER_EXT = /\.(png|jpe?g|webp|gif|bmp|tiff?)$/i;
|
|
12
12
|
|
|
13
|
-
export const DEFAULT_STATIC_ORIGIN = "https://static.tomako.ai";
|
|
14
|
-
|
|
15
13
|
export type OssImageStyle =
|
|
16
14
|
| "default"
|
|
17
15
|
| "256"
|
|
@@ -20,13 +18,9 @@ export type OssImageStyle =
|
|
|
20
18
|
| "1600"
|
|
21
19
|
| "2560";
|
|
22
20
|
|
|
23
|
-
function readStaticOrigin(): string {
|
|
24
|
-
const
|
|
25
|
-
|
|
26
|
-
const value = raw.trim().replace(/\/$/, "");
|
|
27
|
-
if (value.length > 0) return value;
|
|
28
|
-
}
|
|
29
|
-
return DEFAULT_STATIC_ORIGIN;
|
|
21
|
+
function readStaticOrigin(): string | undefined {
|
|
22
|
+
const value = process.env.NEXT_PUBLIC_STATIC_ORIGIN?.trim().replace(/\/$/, "");
|
|
23
|
+
return value || undefined;
|
|
30
24
|
}
|
|
31
25
|
|
|
32
26
|
export function staticAsset(path: string, style?: OssImageStyle): string {
|
|
@@ -39,6 +33,9 @@ export function staticAsset(path: string, style?: OssImageStyle): string {
|
|
|
39
33
|
|
|
40
34
|
const normalized = path.startsWith("/") ? path : `/${path}`;
|
|
41
35
|
const origin = readStaticOrigin();
|
|
36
|
+
// A deployed page always has the corresponding public/ asset. CDN is an
|
|
37
|
+
// optional performance layer, never the only way to render a tool visual.
|
|
38
|
+
if (!origin) return normalized;
|
|
42
39
|
const absolute = `${origin}/static${normalized}`;
|
|
43
40
|
if (!style || !RASTER_EXT.test(normalized.replace(STYLE_SUFFIX_PATTERN, ""))) {
|
|
44
41
|
return absolute;
|