werkmap 0.6.0 → 0.8.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
@@ -9,7 +9,7 @@ Writing is the whole surface, so the package stays small enough to audit. The co
9
9
  - **Zero dependencies.** 7.7 kB minified and brotlied, for the whole writer.
10
10
  - **Strict CSP, including in the browser.** No `unsafe-eval` and no `unsafe-inline`. A Chromium page under `default-src 'none'; script-src 'self'` writes a workbook and reports any violation back, and the Node suite runs on `--disallow-code-generation-from-strings`.
11
11
  - **Write-only, deliberately.** No reader, no formula engine, no chart support — see [Is werkmap the right tool?](#is-werkmap-the-right-tool) before you install it.
12
- - **Hardened.** 72 tests at 100% branch coverage.
12
+ - **Hardened.** 85 tests at 100% branch coverage.
13
13
 
14
14
  ```js
15
15
  import { workbook } from "werkmap";
@@ -118,7 +118,17 @@ The finished package. This does not seal the document: call it as often as you l
118
118
 
119
119
  `cells` is an array; the returned number is the row's 1-based position, which `merge`, `freeze` and `place` take.
120
120
 
121
- `options.level` is the row's **outline level**, an integer from 0 to 7. A reader draws the levels as collapsible groups in its left margin — the way Excel's own Group command does — and reads the summary row as the one **below** each group, which is where a total row sits. `0`, the default, is a row outside any group, and a row given no options is at 0. Nothing is hidden or collapsed: every row you wrote is in the document and open, and what the reader does with the controls is theirs.
121
+ `options.level` is the row's **outline level**, an integer from 0 to 7. A reader draws the levels as collapsible groups in its left margin — the way Excel's own Group command does — and reads the summary row as the one **below** each group, which is where a total row sits. `0`, the default, is a row outside any group, and a row given no options is at 0.
122
+
123
+ `options.hidden` and `options.collapsed` say how a group opens. A collapsed group is **both**: its content rows `hidden`, and its summary row `collapsed`. Hiding alone leaves the group's control showing expanded over rows nobody can see, which is a document in two minds rather than a collapsed group. Neither is set by default, so a row you write is in the document and open unless you say otherwise, and what the reader then does with the controls is theirs.
124
+
125
+ ```js
126
+ sheet.row([{ value: "North" }], { level: 1 });
127
+ sheet.row([{ value: "Laptop" }], { level: 2, hidden: true });
128
+ sheet.row([{ value: "Subtotal" }], { level: 1, collapsed: true });
129
+ ```
130
+
131
+ An option this does not know is **refused**, not ignored: a misspelt one would otherwise be a row attribute you asked for and never got.
122
132
 
123
133
  A sheet that outlines any row also states the workbook's default row height, 15 points, because the format requires it beside the deepest level. A sheet with no outline states neither, and a reader keeps its own default. That is the one place this writer names a row height, and it names the default rather than one of its own.
124
134
 
@@ -141,6 +151,16 @@ Rich text is a **bare array** — there is no wrapper object, because nothing el
141
151
 
142
152
  One merged range across `width` columns of `row`, starting at the 1-based column `at`.
143
153
 
154
+ ### `sheet.link(row, at, target)`
155
+
156
+ Link the cell at 1-based `row` and column `at`. `target` names exactly one of the two destinations the format has: `{ url }`, somewhere outside this workbook, or `{ location }`, a reference inside it — a cell as `'Sheet2'!A1`, or a defined name.
157
+
158
+ Linking **writes no cell**. What a reader shows is whatever the row already put there, which is why the row must exist first, as a merge's must. A reader holds one link per cell, so a second link on the same cell throws rather than quietly replacing the first, and an empty destination throws rather than becoming a link onto nothing.
159
+
160
+ Cells pointing at the same `url` share one relationship, however many of them there are — a column of a thousand rows linking one address writes one. An internal `location` needs no relationship at all.
161
+
162
+ A worksheet that never calls this carries **no `hyperlinks` element**, and no relationship part unless it also places a picture.
163
+
144
164
  ### `sheet.freeze(rows)`
145
165
 
146
166
  Freeze the top `rows` rows. `0` clears.
package/lib/index.d.ts CHANGED
@@ -152,15 +152,35 @@ export interface RowOptions {
152
152
  * the one below the group. `0`, the default, is a row outside any group.
153
153
  */
154
154
  level?: number;
155
+ /**
156
+ * Whether the row is hidden. A collapsed outline group is its content rows
157
+ * hidden and its summary row `collapsed`; hiding alone leaves the group's
158
+ * control showing expanded over rows nobody can see.
159
+ */
160
+ hidden?: boolean;
161
+ /**
162
+ * Whether this row carries the control of a collapsed group. It goes on the
163
+ * **summary** row — the one below the group — not on the hidden rows.
164
+ */
165
+ collapsed?: boolean;
155
166
  }
156
167
 
168
+ /**
169
+ * Where one linked cell points: outside the workbook, or inside it. A
170
+ * `location` is a reference this file can resolve — a cell as `'Sheet2'!A1`,
171
+ * or a defined name.
172
+ */
173
+ export type LinkTarget = { url: string } | { location: string };
174
+
157
175
  export interface Sheet {
158
176
  /** This sheet's name. */
159
177
  readonly name: string;
160
178
  /**
161
179
  * Append a row. `null` is an empty unstyled cell. Returns the row's 1-based
162
180
  * position, which `merge` and `place` take. `options.level` puts the row at
163
- * an outline level.
181
+ * an outline level, and `hidden` / `collapsed` say whether it is shown and
182
+ * whether it carries a collapsed group's control. An option this does not
183
+ * know is refused rather than ignored.
164
184
  */
165
185
  row(cells: readonly (Cell | null)[], options?: RowOptions): number;
166
186
  /**
@@ -168,6 +188,15 @@ export interface Sheet {
168
188
  * Throws on a width below 2, an overlap, or a row that does not exist yet.
169
189
  */
170
190
  merge(row: number, at: number, width: number): void;
191
+ /**
192
+ * Link one cell: a destination outside this workbook (`url`) or a reference
193
+ * inside it (`location`), exactly one of the two.
194
+ *
195
+ * Linking writes no cell — a reader shows whatever the row already put
196
+ * there — so the row must exist first, as a merge's must. A reader holds
197
+ * one link per cell, so a second on the same cell throws.
198
+ */
199
+ link(row: number, at: number, target: LinkTarget): void;
171
200
  /** Freeze the top `count` rows. `0` clears. */
172
201
  freeze(count: number): void;
173
202
  /**
package/lib/index.js CHANGED
@@ -903,12 +903,29 @@ const MAX_LEVEL = 7;
903
903
  /** @param {unknown} level */
904
904
  const isLevel = (level) => level === 0 || isIndex(level, MAX_LEVEL);
905
905
 
906
- /**
907
- * A row's outline level, or 0 for a row that said nothing.
908
- * @param {unknown} options
909
- */
910
- const requireLevel = (options) => {
911
- const level = requireRecord(options ?? {}, "row: options", "{ level }").level ?? 0;
906
+ // Everything a row may say about itself. Named here because the reader below
907
+ // refuses anything else: a key it quietly dropped would be a caller asking for
908
+ // a row attribute and getting no error and no attribute.
909
+ const ROW_OPTIONS = ["level", "hidden", "collapsed"];
910
+
911
+ /** @param {unknown} value @param {string} where */
912
+ const requireFlag = (value, where) => {
913
+ if (value !== undefined && value !== null && typeof value !== "boolean")
914
+ throw TypeError(`${where}: expected a boolean, got ${JSON.stringify(value)}`);
915
+ return value === true;
916
+ };
917
+
918
+ /** The options object, with nothing in it this writer cannot write. */
919
+ /** @param {unknown} options */
920
+ const requireOptions = (options) => {
921
+ const said = requireRecord(options ?? {}, "row: options", `{ ${ROW_OPTIONS.join(", ")} }`);
922
+ const stray = Object.keys(said).find((name) => !ROW_OPTIONS.includes(name));
923
+ if (stray !== undefined) throw TypeError(`row: options: unknown option ${JSON.stringify(stray)}`);
924
+ return said;
925
+ };
926
+
927
+ /** @param {unknown} level */
928
+ const requireOutline = (level) => {
912
929
  if (!isLevel(level))
913
930
  throw RangeError(
914
931
  `row: level: expected an integer between 0 and ${MAX_LEVEL}, got ${JSON.stringify(level)}`,
@@ -917,11 +934,31 @@ const requireLevel = (options) => {
917
934
  };
918
935
 
919
936
  /**
920
- * A row's opening tag: the level rides along only where there is one.
937
+ * What a row says about itself: its outline level, and whether it is hidden or
938
+ * carries a collapsed group's control.
939
+ * @param {unknown} options
940
+ */
941
+ const requireRow = (options) => {
942
+ const said = requireOptions(options);
943
+ return {
944
+ level: requireOutline(said.level ?? 0),
945
+ hidden: requireFlag(said.hidden, "row: hidden"),
946
+ collapsed: requireFlag(said.collapsed, "row: collapsed"),
947
+ };
948
+ };
949
+
950
+ /**
951
+ * A row's opening tag. The attributes sit in the order `CT_Row` declares them,
952
+ * and each rides along only where it says something.
921
953
  * @param {number} at
922
- * @param {number} level
954
+ * @param {ReturnType<typeof requireRow>} row
923
955
  */
924
- const rowOpen = (at, level) => `<row r="${at}"${level === 0 ? "" : ` outlineLevel="${level}"`}>`;
956
+ const rowOpen = (at, row) =>
957
+ `<row r="${at}"` +
958
+ (row.hidden ? ' hidden="1"' : "") +
959
+ (row.level === 0 ? "" : ` outlineLevel="${row.level}"`) +
960
+ (row.collapsed ? ' collapsed="1"' : "") +
961
+ ">";
925
962
 
926
963
  /**
927
964
  * The sheet's `sheetFormatPr`, or nothing for a sheet that outlines no row.
@@ -1079,6 +1116,59 @@ const requireFilter = (range, rows, columns) => {
1079
1116
  return { top, left, bottom, right };
1080
1117
  };
1081
1118
 
1119
+ /**
1120
+ * @typedef {{ url: string } | { location: string }} LinkTarget
1121
+ * @typedef {{ ref: string } & LinkTarget} Link
1122
+ */
1123
+
1124
+ // A link names one of the two: a destination outside the workbook, or a
1125
+ // reference inside it. Both at once names two destinations and neither names
1126
+ // none, so the count is what this checks rather than each key in turn.
1127
+ /** @param {unknown} target @returns {LinkTarget} */
1128
+ const requireTarget = (target) => {
1129
+ const url = /** @type {any} */ (target)?.url;
1130
+ const location = /** @type {any} */ (target)?.location;
1131
+ if ((url === undefined) === (location === undefined))
1132
+ throw RangeError(
1133
+ `link: expected exactly one of url and location, got ${JSON.stringify(target)}`,
1134
+ );
1135
+ if (url === undefined) return { location: requireDestination(location, "link: location") };
1136
+ return { url: requireDestination(url, "link: url") };
1137
+ };
1138
+
1139
+ // A destination is a string with something in it: an empty one is a link a
1140
+ // reader opens onto nothing, which is a caller's mistake rather than a link.
1141
+ /** @param {unknown} value @param {string} where */
1142
+ const requireDestination = (value, where) => {
1143
+ const text = requireString(value, where);
1144
+ if (text === "") throw RangeError(`${where}: expected a destination, got an empty string`);
1145
+ return text;
1146
+ };
1147
+
1148
+ // The destinations outside the workbook, deduplicated and in first-seen
1149
+ // order: one relationship each, however many cells point at it. The same walk
1150
+ // answers for the sheet part and for its relationships, so the two cannot
1151
+ // disagree about which id a link carries.
1152
+ /** @param {ReadonlyArray<Link>} links */
1153
+ const externals = (links) => [
1154
+ ...new Set(links.filter((link) => "url" in link).map((link) => /** @type {any} */ (link).url)),
1155
+ ];
1156
+
1157
+ // Every link of one sheet, in call order. An external one points at a
1158
+ // relationship of this worksheet's own part; an internal one carries its
1159
+ // reference and needs none. `first` is the id the relationships start at,
1160
+ // which is after the drawing where the sheet has one.
1161
+ /** @param {ReadonlyArray<Link>} links @param {number} first */
1162
+ const hyperlinksXml = (links, first) => {
1163
+ if (links.length === 0) return "";
1164
+ const targets = externals(links);
1165
+ const one = (/** @type {Link} */ link) =>
1166
+ "url" in link
1167
+ ? `<hyperlink ref="${link.ref}" r:id="rId${first + targets.indexOf(link.url)}"/>`
1168
+ : `<hyperlink ref="${link.ref}" location="${esc(link.location)}"/>`;
1169
+ return `<hyperlinks>${links.map(one).join("")}</hyperlinks>`;
1170
+ };
1171
+
1082
1172
  /** @param {ReadonlyArray<Range>} merges */
1083
1173
  const mergesXml = (merges) =>
1084
1174
  merges.length === 0
@@ -1195,6 +1285,8 @@ const worksheet = (name, styles, sst, knows) => {
1195
1285
  const merges = [];
1196
1286
  /** @type {Array<{ id: number, row: number, col: number, width: number, height: number }>} */
1197
1287
  const pictures = [];
1288
+ /** @type {Link[]} */
1289
+ const links = [];
1198
1290
  let frozen = 0;
1199
1291
  let widest = 1;
1200
1292
  // The deepest outline level any row carries; 0 writes no `sheetFormatPr`.
@@ -1242,13 +1334,13 @@ const worksheet = (name, styles, sst, knows) => {
1242
1334
  const at = rows.length + 1;
1243
1335
  if (cells.length > MAX_COLUMN)
1244
1336
  throw RangeError(`row: a sheet holds at most ${MAX_COLUMN} columns`);
1245
- const level = requireLevel(options);
1246
- deepest = Math.max(deepest, level);
1337
+ const row = requireRow(options);
1338
+ deepest = Math.max(deepest, row.level);
1247
1339
 
1248
1340
  let body = "";
1249
1341
  for (let index = 0; index < cells.length; index++) body += cellAt(cells[index], index, at);
1250
1342
  widest = Math.max(widest, cells.length);
1251
- rows.push(rowOpen(at, level) + body + "</row>");
1343
+ rows.push(rowOpen(at, row) + body + "</row>");
1252
1344
  return at;
1253
1345
  },
1254
1346
 
@@ -1269,6 +1361,34 @@ const worksheet = (name, styles, sst, knows) => {
1269
1361
  widest = Math.max(widest, range.right);
1270
1362
  },
1271
1363
 
1364
+ /**
1365
+ * Link one cell. `target` names exactly one of `url`, a destination
1366
+ * outside this workbook, and `location`, a reference inside it -- a cell
1367
+ * as `'Sheet2'!A1`, or a defined name.
1368
+ *
1369
+ * Linking writes no cell: what a reader shows is whatever the row already
1370
+ * put there, which is why the row must exist first, as a merge's must. A
1371
+ * reader holds one link per cell, so a second on the same cell throws
1372
+ * rather than quietly replacing the first.
1373
+ *
1374
+ * @param {number} row 1-based
1375
+ * @param {number} at 1-based column
1376
+ * @param {LinkTarget} target
1377
+ */
1378
+ link(row, at, target) {
1379
+ requireIndex(row, MAX_ROW, "link: row");
1380
+ requireIndex(at, MAX_COLUMN, "link: at");
1381
+ requireWritten(row, rows.length, "row", "link");
1382
+ const ref = `${letters(at)}${row}`;
1383
+ if (links.some((other) => other.ref === ref))
1384
+ throw RangeError(`link: ${ref} already carries a link`);
1385
+ links.push({ ref, ...requireTarget(target) });
1386
+ },
1387
+
1388
+ get links() {
1389
+ return links;
1390
+ },
1391
+
1272
1392
  /**
1273
1393
  * How this worksheet prints. Every key is optional, and a worksheet that
1274
1394
  * never calls this carries no print setup at all: a reader's own defaults
@@ -1372,6 +1492,7 @@ const worksheet = (name, styles, sst, knows) => {
1372
1492
  `<sheetData>${rows.join("")}</sheetData>` +
1373
1493
  filtered +
1374
1494
  mergesXml(merges) +
1495
+ hyperlinksXml(links, drawing === null ? 1 : drawing + 1) +
1375
1496
  printXml(printing) +
1376
1497
  (drawing === null ? "" : `<drawing r:id="rId${drawing}"/>`) +
1377
1498
  `</worksheet>`
@@ -1571,9 +1692,28 @@ const workbookRelsXml = (sheets) =>
1571
1692
  * @typedef {{ sheet: ReturnType<typeof worksheet>, index: number }} Drawing
1572
1693
  */
1573
1694
 
1695
+ // What one worksheet's own part points at: its drawing, where it has one, and
1696
+ // then a relationship per destination outside the workbook. The drawing leads
1697
+ // because it did before links existed, and `hyperlinksXml` counts from the
1698
+ // same place.
1699
+ /** @param {number} at @param {ReadonlyArray<Link>} links */
1700
+ const sheetRels = (at, links) =>
1701
+ DECLARATION +
1702
+ `<Relationships xmlns="${NS_PKG_REL}">` +
1703
+ (at === -1
1704
+ ? ""
1705
+ : `<Relationship Id="rId1" Type="${REL}/drawing" Target="../drawings/drawing${at + 1}.xml"/>`) +
1706
+ externals(links)
1707
+ .map(
1708
+ (url, index) =>
1709
+ `<Relationship Id="rId${(at === -1 ? 1 : 2) + index}" Type="${REL}/hyperlink" Target="${esc(url)}" TargetMode="External"/>`,
1710
+ )
1711
+ .join("") +
1712
+ `</Relationships>`;
1713
+
1574
1714
  /**
1575
- * Every worksheet part, and the relationship part of each sheet that carries
1576
- * a drawing.
1715
+ * Every worksheet part, and the relationship part of each sheet that points
1716
+ * at anything of its own — a drawing, a link, or both.
1577
1717
  * @param {Part} part
1578
1718
  * @param {ReadonlyArray<ReturnType<typeof worksheet>>} sheets
1579
1719
  * @param {ReadonlyArray<Drawing>} drawings
@@ -1582,12 +1722,8 @@ const sheetParts = (part, sheets, drawings) => {
1582
1722
  for (const [index, sheet] of sheets.entries()) {
1583
1723
  const at = drawings.findIndex((each) => each.index === index);
1584
1724
  part(`xl/worksheets/sheet${index + 1}.xml`, sheet.xml(at === -1 ? null : 1));
1585
- if (at !== -1)
1586
- part(
1587
- `xl/worksheets/_rels/sheet${index + 1}.xml.rels`,
1588
- DECLARATION +
1589
- `<Relationships xmlns="${NS_PKG_REL}"><Relationship Id="rId1" Type="${REL}/drawing" Target="../drawings/drawing${at + 1}.xml"/></Relationships>`,
1590
- );
1725
+ if (at !== -1 || externals(sheet.links).length > 0)
1726
+ part(`xl/worksheets/_rels/sheet${index + 1}.xml.rels`, sheetRels(at, sheet.links));
1591
1727
  }
1592
1728
  };
1593
1729
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "werkmap",
3
- "version": "0.6.0",
3
+ "version": "0.8.0",
4
4
  "description": "Tiny, CSP-safe OOXML spreadsheet writer. Write-only, zero dependencies, byte-identical output.",
5
5
  "keywords": [
6
6
  "csp",
@@ -24,10 +24,12 @@
24
24
  }
25
25
  },
26
26
  "scripts": {
27
- "check": "run-s fmt:check lint fallow size test test:browser",
27
+ "check": "run-s fmt:check lint fallow:lint size test fallow:health test:browser",
28
28
  "bench": "node --disallow-code-generation-from-strings --expose-gc bench/index.js",
29
29
  "commitlint": "commitlint",
30
- "fallow": "fallow",
30
+ "fallow": "run-s fallow:lint fallow:health",
31
+ "fallow:health": "fallow health --coverage coverage/coverage-final.json",
32
+ "fallow:lint": "fallow dead-code && fallow dupes",
31
33
  "fmt": "oxfmt",
32
34
  "fmt:check": "oxfmt --check",
33
35
  "lint": "oxlint",
@@ -57,7 +59,7 @@
57
59
  "size-limit": [
58
60
  {
59
61
  "path": "lib/index.js",
60
- "limit": "8 kB"
62
+ "limit": "8.5 kB"
61
63
  }
62
64
  ],
63
65
  "engines": {