Skip to content

Commit 8450ff5

Browse files
committed
build(store): Microsoft Store listings in 32 languages
1 parent 48c5e04 commit 8450ff5

105 files changed

Lines changed: 968 additions & 2 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.gitattributes‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,3 +18,7 @@ src-tauri/Cargo.toml text eol=lf
1818

1919
# Generated from the command catalog; the check compares on LF.
2020
docs/zpl-coverage.md text eol=lf
21+
22+
# Store listing texts: Partner Center would import a CR from a CRLF checkout
23+
# as part of each feature and keyword.
24+
store/**/*.txt text eol=lf

‎package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
"attribution:gen": "node scripts/gen-attribution.mjs",
2020
"attribution:check": "node scripts/gen-attribution.mjs --check",
2121
"msix:build": "node scripts/build-msix.mjs",
22+
"store:listing": "node scripts/build-store-listing.mjs",
2223
"coverage:gen": "node scripts/gen-coverage.mjs",
2324
"coverage:check": "node scripts/gen-coverage.mjs --check",
2425
"preview": "vite preview",

‎scripts/build-store-listing.mjs‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
// Fills a Partner Center listing export with the texts in store/microsoft/ and
2+
// writes an import folder: listings.csv plus the screenshot it names. The
3+
// export is the template so field IDs and columns stay Partner Center's.
4+
import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
5+
import { basename, join, resolve } from 'node:path';
6+
import { fileURLToPath } from 'node:url';
7+
import { fillExport, listingsProblems, readListings } from './store-listing.mjs';
8+
9+
const SCREENSHOT = fileURLToPath(new URL('../docs/screenshot-light.png', import.meta.url));
10+
11+
const [exportPath, outDir] = process.argv.slice(2);
12+
if (!exportPath || !outDir) {
13+
console.error('usage: pnpm store:listing <partner-center-export.csv> <out-dir>');
14+
process.exit(1);
15+
}
16+
17+
const listings = readListings();
18+
const problems = listingsProblems(listings);
19+
if (problems.length) {
20+
console.error(problems.join('\n'));
21+
process.exit(1);
22+
}
23+
24+
const out = resolve(outDir);
25+
// Partner Center's folder import takes exactly one .csv.
26+
const strayCsv = existsSync(out) ? readdirSync(out).filter((f) => f.endsWith('.csv') && f !== 'listings.csv') : [];
27+
if (strayCsv.length) {
28+
console.error(`${out} already holds ${strayCsv.join(', ')}; use an empty folder`);
29+
process.exit(1);
30+
}
31+
// Import paths start with the imported folder's own name.
32+
const screenshot = `${basename(out)}/${basename(SCREENSHOT)}`;
33+
const csv = fillExport(readFileSync(exportPath, 'utf8'), listings, screenshot);
34+
35+
mkdirSync(out, { recursive: true });
36+
copyFileSync(SCREENSHOT, join(out, basename(SCREENSHOT)));
37+
writeFileSync(join(out, 'listings.csv'), csv);
38+
console.log(`Import the folder ${out} in Partner Center (Store listings > Import listings > Import folder).`);

‎scripts/msix.d.mts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,4 @@ export declare function storeVersion(version: string): string;
44
export declare function packageVersion(store: string): string;
55
export declare function stampVersion(manifest: string, version: string): string;
66
export declare function manifestExecutable(manifest: string): string | undefined;
7+
export declare function manifestLanguages(manifest: string): string[];

‎scripts/msix.mjs‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,3 +28,5 @@ export function stampVersion(manifest, version) {
2828
}
2929

3030
export const manifestExecutable = (manifest) => /<Application\b[^>]*\bExecutable="([^"]+)"/.exec(manifest)?.[1];
31+
32+
export const manifestLanguages = (manifest) => [...manifest.matchAll(/<Resource Language="([^"]+)"/g)].map((m) => m[1]);

‎scripts/store-listing.d.mts‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
// Types for the node-only listing helpers, so the test can import them under the app tsconfig.
2+
export interface Listing {
3+
tag: string;
4+
description: string;
5+
features: string[];
6+
keywords: string[];
7+
}
8+
export declare const SOURCE_TAG: 'en-US';
9+
export declare const LIMITS: Record<'description' | 'features' | 'featureChars' | 'keywords' | 'keywordChars' | 'keywordWords', number>;
10+
export declare function readListings(): Listing[];
11+
export declare function listingsProblems(listings: Listing[]): string[];
12+
export declare function parseCsv(text: string): string[][];
13+
export declare function fillExport(exportCsv: string, listings: Listing[], screenshotPath: string): string;

