Skip to content

Commit 3ac4284

Browse files
author
Lukas Geiger
committed
chore: add RSS-BOOK edge packager
1 parent 9fbd4d3 commit 3ac4284

6 files changed

Lines changed: 364 additions & 2 deletions

File tree

.gitignore

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,14 @@ desktop.ini
2424

2525
# Packages
2626
node_modules/
27+
npm-debug.log*
28+
yarn-debug.log*
29+
yarn-error.log*
30+
coverage/
31+
.nyc_output/
32+
.cache/
33+
*.log
34+
*.bak
2735
*.zip
2836
*.crx
2937
dist/

CHANGELOG.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,14 @@
66
- Added automated light/dark theme coverage for popup and options CSS variables.
77
- Added a read-only GitHub Actions CI workflow for the Node test suite.
88
- Added regression coverage for service-worker alarm scheduling.
9+
- Added a dependency-free Edge upload ZIP packager via `npm run package`.
910

1011
### Fixed
1112
- Fixed alarm updates for manual-only feeds when global interval is disabled but other feeds define per-feed intervals.
1213

1314
### Verified
14-
- `npm test` now covers 26 dependency-free Node tests, including theme and service-worker scheduling coverage.
15+
- `npm test` now covers 28 dependency-free Node tests, including theme, service-worker scheduling, and package-content coverage.
16+
- `npm run package` creates `dist/RSS-BOOK-v1.1.2-edge.zip` with the Manifest V3 runtime files plus license/privacy docs.
1517

1618
## [1.1.2] — 2026-04-30
1719

README.md

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,12 +59,20 @@ Store listing is planned after the remaining browser and screenshot checks.
5959

6060
## Development
6161

62-
RSS-BOOK has no build step. The repository includes 26 dependency-free Node tests for parser behavior, OPML, storage, bookmark cleanup, feed discovery, folder export, store assets, service-worker scheduling, and light/dark theme CSS coverage:
62+
RSS-BOOK has no bundling step. The repository includes 28 dependency-free Node tests for parser behavior, OPML, storage, bookmark cleanup, feed discovery, folder export, store assets, service-worker scheduling, light/dark theme CSS coverage, and Edge package contents:
6363

6464
```bash
6565
npm test
6666
```
6767

68+
Create the Edge upload ZIP with:
69+
70+
```bash
71+
npm run package
72+
```
73+
74+
The package is written to `dist/RSS-BOOK-v<manifest version>-edge.zip`.
75+
6876
GitHub Actions runs the same suite on pushes to `main` and pull requests.
6977

