ooxml.js 1.0.0 → 1.2.0

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/README.md CHANGED
@@ -1,5 +1,7 @@
1
1
  # ooxml.js
2
2
 
3
+ [![GitHub](https://img.shields.io/badge/GitHub-181717?logo=github&logoColor=white)](https://github.com/ExaDev/ooxml.js) [![npm](https://img.shields.io/npm/v/ooxml.js?logo=npm)](https://www.npmjs.com/package/ooxml.js) [![CI](https://img.shields.io/github/actions/workflow/status/ExaDev/ooxml.js/ci.yml?branch=main)](https://github.com/ExaDev/ooxml.js/actions)
4
+
3
5
  > Type-safe, lossless round-trip conversion between OOXML packages (`.docx`, `.pptx`, `.xlsx`) and a faithful JSON model, built on [Zod 4](https://zod.dev) codecs.
4
6
 
5
7
  An OOXML file is a ZIP archive of parts (an OPC "package"): `[Content_Types].xml`, relationships (`*.rels`), XML content parts, and binary parts (images, embedded objects). `ooxml.js` decodes the **whole package** into a faithful JSON model and encodes it back — part for part — so `encode(decode(file))` reproduces the original content.
@@ -140,13 +142,14 @@ const out = encodeCompactPackage(compact); // CompactPackage -> OOXML bytes dire
140
142
 
141
143
  ```sh
142
144
  pnpm build # tsdown -> dist/ (ESM + CJS + .d.ts, via tsdown.config.ts)
145
+ pnpm lint # eslint . --max-warnings 0
143
146
  pnpm typecheck # tsc --noEmit
144
147
  pnpm test # vitest run
145
148
  pnpm test:watch # vitest
146
149
  pnpm test:smoke # builds dist/, then runs test/smoke.test.mjs to verify the built ESM and CJS artifacts both load and behave identically
147
150
  ```
148
151
 
149
- There is no separate lint script or ESLint config; `pnpm typecheck` and `vitest` are the enforced gates. `pnpm prepublishOnly` runs `typecheck`, `tsdown`, `publint`, and `@arethetypeswrong/cli` (`attw --pack`) — the full publish-readiness check — before a release.
152
+ `pnpm prepublishOnly` runs `lint`, `typecheck`, `tsdown`, `publint`, and `@arethetypeswrong/cli` (`attw --pack`) — the full publish-readiness check — before a release.
150
153
 
151
154
  `test/smoke.test.mjs` loads the actual built `dist/index.js` (ESM) and `dist/index.cjs` (CJS) artifacts and checks they load and behave identically — a check none of `vitest`'s normal run, `tsc`, `publint`, or `attw` can do, since those either run against source or statically analyse package metadata without executing the compiled output. `vitest.config.ts` defines it as its own `smoke` project (vitest's `test.projects`), separate from the `unit` project (`src/**/*.test.ts`); `pnpm test`/`test:watch` pass `--project unit` and `pnpm test:smoke` passes `--project smoke` after `tsdown` rebuilds `dist/`, so neither run touches the other project's files.
152
155
 
@@ -170,15 +173,15 @@ The package is layered from a lossless core outward to lossy convenience views:
170
173
  - **`XmlNode` uses a recursive structural guard, not `z.lazy`.** `z.lazy` collapses to `unknown` for the element-children case in the Zod version this project pins, so `XmlElementSchema` validates `children` via `z.custom<XmlNode>(isXmlNode)`, a hand-written recursive type guard in `model/node.ts`. Any change to `XmlNode`'s shape must update `isXmlNode` in step. `src/compact.ts`'s `CompactXmlNode` reuses the same pattern (`isCompactXmlNode` + `z.custom`) for the same reason.
171
174
  - **Lossless core vs. lossy views is a hard boundary.** `decodePackage`/`encodePackage` (and the underlying codecs) must stay byte/part faithful — every part round-trips unchanged. `src/typed/*` readers are explicitly one-way and are allowed to drop information (documented per-reader, e.g. `readDocx`'s bold/italic toggle presence check ignores `w:val`, and `readXlsx` drops cell styles, formats and charts). Don't blur this line by adding write-back support to a typed reader; a full round-trip always goes through the generic `Package`.
172
175
  - **XML entities stay raw in the lossless layer.** `parseXml` runs with `processEntities: false` so encoded entities (e.g. `&amp;`) are preserved verbatim for round-trip fidelity; typed readers decode the five standard entities (`decodeEntities` in `typed/util.ts`) only in their own lossy projection, never in the core model.
176
+ - **No type assertions.** `eslint.config.ts` runs `@typescript-eslint/consistent-type-assertions` with `assertionStyle: "never"`, banning `as` and angle-bracket casts outright, with `linterOptions.noInlineConfig: true` so there is no `eslint-disable` escape hatch either — narrow with a guard or parse with Zod. An exception would have to be scoped structurally, as a `files`-matched override block in `eslint.config.ts`, not an inline comment.
173
177
 
174
178
  ## Gotchas and quirks
175
179
 
176
- - **No git remote is configured yet.** `git remote -v` is empty; this repository has not been pushed anywhere. Confirm the intended origin before assuming a `git push` target.
177
180
  - **`test:smoke` depends on a fresh build.** It runs `tsdown && vitest run --project smoke`, so it always rebuilds `dist/` first — don't run it expecting to test a stale build.
178
181
  - **`--project` matters for `test/smoke.test.mjs`.** `vitest.config.ts` defines `unit` and `smoke` as separate projects; `pnpm test`/`test:watch`/`test:smoke` always pass the right `--project` flag. A bare `vitest`/`vitest run` with no `--project` filter runs both projects, and `smoke` fails loudly (`Cannot find module '../dist/index.js'`) if `dist/` hasn't been built yet — a clear failure pointing at the cause, not a silent false pass, but still worth knowing if you invoke `vitest` directly instead of through the npm scripts.
179
182
  - **Binary-vs-XML part classification is a byte sniff, not an extension check.** `package-io/read.ts`'s `looksLikeXml` looks for a leading `<` after skipping a UTF-8 BOM and whitespace; this is deliberate (no standard OOXML binary part starts with `<`) but means any future binary format starting with `<` would misclassify.
180
- - **`fflate`'s bundled types are ahead of what it actually allocates.** `zip.ts` casts `fflate`'s `Uint8Array<ArrayBufferLike>` results to `Uint8Array<ArrayBuffer>` with a comment explaining why the narrowing is safe (fflate only ever allocates a real `ArrayBuffer`) don't remove the cast without preserving that guarantee elsewhere.
181
- - **No CI workflow exists yet.** There is no `.github/workflows/` directory; `pnpm build`, `pnpm typecheck`, and `pnpm test` are run locally/manually, not gated by GitHub Actions.
183
+ - **`Array.isArray` narrows `unknown` to `any[]`, not `unknown[]`.** `lib.es5.d.ts` types its parameter as `any`, so TypeScript can't do better even after the check succeeds indexing straight into the result (e.g. `value[0]`) silently reintroduces `any` and trips `@typescript-eslint/no-unsafe-assignment`. `compact.ts` and `xml/parse.ts` each define a local `isUnknownArray` guard (`value is unknown[]`) for exactly this reason; reach for it instead of `Array.isArray` wherever the narrowed element is going to be read.
184
+ - **TypeScript is pinned to the latest 6.x, not 7.** TypeScript 7 restructured its JS-facing API surface heavily enough that both `typescript-eslint` (peer range `<6.1.0`) and `cosmiconfig`'s TypeScript loader (used by `semantic-release` to read `release.config.ts`, via `typescript.findConfigFile`, which TS 7 no longer exports) break under it. Upgrading past 6.x has to wait for that ecosystem tooling to add TS 7 support.
182
185
 
183
186
  ## Fidelity
184
187
 
@@ -186,9 +189,15 @@ Conversion is **part-content-faithful**: every XML part re-serialises to equival
186
189
 
187
190
  It is **not** guaranteed to be byte-for-byte identical at the ZIP-container level — re-zipping changes archive entry layout (entry order, compression, metadata), and that is not achievable deterministically across the tools that produce OOXML files.
188
191
 
192
+ ## Release and publishing
193
+
194
+ `.github/workflows/ci.yml` runs commitlint, lint, typecheck, the unit suite, and the smoke test on every push and pull request. On a push to `main` where those all pass, `release.config.ts` drives [semantic-release](https://semantic-release.gitbook.io/semantic-release): commit history since the last tag decides the version bump, `CHANGELOG.md` and `package.json` are committed back to `main`, a GitHub Release is cut, and the package publishes to [npmjs.org](https://www.npmjs.com/package/ooxml.js) — via npm's OIDC trusted publishing, so no `NPM_TOKEN` exists anywhere in the pipeline.
195
+
196
+ Whether that release actually published a new version is detected by diffing `package.json`'s version before and after the release step, not by trusting a third-party action's own detection. Two further jobs gate on that: one republishes the same build under the scoped `@exadev/ooxml.js` alias to GitHub Packages (which has no OIDC exchange of its own, so it authenticates with `GITHUB_TOKEN` instead), and one packs the release into its own directory, generates an SPDX SBOM (`pnpm sbom`), and signs both an SBOM and a build-provenance attestation against that exact tarball — verifiable independently of the registry, and still present if the package is later unpublished.
197
+
189
198
  ## Contributing
190
199
 
191
- Commits follow Conventional Commits (`feat:`, `fix:`, `test:`, `chore:`), evidenced by the existing git history; there is no `CONTRIBUTING.md` or enforced commit hook yet. There is a single `main` branch and no open pull request workflow established so far.
200
+ Commits follow Conventional Commits (`feat:`, `fix:`, `test:`, `chore:`, …), enforced by commitlint (`commitlint.config.ts`) via a husky `commit-msg` hook and a CI `commitlint` job semantic-release's version bump depends on these being well-formed, not just style. A husky `pre-commit` hook runs `lint-staged` (`eslint --fix` on staged `*.ts` files) and `pre-push` runs the test suite. There is a single `main` branch and no open pull request workflow established so far.
192
201
 
193
202
  ## License
194
203
 
package/dist/index.cjs CHANGED
@@ -32,15 +32,15 @@ function isRecord$1(value) {
32
32
  return typeof value === "object" && value !== null && !Array.isArray(value);
33
33
  }
34
34
  function isAttribute(value) {
35
- return isRecord$1(value) && typeof value["name"] === "string" && typeof value["value"] === "string";
35
+ return isRecord$1(value) && typeof value.name === "string" && typeof value.value === "string";
36
36
  }
37
37
  function isXmlNode(value) {
38
38
  if (!isRecord$1(value)) return false;
39
- const t = value["type"];
40
- if (t === "text" || t === "cdata" || t === "comment") return typeof value["value"] === "string";
41
- if (t === "declaration") return Array.isArray(value["attributes"]) && value["attributes"].every(isAttribute);
42
- if (t === "pi") return typeof value["target"] === "string" && typeof value["content"] === "string";
43
- if (t === "element") return typeof value["tag"] === "string" && Array.isArray(value["attributes"]) && value["attributes"].every(isAttribute) && Array.isArray(value["children"]) && value["children"].every(isXmlNode);
39
+ const t = value.type;
40
+ if (t === "text" || t === "cdata" || t === "comment") return typeof value.value === "string";
41
+ if (t === "declaration") return Array.isArray(value.attributes) && value.attributes.every(isAttribute);
42
+ if (t === "pi") return typeof value.target === "string" && typeof value.content === "string";
43
+ if (t === "element") return typeof value.tag === "string" && Array.isArray(value.attributes) && value.attributes.every(isAttribute) && Array.isArray(value.children) && value.children.every(isXmlNode);
44
44
  return false;
45
45
  }
46
46
  const XmlElementSchema = zod.z.object({
@@ -133,12 +133,15 @@ function parseXml(xml) {
133
133
  function isRecord(value) {
134
134
  return typeof value === "object" && value !== null && !Array.isArray(value);
135
135
  }
136
+ function isUnknownArray$1(value) {
137
+ return Array.isArray(value);
138
+ }
136
139
  function asString(value) {
137
140
  if (typeof value !== "string") throw new Error(`expected string while parsing XML, got ${typeof value}`);
138
141
  return value;
139
142
  }
140
143
  function parseNodes(raw) {
141
- if (!Array.isArray(raw)) throw new Error("fast-xml-parser output was not an ordered array");
144
+ if (!isUnknownArray$1(raw)) throw new Error("fast-xml-parser output was not an ordered array");
142
145
  return raw.map(parseNode);
143
146
  }
144
147
  function parseNode(raw) {
@@ -156,11 +159,11 @@ function parseNode(raw) {
156
159
  };
157
160
  if (tagKey === "__comment") return {
158
161
  type: "comment",
159
- value: scalarText(raw["__comment"])
162
+ value: scalarText(raw.__comment)
160
163
  };
161
164
  if (tagKey === "__cdata") return {
162
165
  type: "cdata",
163
- value: scalarText(raw["__cdata"])
166
+ value: scalarText(raw.__cdata)
164
167
  };
165
168
  if (tagKey === "?xml") return {
166
169
  type: "declaration",
@@ -192,7 +195,7 @@ function parseAttributes(raw) {
192
195
  return attrs;
193
196
  }
194
197
  function scalarText(raw) {
195
- if (!Array.isArray(raw) || raw.length === 0) throw new Error("expected a scalar-text wrapper array");
198
+ if (!isUnknownArray$1(raw) || raw.length === 0) throw new Error("expected a scalar-text wrapper array");
196
199
  const first = raw[0];
197
200
  if (!isRecord(first)) throw new Error("scalar-text wrapper was not an object");
198
201
  return asString(first["#text"]);
@@ -308,11 +311,14 @@ function encodePackage(pkg) {
308
311
  }
309
312
  //#endregion
310
313
  //#region src/compact.ts
314
+ function isUnknownArray(value) {
315
+ return Array.isArray(value);
316
+ }
311
317
  function isCompactAttrPairs(value) {
312
- return Array.isArray(value) && value.every((v) => typeof v === "number");
318
+ return isUnknownArray(value) && value.every((v) => typeof v === "number");
313
319
  }
314
320
  function isCompactXmlNode(value) {
315
- if (!Array.isArray(value)) return false;
321
+ if (!isUnknownArray(value)) return false;
316
322
  const code = value[0];
317
323
  if (code === 1 || code === 2 || code === 3) return value.length === 2 && typeof value[1] === "number";
318
324
  if (code === 4) return value.length === 2 && isCompactAttrPairs(value[1]);
@@ -473,7 +479,7 @@ function attr(element, name) {
473
479
  for (const a of element.attributes) if (a.name === name) return a.value;
474
480
  }
475
481
  function rootElement(part) {
476
- if (part === void 0 || part.kind !== "xml") return;
482
+ if (part?.kind !== "xml") return;
477
483
  for (const node of part.nodes) if (node.type === "element") return node;
478
484
  }
479
485
  function decodeEntities(value) {
@@ -647,7 +653,7 @@ function readHeaderFooterText(pkg, prefix) {
647
653
  for (const path of Object.keys(pkg.parts)) {
648
654
  if (!path.startsWith(prefix) || !path.endsWith(".xml")) continue;
649
655
  const part = pkg.parts[path];
650
- if (part === void 0 || part.kind !== "xml") continue;
656
+ if (part?.kind !== "xml") continue;
651
657
  out.push(elementsWithTag(part.nodes, "w:t").map(textContent).join(""));
652
658
  }
653
659
  return out;
@@ -708,7 +714,7 @@ function readNotes(pkg, slidePath) {
708
714
  }
709
715
  if (notesPath === void 0) return "";
710
716
  const part = pkg.parts[notesPath];
711
- if (part === void 0 || part.kind !== "xml") return "";
717
+ if (part?.kind !== "xml") return "";
712
718
  return elementsWithTag(part.nodes, "a:t").map(textContent).join("");
713
719
  }
714
720
  function readPptx(pkg) {
package/dist/index.js CHANGED
@@ -31,15 +31,15 @@ function isRecord$1(value) {
31
31
  return typeof value === "object" && value !== null && !Array.isArray(value);
32
32
  }
33
33
  function isAttribute(value) {
34
- return isRecord$1(value) && typeof value["name"] === "string" && typeof value["value"] === "string";
34
+ return isRecord$1(value) && typeof value.name === "string" && typeof value.value === "string";
35
35
  }
36
36
  function isXmlNode(value) {
37
37
  if (!isRecord$1(value)) return false;
38
- const t = value["type"];
39
- if (t === "text" || t === "cdata" || t === "comment") return typeof value["value"] === "string";
40
- if (t === "declaration") return Array.isArray(value["attributes"]) && value["attributes"].every(isAttribute);
41
- if (t === "pi") return typeof value["target"] === "string" && typeof value["content"] === "string";
42
- if (t === "element") return typeof value["tag"] === "string" && Array.isArray(value["attributes"]) && value["attributes"].every(isAttribute) && Array.isArray(value["children"]) && value["children"].every(isXmlNode);
38
+ const t = value.type;
39
+ if (t === "text" || t === "cdata" || t === "comment") return typeof value.value === "string";
40
+ if (t === "declaration") return Array.isArray(value.attributes) && value.attributes.every(isAttribute);
41
+ if (t === "pi") return typeof value.target === "string" && typeof value.content === "string";
42
+ if (t === "element") return typeof value.tag === "string" && Array.isArray(value.attributes) && value.attributes.every(isAttribute) && Array.isArray(value.children) && value.children.every(isXmlNode);
43
43
  return false;
44
44
  }
45
45
  const XmlElementSchema = z.object({
@@ -132,12 +132,15 @@ function parseXml(xml) {
132
132
  function isRecord(value) {
133
133
  return typeof value === "object" && value !== null && !Array.isArray(value);
134
134
  }
135
+ function isUnknownArray$1(value) {
136
+ return Array.isArray(value);
137
+ }
135
138
  function asString(value) {
136
139
  if (typeof value !== "string") throw new Error(`expected string while parsing XML, got ${typeof value}`);
137
140
  return value;
138
141
  }
139
142
  function parseNodes(raw) {
140
- if (!Array.isArray(raw)) throw new Error("fast-xml-parser output was not an ordered array");
143
+ if (!isUnknownArray$1(raw)) throw new Error("fast-xml-parser output was not an ordered array");
141
144
  return raw.map(parseNode);
142
145
  }
143
146
  function parseNode(raw) {
@@ -155,11 +158,11 @@ function parseNode(raw) {
155
158
  };
156
159
  if (tagKey === "__comment") return {
157
160
  type: "comment",
158
- value: scalarText(raw["__comment"])
161
+ value: scalarText(raw.__comment)
159
162
  };
160
163
  if (tagKey === "__cdata") return {
161
164
  type: "cdata",
162
- value: scalarText(raw["__cdata"])
165
+ value: scalarText(raw.__cdata)
163
166
  };
164
167
  if (tagKey === "?xml") return {
165
168
  type: "declaration",
@@ -191,7 +194,7 @@ function parseAttributes(raw) {
191
194
  return attrs;
192
195
  }
193
196
  function scalarText(raw) {
194
- if (!Array.isArray(raw) || raw.length === 0) throw new Error("expected a scalar-text wrapper array");
197
+ if (!isUnknownArray$1(raw) || raw.length === 0) throw new Error("expected a scalar-text wrapper array");
195
198
  const first = raw[0];
196
199
  if (!isRecord(first)) throw new Error("scalar-text wrapper was not an object");
197
200
  return asString(first["#text"]);
@@ -307,11 +310,14 @@ function encodePackage(pkg) {
307
310
  }
308
311
  //#endregion
309
312
  //#region src/compact.ts
313
+ function isUnknownArray(value) {
314
+ return Array.isArray(value);
315
+ }
310
316
  function isCompactAttrPairs(value) {
311
- return Array.isArray(value) && value.every((v) => typeof v === "number");
317
+ return isUnknownArray(value) && value.every((v) => typeof v === "number");
312
318
  }
313
319
  function isCompactXmlNode(value) {
314
- if (!Array.isArray(value)) return false;
320
+ if (!isUnknownArray(value)) return false;
315
321
  const code = value[0];
316
322
  if (code === 1 || code === 2 || code === 3) return value.length === 2 && typeof value[1] === "number";
317
323
  if (code === 4) return value.length === 2 && isCompactAttrPairs(value[1]);
@@ -472,7 +478,7 @@ function attr(element, name) {
472
478
  for (const a of element.attributes) if (a.name === name) return a.value;
473
479
  }
474
480
  function rootElement(part) {
475
- if (part === void 0 || part.kind !== "xml") return;
481
+ if (part?.kind !== "xml") return;
476
482
  for (const node of part.nodes) if (node.type === "element") return node;
477
483
  }
478
484
  function decodeEntities(value) {
@@ -646,7 +652,7 @@ function readHeaderFooterText(pkg, prefix) {
646
652
  for (const path of Object.keys(pkg.parts)) {
647
653
  if (!path.startsWith(prefix) || !path.endsWith(".xml")) continue;
648
654
  const part = pkg.parts[path];
649
- if (part === void 0 || part.kind !== "xml") continue;
655
+ if (part?.kind !== "xml") continue;
650
656
  out.push(elementsWithTag(part.nodes, "w:t").map(textContent).join(""));
651
657
  }
652
658
  return out;
@@ -707,7 +713,7 @@ function readNotes(pkg, slidePath) {
707
713
  }
708
714
  if (notesPath === void 0) return "";
709
715
  const part = pkg.parts[notesPath];
710
- if (part === void 0 || part.kind !== "xml") return "";
716
+ if (part?.kind !== "xml") return "";
711
717
  return elementsWithTag(part.nodes, "a:t").map(textContent).join("");
712
718
  }
713
719
  function readPptx(pkg) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ooxml.js",
3
- "version": "1.0.0",
3
+ "version": "1.2.0",
4
4
  "description": "Type-safe, lossless round-trip conversion between OOXML packages (docx, pptx, xlsx) and JSON, built on Zod 4 codecs.",
5
5
  "type": "module",
6
6
  "repository": {
@@ -38,7 +38,8 @@
38
38
  },
39
39
  "scripts": {
40
40
  "build": "tsdown",
41
- "prepublishOnly": "pnpm run typecheck && tsdown && publint && attw --pack",
41
+ "prepublishOnly": "pnpm run lint && pnpm run typecheck && tsdown && publint && attw --pack",
42
+ "lint": "eslint . --max-warnings 0",
42
43
  "typecheck": "tsc --noEmit",
43
44
  "test": "vitest run --project unit",
44
45
  "test:watch": "vitest --project unit",
@@ -67,14 +68,19 @@
67
68
  "@arethetypeswrong/cli": "^0.18.5",
68
69
  "@commitlint/cli": "^21.2.1",
69
70
  "@commitlint/config-conventional": "^21.2.0",
71
+ "@eslint/js": "^10.0.1",
70
72
  "@semantic-release/changelog": "^7.0.0",
71
73
  "@semantic-release/git": "^11.0.1",
72
74
  "@types/node": "^26.1.1",
75
+ "eslint": "^10.8.0",
76
+ "globals": "^17.8.0",
73
77
  "husky": "^9.1.7",
78
+ "lint-staged": "^17.2.0",
74
79
  "publint": "^0.3.21",
75
80
  "semantic-release": "^25.0.8",
76
81
  "tsdown": "^0.22.13",
77
- "typescript": "^7.0.2",
82
+ "typescript": "^6.0.3",
83
+ "typescript-eslint": "^8.65.0",
78
84
  "vitest": "^4.1.10"
79
85
  }
80
86
  }