storebound/cli/lib/project-files.ts

221 lines
12 KiB
TypeScript

// Offline reads of a project's own files for the desktop app (serve.ts): the app icon, listing completeness per
// language on both stores, and the local review reply drafts. Tolerant on purpose: a broken file becomes a problem
// line, never an exception, so one bad section does not hide the rest ("unknown, never false").
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
import { extname, isAbsolute, join, resolve } from 'node:path';
import { appleName, type ShipConfig, playCodes } from './config';
import { APPLE_LIMITS, len, PLAY_LIMITS } from './copy';
import type { DraftsResult, IconResult, ListingField, ListingLocale, ListingsResult, ListingText, RenderImage, ReplyDraft } from './desktop-protocol';
import { parseFrontMatter, stringifyFrontMatter } from './frontmatter';
import { checkReply, parseDraft } from './reviews';
const IMAGE = /\.(png|jpe?g|webp|svg)$/i;
export const ICON_MAX_BYTES = 1_500_000;
/** Icon candidates, most specific first: icons.composite, the rendered store icons, play.icon, the usual places. */
export function iconCandidates(config: Pick<ShipConfig, 'icons' | 'play'>, root: string): string[] {
const r = (p: string) => (isAbsolute(p) ? p : resolve(root, p));
const out: string[] = [];
if (config.icons?.composite) out.push(r(config.icons.composite));
if (config.icons?.out) out.push(r(join(config.icons.out, 'app-store-1024.png')), r(join(config.icons.out, 'play-512.png')));
if (config.play?.icon) out.push(r(config.play.icon));
for (const p of ['store/icons/app-store-1024.png', 'store/icons/play-512.png', 'store/icon.png', 'store/icon-1024.png', 'icon.png', 'assets/icon.png',
'resources/icon.png', 'assets/icon-only.png', 'public/icon-512.png', 'web/icon-512.png']) out.push(r(p));
return [...new Set(out)];
}
/** First candidate that exists and is an image file, or null. */
export function findIcon(config: Pick<ShipConfig, 'icons' | 'play'>, root: string): string | null {
for (const p of iconCandidates(config, root)) {
try { if (IMAGE.test(p) && statSync(p).isFile()) return p; } catch { /* next */ }
}
return null;
}
const MIME: Record<string, string> = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp', '.svg': 'image/svg+xml' };
/** The icon as a data: URL for the desktop window (its CSP allows data: images only). Too big or unreadable -> null. */
export function iconDataUrl(path: string | null): IconResult {
if (!path) return { path: null, dataUrl: null };
try {
const st = statSync(path);
if (!st.isFile() || st.size > ICON_MAX_BYTES) return { path, dataUrl: null };
const mime = MIME[extname(path).toLowerCase()];
if (!mime) return { path, dataUrl: null };
return { path, dataUrl: `data:${mime};base64,${readFileSync(path).toString('base64')}` };
} catch { return { path, dataUrl: null }; }
}
/** "### heading" sub-sections of a "## " section with their first fenced block (like lib/copy.ts, never throws). */
function blocks(section: string): { heading: string; text: string }[] {
const out: { heading: string; text: string }[] = [];
for (const part of section.split(/^### /m).slice(1)) {
const heading = part.split('\n')[0]!.trim();
const m = /^```[^\n]*\n([\s\S]*?)\n```/m.exec(part);
out.push({ heading, text: m ? m[1]!.trim() : '' });
}
return out;
}
function field(name: string, text: string | undefined, limit: number, required: boolean): ListingField {
const n = text ? len(text) : 0;
const state: ListingField['state'] = !n ? (required ? 'missing' : 'empty-optional') : n > limit ? 'over' : 'ok';
return { field: name, length: n, limit, required, state };
}
/** Play sections by Play language code: { title, short, full, whatsNew } texts. */
export function playSections(md: string): Map<string, Record<string, string | undefined>> {
const out = new Map<string, Record<string, string | undefined>>();
for (const sec of md.split(/^## /m).slice(1)) {
const m = /^<!--\s*play:\s*([^>]+?)\s*-->$/m.exec(sec);
if (!m) continue;
const bs = blocks(sec);
const find = (re: RegExp): string | undefined => bs.find(b => re.test(b.heading))?.text;
const f = { title: find(/\btitle\b/i), short: find(/\bshort\b/i), full: find(/\bfull\b/i), whatsNew: find(/what'?s new|release notes/i) };
for (const lang of m[1]!.split(',').map(s => s.trim()).filter(Boolean)) out.set(lang, f);
}
return out;
}
/** App Store sections by App Store Connect locale: field -> text. */
export function appleSections(md: string): Map<string, Record<string, string | undefined>> {
const out = new Map<string, Record<string, string | undefined>>();
for (const sec of md.split(/^## /m).slice(1)) {
const locale = sec.split(/[\s·]/)[0]!.trim();
if (!/^[a-z]{2,3}(-[A-Za-z]{2,4})?$/.test(locale)) continue;
const f: Record<string, string | undefined> = {};
for (const b of blocks(sec)) if (b.heading in APPLE_LIMITS) f[b.heading] = b.text;
out.set(locale, f);
}
return out;
}
/** Store images of one language: the images in its size folders (<lang>/<size>/...). Files right in the language
* folder (a contact sheet, a preview collage) are not store images and are not counted, like the gallery. */
export function countImages(dir: string, depth = 0): number {
let n = 0;
try {
for (const e of readdirSync(dir, { withFileTypes: true })) {
if (e.isDirectory()) n += countImages(join(dir, e.name), depth + 1);
else if (depth > 0 && /\.(png|jpe?g)$/i.test(e.name)) n++;
}
} catch { /* unreadable: 0 */ }
return n;
}
/** Listing completeness per config language, both stores, plus rendered screenshots per language. */
export function listingCompleteness(config: ShipConfig, root: string): ListingsResult {
const problems: string[] = [];
const read = (rel: string | undefined, fallback: string): { path: string | null; text: string | null } => {
const p = resolve(root, rel ?? fallback);
if (!existsSync(p)) { problems.push(`${rel ?? fallback}: file not found`); return { path: null, text: null }; }
try { return { path: p, text: readFileSync(p, 'utf8') }; } catch (e) { problems.push(`${rel ?? fallback}: ${(e as Error).message}`); return { path: p, text: null }; }
};
const playFile = config.play ? read(config.play.listing, 'store/listing.md') : { path: null, text: null };
const appleFile = config.apple ? read(config.apple.listing, 'store/appstore.md') : { path: null, text: null };
const play = playFile.text !== null ? playSections(playFile.text) : null;
const apple = appleFile.text !== null ? appleSections(appleFile.text) : null;
const rendersDir = resolve(root, config.renders ?? config.shots?.out ?? 'store/renders');
const hasRenders = existsSync(rendersDir);
const locales: ListingLocale[] = config.languages.map(l => {
let req = 0, good = 0;
const tally = (fs: ListingField[]) => { for (const f of fs) if (f.required) { req++; if (f.state === 'ok') good++; } return fs; };
const pc = config.play ? playCodes(l)[0] ?? null : null;
const ps = pc && play ? play.get(pc) : undefined;
const playPart = config.play ? {
code: pc,
fields: tally([
field('title', ps?.title ?? (ps ? config.app.name : undefined), PLAY_LIMITS.title, true),
field('short', ps?.short, PLAY_LIMITS.short, true),
field('full', ps?.full, PLAY_LIMITS.full, true),
field('whatsNew', ps?.whatsNew, PLAY_LIMITS.whatsNew, false),
]),
} : null;
const ac = config.apple ? l.apple ?? null : null;
const as = ac && apple ? apple.get(ac) : undefined;
const applePart = config.apple ? {
code: ac,
fields: tally([
field('name', as?.name ?? (as ? appleName(config) : undefined), APPLE_LIMITS.name, true),
field('subtitle', as?.subtitle, APPLE_LIMITS.subtitle, true),
field('promotionalText', as?.promotionalText, APPLE_LIMITS.promotionalText, true),
field('keywords', as?.keywords, APPLE_LIMITS.keywords, true),
field('description', as?.description, APPLE_LIMITS.description, true),
field('whatsNew', as?.whatsNew, APPLE_LIMITS.whatsNew, false),
]),
} : null;
const screenshots = hasRenders ? countImages(join(rendersDir, l.id)) : null;
return { lang: l.id, rtl: !!l.rtl, play: playPart, apple: applePart, screenshots, complete: req ? good / req : 0 };
});
return { files: { play: playFile.path, apple: appleFile.path }, problems, locales };
}
/** The listing files as they are (the desktop export), or null where the config has no store / the file is missing. */
export function listingText(config: ShipConfig, root: string): ListingText {
const one = (rel: string): { path: string; text: string } | null => {
const p = resolve(root, rel);
try { return { path: p, text: readFileSync(p, 'utf8') }; } catch { return null; }
};
return { play: config.play ? one(config.play.listing ?? 'store/listing.md') : null, apple: config.apple ? one(config.apple.listing ?? 'store/appstore.md') : null };
}
export const RENDER_MAX_BYTES = 12_000_000;
/** A render path as the gallery data names it: relative, no "..", an image. */
export const RENDER_FILE_RE = /^(?!.*(^|\/)\.\.?(\/|$))(?!\/)[A-Za-z0-9._ @+()/-]{1,300}\.(png|jpe?g|webp)$/i;
/** One render (a path relative to the renders folder) as a data: URL for the desktop gallery; null when it is not
* an image inside the folder or is bigger than RENDER_MAX_BYTES. */
export function renderImage(rendersDir: string, file: string): RenderImage {
if (!RENDER_FILE_RE.test(file)) return { file, dataUrl: null, bytes: 0 };
const base = resolve(rendersDir), p = resolve(base, file);
if (!p.startsWith(base + '/') && !p.startsWith(base + '\\')) return { file, dataUrl: null, bytes: 0 };
try {
const st = statSync(p);
const mime = MIME[extname(p).toLowerCase()];
if (!st.isFile() || !mime || st.size > RENDER_MAX_BYTES) return { file, dataUrl: null, bytes: st.size };
return { file, dataUrl: `data:${mime};base64,${readFileSync(p).toString('base64')}`, bytes: st.size };
} catch { return { file, dataUrl: null, bytes: 0 }; }
}
/** Local reply drafts with their send checks. Unparsable files become problems. */
export function readReplyDrafts(dir: string): DraftsResult {
const out: DraftsResult = { dir, drafts: [], problems: [] };
if (!existsSync(dir)) return out;
for (const name of readdirSync(dir).filter(n => n.endsWith('.md')).sort()) {
const file = join(dir, name);
try {
const text = readFileSync(file, 'utf8');
const d = parseDraft(file, text);
const { data, body } = parseFrontMatter(text);
const quote = body.split(/^## Reply\s*$/m)[0]!.split('\n').filter(l => l.startsWith('>')).map(l => l.replace(/^>\s?/, '')).join('\n').trim();
const num = (v: unknown): number | null => (typeof v === 'number' ? v : typeof v === 'string' && /^\d+$/.test(v) ? Number(v) : null);
const str = (v: unknown): string | null => (typeof v === 'string' && v ? v : null);
const draft: ReplyDraft = {
file, store: d.store, reviewId: d.reviewId, approved: d.approved, sent: d.sent, reply: d.reply,
rating: num(data.rating), title: str(data.title) ?? (/^# [★☆]*\s*(.+)$/m.exec(body)?.[1]?.trim() || null), body: quote || null, date: str(data.date),
issues: d.reply ? checkReply(d.reply, d.store) : [],
};
out.drafts.push(draft);
} catch (e) { out.problems.push(`${name}: ${(e as Error).message}`); }
}
return out;
}
/** The "## Reply" section of a draft set to `reply` (everything after the heading is replaced); `approved` goes back
* to false, since an edited reply needs a fresh approval. Sent drafts are not edited. */
export function setReplyText(text: string, reply: string): string {
const { data, body } = parseFrontMatter(text);
if (data.store !== 'apple' && data.store !== 'play') throw new Error('not a review reply draft (front matter "store" missing)');
if (typeof data.sent === 'string' && data.sent) throw new Error('this reply was sent already: edit it in the store console');
const clean = reply.replace(/\r\n?/g, '\n').replace(/<!--[\s\S]*?-->/g, '').trim();
const at = body.search(/^## Reply\s*$/m);
const head = at < 0 ? `${body.trimEnd()}\n\n` : body.slice(0, at);
return stringifyFrontMatter({ ...data, approved: false }, `${head}## Reply\n\n${clean}\n`);
}
/** Front matter `approved: <bool>` set in a draft file text; the body unchanged. */
export function setApproved(text: string, approved: boolean): string {
const { data, body } = parseFrontMatter(text);
if (data.store !== 'apple' && data.store !== 'play') throw new Error('not a review reply draft (front matter "store" missing)');
return stringifyFrontMatter({ ...data, approved }, body);
}