‎scripts/store-listing.mjs‎

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
// Microsoft Store listing helpers for build-store-listing.mjs, split out so
2+
// src/lib/storeListingScripts.test.ts can check the texts without an export.
3+
import { readdirSync, readFileSync } from 'node:fs';
4+
import { join } from 'node:path';
5+
import { fileURLToPath } from 'node:url';
6+
import Papa from 'papaparse';
7+
8+
const ROOT = fileURLToPath(new URL('..', import.meta.url));
9+
const LISTINGS = join(ROOT, 'store', 'microsoft');
10+
export const SOURCE_TAG = 'en-US';
11+
12+
export const LIMITS = { description: 10000, features: 20, featureChars: 200, keywords: 7, keywordChars: 40, keywordWords: 21 };
13+
14+
// CRLF from an editor would otherwise reach Partner Center as a trailing CR.
15+
const lf = (text) => text.replace(/\r\n?/g, '\n');
16+
const lines = (text) => lf(text).split('\n').map((line) => line.trim()).filter(Boolean);
17+
18+
export function readListings() {
19+
return readdirSync(LISTINGS, { withFileTypes: true })
20+
.filter((entry) => entry.isDirectory())
21+
.map(({ name: tag }) => {
22+
const read = (file) => readFileSync(join(LISTINGS, tag, file), 'utf8');
23+
return { tag, description: lf(read('description.txt')).trimEnd(), features: lines(read('features.txt')), keywords: lines(read('keywords.txt')) };
24+
});
25+
}
26+
27+
/** What Partner Center would reject, plus translations whose feature count drifted from the source. */
28+
export function listingsProblems(listings) {
29+
const source = listings.find((l) => l.tag === SOURCE_TAG);
30+
if (!source) return [`no ${SOURCE_TAG} source listing`];
31+
const problems = [];
32+
for (const listing of listings) {
33+
const check = (ok, message) => {
34+
if (!ok) problems.push(`${listing.tag}: ${message}`);
35+
};
36+
check(listing.description.length > 0, 'empty description');
37+
check(listing.description.length <= LIMITS.description, `description over ${LIMITS.description} chars`);
38+
check(listing.features.length === source.features.length, `${listing.features.length} features, ${SOURCE_TAG} has ${source.features.length}`);
39+
check(listing.features.length <= LIMITS.features, `more than ${LIMITS.features} features`);
40+
listing.features.forEach((f, i) => check(f.length <= LIMITS.featureChars, `feature ${i + 1} over ${LIMITS.featureChars} chars`));
41+
check(listing.keywords.length <= LIMITS.keywords, `more than ${LIMITS.keywords} keywords`);
42+
listing.keywords.forEach((k, i) => check(k.length <= LIMITS.keywordChars, `keyword ${i + 1} over ${LIMITS.keywordChars} chars`));
43+
check(listing.keywords.join(' ').split(/\s+/).length <= LIMITS.keywordWords, `keywords over ${LIMITS.keywordWords} words`);
44+
}
45+
return problems;
46+
}
47+
48+
export function parseCsv(text) {
49+
const { data, errors } = Papa.parse(text.replace(/^\uFEFF/, ''), { delimiter: ',', skipEmptyLines: true });
50+
if (errors.length) throw new Error(`malformed CSV: ${errors[0].message} (row ${errors[0].row})`);
51+
return data;
52+
}
53+
54+
/** Writes the listings into a Partner Center export. Every text slot of their
55+
* languages is rewritten, the default column included: Partner Center fills an empty
56+
* language cell from it, so a stale default would resurface everywhere. */
57+
export function fillExport(exportCsv, listings, screenshotPath) {
58+
const rows = parseCsv(exportCsv);
59+
const column = (tag) => rows[0].indexOf(tag.toLowerCase());
60+
const row = (field) => {
61+
const found = rows.find((r) => r[0] === field);
62+
if (!found) throw new Error(`the export has no ${field} row`);
63+
return found;
64+
};
65+
const slots = (prefix, count) => Array.from({ length: count }, (_, i) => row(`${prefix}${i + 1}`));
66+
67+
const source = listings.find((l) => l.tag === SOURCE_TAG);
68+
if (!source) throw new Error(`no ${SOURCE_TAG} listing to fill the default column from`);
69+
const targets = [['default', source], ...listings.map((l) => [l.tag, l])].map(([tag, listing]) => ({ tag, listing, col: column(tag) }));
70+
for (const { tag, col } of targets) if (col < 0) throw new Error(`the export has no ${tag} column; add the language in Partner Center first`);
71+
const description = row('Description');
72+
const features = slots('Feature', LIMITS.features);
73+
const keywords = slots('SearchTerm', LIMITS.keywords);
74+
75+
row('DesktopScreenshot1')[column('default')] = screenshotPath;
76+
for (const { listing, col } of targets) {
77+
description[col] = listing.description;
78+
features.forEach((r, i) => { r[col] = listing.features[i] ?? ''; });
79+
keywords.forEach((r, i) => { r[col] = listing.keywords[i] ?? ''; });
80+
}
81+
return `\uFEFF${Papa.unparse(rows, { newline: '\r\n' })}\r\n`;
82+
}