7078
## Permissions
@@ -94,6 +102,7 @@ RSS-BOOK/
94102
├── ui/
95103
│ ├── popup.html/js # Extension popup
96104
│ └── options.html/js # Settings page
105+
├── scripts/ # Release packaging helpers
97106
├── tests/ # Node test suite
98107
└── icons/ # Extension and store icons
99108
```

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
"type": "module",
66
"description": "Manifest V3 RSS/Atom to bookmarks browser extension.",
77
"scripts": {
8+
"package": "node scripts/package-extension.mjs",
89
"test": "node --test tests/*.test.mjs"
910
},
1011
"devDependencies": {}

scripts/package-extension.mjs

Lines changed: 254 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,254 @@
1+
import { promises as fs } from "node:fs";
2+
import path from "node:path";
3+
import { deflateRawSync } from "node:zlib";
4+
import { fileURLToPath, pathToFileURL } from "node:url";
5+
6+
const __filename = fileURLToPath(import.meta.url);
7+
const __dirname = path.dirname(__filename);
8+
const projectRoot = path.resolve(__dirname, "..");
9+
10+
const REQUIRED_FILES = [
11+
"manifest.json",
12+
"sw.js",
13+
"LICENSE",
14+
"PRIVACY_POLICY.md",
15+
"icons/16.png",
16+
"icons/48.png",
17+
"icons/128.png",
18+
];
19+
20+
const REQUIRED_DIRS = ["_locales", "lib", "ui"];
21+
22+
const CRC_TABLE = new Uint32Array(256);
23+
for (let i = 0; i < CRC_TABLE.length; i += 1) {
24+
let crc = i;
25+
for (let bit = 0; bit < 8; bit += 1) {
26+
crc = crc & 1 ? 0xedb88320 ^ (crc >>> 1) : crc >>> 1;
27+
}
28+
CRC_TABLE[i] = crc >>> 0;
29+
}
30+
31+
function toZipPath(relativePath) {
32+
return relativePath.split(path.sep).join("/");
33+
}
34+
35+
function crc32(buffer) {
36+
let crc = 0xffffffff;
37+
for (const byte of buffer) {
38+
crc = CRC_TABLE[(crc ^ byte) & 0xff] ^ (crc >>> 8);
39+
}
40+
return (crc ^ 0xffffffff) >>> 0;
41+
}
42+
43+
function dosDateTime(date) {
44+
const year = Math.min(Math.max(date.getFullYear(), 1980), 2107);
45+
return {
46+
date: ((year - 1980) << 9) | ((date.getMonth() + 1) << 5) | date.getDate(),
47+
time: (date.getHours() << 11) | (date.getMinutes() << 5) | Math.floor(date.getSeconds() / 2),
48+
};
49+
}
50+
51+
async function assertFile(rootDir, relativePath) {
52+
const absolutePath = path.join(rootDir, relativePath);
53+
const stat = await fs.stat(absolutePath);
54+
if (!stat.isFile()) {
55+
throw new Error(`Expected file in package: ${relativePath}`);
56+
}
57+
return {
58+
name: toZipPath(relativePath),
59+
absolutePath,
60+
mtime: stat.mtime,
61+
size: stat.size,
62+
};
63+
}
64+
65+
async function collectFiles(rootDir, relativeDir) {
66+
const absoluteDir = path.join(rootDir, relativeDir);
67+
const dirEntries = await fs.readdir(absoluteDir, { withFileTypes: true });
68+
const files = [];
69+
70+
for (const entry of dirEntries.sort((a, b) => a.name.localeCompare(b.name))) {
71+
const relativePath = path.join(relativeDir, entry.name);
72+
if (entry.isDirectory()) {
73+
files.push(...(await collectFiles(rootDir, relativePath)));
74+
} else if (entry.isFile()) {
75+
files.push(await assertFile(rootDir, relativePath));
76+
}
77+
}
78+
79+
return files;
80+
}
81+
82+
async function readJson(rootDir, relativePath) {
83+
const text = await fs.readFile(path.join(rootDir, relativePath), "utf8");
84+
return JSON.parse(text);
85+
}
86+
87+
function ensureUnique(entries) {
88+
const seen = new Set();
89+
for (const entry of entries) {
90+
if (seen.has(entry.name)) {
91+
throw new Error(`Duplicate package entry: ${entry.name}`);
92+
}
93+
seen.add(entry.name);
94+
}
95+
}
96+
97+
async function validatePackageEntries(rootDir, entries) {
98+
const manifest = await readJson(rootDir, "manifest.json");
99+
const packageJson = await readJson(rootDir, "package.json");
100+
const names = new Set(entries.map((entry) => entry.name));
101+
102+
if (manifest.version !== packageJson.version) {
103+
throw new Error(`Version mismatch: manifest ${manifest.version} != package ${packageJson.version}`);
104+
}
105+
106+
for (const iconPath of Object.values(manifest.icons || {})) {
107+
if (!names.has(iconPath)) {
108+
throw new Error(`Manifest icon is missing from package: ${iconPath}`);
109+
}
110+
}
111+
112+
for (const iconPath of Object.values(manifest.action?.default_icon || {})) {
113+
if (!names.has(iconPath)) {
114+
throw new Error(`Action icon is missing from package: ${iconPath}`);
115+
}
116+
}
117+
118+
if (manifest.default_locale && !names.has(`_locales/${manifest.default_locale}/messages.json`)) {
119+
throw new Error(`Default locale is missing from package: ${manifest.default_locale}`);
120+
}
121+
122+
return { manifest, packageJson };
123+
}
124+
125+
export async function collectPackageEntries(rootDir = projectRoot) {
126+
const entries = [];
127+
128+
for (const filePath of REQUIRED_FILES) {
129+
entries.push(await assertFile(rootDir, filePath));
130+
}
131+
132+
for (const dirPath of REQUIRED_DIRS) {
133+
entries.push(...(await collectFiles(rootDir, dirPath)));
134+
}
135+
136+
entries.sort((a, b) => a.name.localeCompare(b.name));
137+
ensureUnique(entries);
138+
await validatePackageEntries(rootDir, entries);
139+
return entries;
140+
}
141+
142+
async function createZip(entries) {
143+
const localParts = [];
144+
const centralParts = [];
145+
let offset = 0;
146+
147+
for (const entry of entries) {
148+
const fileBuffer = await fs.readFile(entry.absolutePath);
149+
const compressed = deflateRawSync(fileBuffer, { level: 9 });
150+
const useStored = compressed.length >= fileBuffer.length;
151+
const payload = useStored ? fileBuffer : compressed;
152+
const method = useStored ? 0 : 8;
153+
const nameBuffer = Buffer.from(entry.name, "utf8");
154+
const crc = crc32(fileBuffer);
155+
const { date, time } = dosDateTime(entry.mtime);
156+
const localOffset = offset;
157+
158+
const localHeader = Buffer.alloc(30);
159+
localHeader.writeUInt32LE(0x04034b50, 0);
160+
localHeader.writeUInt16LE(20, 4);
161+
localHeader.writeUInt16LE(0x0800, 6);
162+
localHeader.writeUInt16LE(method, 8);
163+
localHeader.writeUInt16LE(time, 10);
164+
localHeader.writeUInt16LE(date, 12);
165+
localHeader.writeUInt32LE(crc, 14);
166+
localHeader.writeUInt32LE(payload.length, 18);
167+
localHeader.writeUInt32LE(fileBuffer.length, 22);
168+
localHeader.writeUInt16LE(nameBuffer.length, 26);
169+
localHeader.writeUInt16LE(0, 28);
170+
171+
localParts.push(localHeader, nameBuffer, payload);
172+
offset += localHeader.length + nameBuffer.length + payload.length;
173+
174+
const centralHeader = Buffer.alloc(46);
175+
centralHeader.writeUInt32LE(0x02014b50, 0);
176+
centralHeader.writeUInt16LE(20, 4);
177+
centralHeader.writeUInt16LE(20, 6);
178+
centralHeader.writeUInt16LE(0x0800, 8);
179+
centralHeader.writeUInt16LE(method, 10);
180+
centralHeader.writeUInt16LE(time, 12);
181+
centralHeader.writeUInt16LE(date, 14);
182+
centralHeader.writeUInt32LE(crc, 16);
183+
centralHeader.writeUInt32LE(payload.length, 20);
184+
centralHeader.writeUInt32LE(fileBuffer.length, 24);
185+
centralHeader.writeUInt16LE(nameBuffer.length, 28);
186+
centralHeader.writeUInt16LE(0, 30);
187+
centralHeader.writeUInt16LE(0, 32);
188+
centralHeader.writeUInt16LE(0, 34);
189+
centralHeader.writeUInt16LE(0, 36);
190+
centralHeader.writeUInt32LE(0, 38);
191+
centralHeader.writeUInt32LE(localOffset, 42);
192+
193+
centralParts.push(centralHeader, nameBuffer);
194+
}
195+
196+
const centralOffset = offset;
197+
const centralDirectory = Buffer.concat(centralParts);
198+
const centralSize = centralDirectory.length;
199+
const entryCount = entries.length;
200+
201+
if (entryCount > 0xffff || centralOffset > 0xffffffff || centralSize > 0xffffffff) {
202+
throw new Error("ZIP64 is not supported by this lightweight packager.");
203+
}
204+
205+
const endRecord = Buffer.alloc(22);
206+
endRecord.writeUInt32LE(0x06054b50, 0);
207+
endRecord.writeUInt16LE(0, 4);
208+
endRecord.writeUInt16LE(0, 6);
209+
endRecord.writeUInt16LE(entryCount, 8);
210+
endRecord.writeUInt16LE(entryCount, 10);
211+
endRecord.writeUInt32LE(centralSize, 12);
212+
endRecord.writeUInt32LE(centralOffset, 16);
213+
endRecord.writeUInt16LE(0, 20);
214+
215+
return Buffer.concat([...localParts, centralDirectory, endRecord]);
216+
}
217+
218+
export async function buildExtensionPackage({
219+
rootDir = projectRoot,
220+
outputDir = path.join(rootDir, "dist"),
221+
outputFile,
222+
quiet = false,
223+
} = {}) {
224+
const entries = await collectPackageEntries(rootDir);
225+
const { manifest } = await validatePackageEntries(rootDir, entries);
226+
const archive = await createZip(entries);
227+
const fileName = outputFile || `RSS-BOOK-v${manifest.version}-edge.zip`;
228+
const outputPath = path.join(outputDir, fileName);
229+
230+
await fs.mkdir(outputDir, { recursive: true });
231+
await fs.writeFile(outputPath, archive);
232+
233+
const result = {
234+
outputPath,
235+
version: manifest.version,
236+
archiveSize: archive.length,
237+
entries: entries.map(({ name, size }) => ({ name, size })),
238+
};
239+
240+
if (!quiet) {
241+
console.log(`Created ${path.relative(rootDir, outputPath)}`);
242+
console.log(`${result.entries.length} files, ${result.archiveSize} bytes`);
243+
}
244+
245+
return result;
246+
}
247+
248+
const invokedPath = process.argv[1] ? pathToFileURL(path.resolve(process.argv[1])).href : "";
249+
if (invokedPath === import.meta.url) {
250+
buildExtensionPackage().catch((error) => {
251+
console.error(error instanceof Error ? error.message : error);
252+
process.exitCode = 1;
253+
});
254+
}

0 commit comments

Comments
 (0)