UNPKG

@walletpass/pass-js

Version:

Apple Wallet Pass generating and pushing updates from Node.js

161 lines 6.59 kB
// SPDX-License-Identifier: AGPL-3.0-or-later // Copyright (C) 2017-2026 Konstantin Vyatkin <tino@vtkn.io> /** * Class to handle Apple pass localizations * * @see {@link @see https://apple.co/2M9LWVu} - String Resources * @see {@link https://developer.apple.com/library/archive/documentation/UserExperience/Conceptual/PassKit_PG/Creating.html#//apple_ref/doc/uid/TP40012195-CH4-SW54} */ import { createReadStream, promises as fs } from 'fs'; import { createInterface } from 'readline'; import * as path from 'path'; import { normalizeLocale } from './normalize-locale.js'; /** * Just as in C, some characters must be prefixed with a backslash before you * can include them in the string. These characters include double quotation * marks, the backslash character itself, and special control characters such * as linefeed (\n) and carriage returns (\r). * * Apple's spec uses literal LF ( ) for `\n` regardless of host OS — the * previous implementation substituted `os.EOL` (CRLF on Windows) and silently * produced different output across platforms, breaking cross-platform * reproducibility of pass bundles. * * @see {@link https://developer.apple.com/library/archive/documentation/Cocoa/Conceptual/LoadingResources/Strings/Strings.html#//apple_ref/doc/uid/10000051i-CH6-SW13} */ export function escapeString(str) { return str .replace(/["\\]/g, '\\$&') // quote and backslash .replace(/\r\n|\r|\n/g, '\\n'); // all line-ending styles → escaped LF } export function unescapeString(str) { // Use a sentinel to protect escaped backslashes from the \n rewrite. // Without this, a literal "\\n" (escaped backslash followed by 'n', e.g. // a Windows path segment) would incorrectly collapse to a newline. const SENTINEL = ''; return str .replace(/\\\\/g, SENTINEL) // escaped backslash → sentinel .replace(/\\n/g, '\n') // escaped newline → LF .replace(/\\"/g, '"') // escaped quote → quote .replace(new RegExp(SENTINEL, 'g'), '\\'); // sentinel → literal backslash } async function readStringsFromStream(stream) { const res = new Map(); let nextLineIsComment = false; stream.setEncoding('utf-16le'); const rl = createInterface(stream); for await (const line of rl) { // skip empty lines const l = line.trim(); if (!l) continue; // check if starts with '/*' and skip comments if (nextLineIsComment || l.startsWith('/*')) { nextLineIsComment = !l.endsWith('*/'); continue; } // check for first quote, assignment operator, and final semicolon const test = /^"(?<msgId>.+)"\s*=\s*"(?<msgStr>.+)"\s*;/.exec(l); if (!test) continue; const { msgId, msgStr } = test.groups; res.set(unescapeString(msgId), unescapeString(msgStr)); } return res; } /** * @see {@link https://github.com/justinklemm/i18n-strings-files/blob/dae303ed60d9d43dbe1a39bb66847be8a0d62c11/index.coffee#L100} * @param {string} filename - path to pass.strings file */ export async function readLprojStrings(filename) { return readStringsFromStream(createReadStream(filename, { encoding: 'utf16le' })); } /** * Converts given translations map into UTF-16 encoded buffer in .lproj format * * @param {Map.<string, string>} strings */ export function getLprojBuffer(strings) { /** * Just as in C, some characters must be prefixed with a backslash before you can include them in the string. * These characters include double quotation marks, the backslash character itself, * and special control characters such as linefeed (\n) and carriage returns (\r). * * @see {@link https://developer.apple.com/library/archive/documentation/Cocoa/Conceptual/LoadingResources/Strings/Strings.html#//apple_ref/doc/uid/10000051i-CH6-SW13} */ return Buffer.from('\ufeff' /* byte order mark - UTF16 LE */ + [...strings] .map(([key, value]) => `"${escapeString(key)}" = "${escapeString(value)}";`) .join('\n'), // macOs compatible output for Buffer, so no EOL 'utf16le'); } /** * Localizations class Map<lang, Map<key, translation>> */ export class Localizations extends Map { constructor(v) { // copy localizations if provided super(v instanceof Localizations ? [...v].map(([lang, map]) => [lang, new Map(map)]) : undefined); } /** * * @param {string} lang - ISO 3166 alpha-2 code for the language * @param {{ [k: string]?: string }} values */ add(lang, values) { const locale = normalizeLocale(lang); const map = this.get(locale) || new Map(); for (const [key, value] of Object.entries(values)) { map.set(key, value); } if (!this.has(lang)) this.set(locale, map); return this; } toArray() { return [...this].map(([lang, map]) => ({ path: `${lang}.lproj/pass.strings`, data: getLprojBuffer(map), })); } async addFile(language, filename) { this.set(normalizeLocale(language), await readLprojStrings(filename)); } async addFromStream(language, stream) { this.set(normalizeLocale(language), await readStringsFromStream(stream)); } async addFromBuffer(language, buffer) { const { Readable } = await import('node:stream'); await this.addFromStream(language, Readable.from(buffer)); } /** * Loads available localizations from given folder path * * @param {string} dirPath */ async load(dirPath) { const entries = await fs.readdir(dirPath, { withFileTypes: true }); const loaders = []; for (const entry of entries) { if (!entry.isDirectory()) continue; // check if it's a localization folder const test = /^(?<lang>[-A-Z_a-z]+)\.lproj$/.exec(entry.name); if (!test) continue; const { lang } = test.groups; const currentPath = path.join(dirPath, entry.name); const localizations = await fs.readdir(currentPath, { withFileTypes: true, }); // check if it has strings and load if (localizations.find(f => f.isFile() && f.name === 'pass.strings')) loaders.push(this.addFile(lang, path.join(currentPath, 'pass.strings'))); } await Promise.all(loaders); } } //# sourceMappingURL=localizations.js.map