‎src/lib/msixScripts.test.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,14 @@ import en from "../locales/en";
44
import baseConfig from "../../src-tauri/tauri.conf.json";
55
import msixConfig from "../../src-tauri/tauri.msix.conf.json";
66
import { LOCALE_CODES, type LocaleCode } from "../locales";
7-
import { manifestExecutable, packageVersion, readManifest, stampVersion, storeVersion } from "../../scripts/msix.mjs";
7+
import { manifestExecutable, manifestLanguages, packageVersion, readManifest, stampVersion, storeVersion } from "../../scripts/msix.mjs";
88

99
// Locale codes whose Store language tag is spelled differently.
1010
const STORE_TAG: Partial<Record<LocaleCode, string>> = { en: "en-US", no: "nb", sr: "sr-Cyrl", "zh-hans": "zh-Hans", "zh-hant": "zh-Hant" };
1111

1212
describe("MSIX manifest", () => {
1313
it("declares every UI language, the default first", () => {
14-
const tags = [...readManifest().matchAll(/<Resource Language="([^"]+)"/g)].map((m) => m[1]);
14+
const tags = manifestLanguages(readManifest());
1515
expect(tags[0]).toBe("en-US");
1616
expect([...tags].sort()).toEqual(LOCALE_CODES.map((c) => STORE_TAG[c] ?? c).sort());
1717
});
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { describe, it, expect } from "vitest";
2+
import { LIMITS, fillExport, listingsProblems, parseCsv, readListings } from "../../scripts/store-listing.mjs";
3+
import { manifestLanguages, readManifest } from "../../scripts/msix.mjs";
4+
5+
describe("Microsoft Store listings", () => {
6+
const listings = readListings();
7+
8+
it("cover exactly the languages the package declares, spelled the same", () => {
9+
expect(listings.map((l) => l.tag).sort()).toEqual(manifestLanguages(readManifest()).sort());
10+
});
11+
12+
it("fit Partner Center's limits and match the source's feature count", () => {
13+
expect(listingsProblems(listings)).toEqual([]);
14+
});
15+
});
16+
17+
describe("fillExport", () => {
18+
const exportCsv = [
19+
"Field,ID,Type (Type),default,en-us",
20+
"Description,2,Text,old default,",
21+
"DesktopScreenshot1,100,Relative path,,",
22+
...Array.from({ length: LIMITS.features }, (_, i) => `Feature${i + 1},${700 + i},Text,${i === 13 ? "stale default" : ""},${i === 13 ? "stale" : ""}`),
23+
...Array.from({ length: LIMITS.keywords }, (_, i) => `SearchTerm${i + 1},${900 + i},Text,,`),
24+
].join("\r\n");
25+
const listing = { tag: "en-US", description: 'Line one, "quoted".\n\nLine two.', features: ["A", "B"], keywords: ["k"] };
26+
const rows = parseCsv(fillExport(exportCsv, [listing], "out/shot.png"));
27+
const cell = (field: string, col: number) => rows.find((r) => r[0] === field)?.[col];
28+
29+
it("round-trips multi-line, quoted text into the language column", () => {
30+
expect(cell("Description", 4)).toBe(listing.description);
31+
expect(cell("Feature2", 4)).toBe("B");
32+
expect(cell("SearchTerm1", 4)).toBe("k");
33+
});
34+
35+
it("rewrites the default column from the source, which empty language cells fall back to", () => {
36+
expect(cell("Description", 3)).toBe(listing.description);
37+
expect(cell("Feature14", 3)).toBe("");
38+
});
39+
40+
it("clears slots the listing does not fill", () => {
41+
expect(cell("Feature14", 4)).toBe("");
42+
});
43+
44+
it("puts the screenshot in the default column", () => {
45+
expect(cell("DesktopScreenshot1", 3)).toBe("out/shot.png");
46+
});
47+
48+
it("refuses an export without the listing's language column", () => {
49+
expect(() => fillExport(exportCsv, [listing, { ...listing, tag: "fr" }], "x.png")).toThrow(/fr column/);
50+
});
51+
});
52+
53+
describe("parseCsv", () => {
54+
it("reports a malformed export instead of a missing row", () => {
55+
expect(() => parseCsv('Field,ID\n"unterminated,1')).toThrow(/malformed CSV/);
56+
});
57+
});

‎store/microsoft/ar/description.txt‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
ZPLab برنامج مجاني ومفتوح المصدر لتصميم الملصقات وتحرير ZPL، مخصص للطابعات المتوافقة مع ZPL. يمكن طباعة الملصقات أو تصديرها بصيغة ZPL أو PDF أو صورة.
2+
3+
تعرض اللوحة الملصق مباشرةً. ويمكن الحصول على معاينة بديلة من طابعة على الشبكة، أو من خدمة Labelary الخارجية بعد الموافقة.
4+
5+
لا يلزم إنشاء حساب. ZPLab مرخّص بموجب رخصة MIT.

0 commit comments

Comments
 (0)