werkmap 0.7.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
@@ -151,6 +151,16 @@ Rich text is a **bare array** — there is no wrapper object, because nothing el
151
151
 
152
152
  One merged range across `width` columns of `row`, starting at the 1-based column `at`.
153
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
+
154
164
  ### `sheet.freeze(rows)`
155
165
 
156
166
  Freeze the top `rows` rows. `0` clears.
package/lib/index.d.ts CHANGED
@@ -165,6 +165,13 @@ export interface RowOptions {
165
165
  collapsed?: boolean;
166
166
  }
167
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
+
168
175
  export interface Sheet {
169
176
  /** This sheet's name. */
170
177
  readonly name: string;
@@ -181,6 +188,15 @@ export interface Sheet {
181
188
  * Throws on a width below 2, an overlap, or a row that does not exist yet.
182
189
  */
183
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;
184
200
  /** Freeze the top `count` rows. `0` clears. */
185
201
  freeze(count: number): void;
186
202
  /**
package/lib/index.js CHANGED
@@ -1116,6 +1116,59 @@ const requireFilter = (range, rows, columns) => {
1116
1116
  return { top, left, bottom, right };
1117
1117
  };
1118
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
+
1119
1172
  /** @param {ReadonlyArray<Range>} merges */
1120
1173
  const mergesXml = (merges) =>
1121
1174
  merges.length === 0
@@ -1232,6 +1285,8 @@ const worksheet = (name, styles, sst, knows) => {
1232
1285
  const merges = [];
1233
1286
  /** @type {Array<{ id: number, row: number, col: number, width: number, height: number }>} */
1234
1287
  const pictures = [];
1288
+ /** @type {Link[]} */
1289
+ const links = [];
1235
1290
  let frozen = 0;
1236
1291
  let widest = 1;
1237
1292
  // The deepest outline level any row carries; 0 writes no `sheetFormatPr`.
@@ -1306,6 +1361,34 @@ const worksheet = (name, styles, sst, knows) => {
1306
1361
  widest = Math.max(widest, range.right);
1307
1362
  },
1308
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
+
1309
1392
  /**
1310
1393
  * How this worksheet prints. Every key is optional, and a worksheet that
1311
1394
  * never calls this carries no print setup at all: a reader's own defaults
@@ -1409,6 +1492,7 @@ const worksheet = (name, styles, sst, knows) => {
1409
1492
  `<sheetData>${rows.join("")}</sheetData>` +
1410
1493
  filtered +
1411
1494
  mergesXml(merges) +
1495
+ hyperlinksXml(links, drawing === null ? 1 : drawing + 1) +
1412
1496
  printXml(printing) +
1413
1497
  (drawing === null ? "" : `<drawing r:id="rId${drawing}"/>`) +
1414
1498
  `</worksheet>`
@@ -1608,9 +1692,28 @@ const workbookRelsXml = (sheets) =>
1608
1692
  * @typedef {{ sheet: ReturnType<typeof worksheet>, index: number }} Drawing
1609
1693
  */
1610
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
+
1611
1714
  /**
1612
- * Every worksheet part, and the relationship part of each sheet that carries
1613
- * 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.
1614
1717
  * @param {Part} part
1615
1718
  * @param {ReadonlyArray<ReturnType<typeof worksheet>>} sheets
1616
1719
  * @param {ReadonlyArray<Drawing>} drawings
@@ -1619,12 +1722,8 @@ const sheetParts = (part, sheets, drawings) => {
1619
1722
  for (const [index, sheet] of sheets.entries()) {
1620
1723
  const at = drawings.findIndex((each) => each.index === index);
1621
1724
  part(`xl/worksheets/sheet${index + 1}.xml`, sheet.xml(at === -1 ? null : 1));
1622
- if (at !== -1)
1623
- part(
1624
- `xl/worksheets/_rels/sheet${index + 1}.xml.rels`,
1625
- DECLARATION +
1626
- `<Relationships xmlns="${NS_PKG_REL}"><Relationship Id="rId1" Type="${REL}/drawing" Target="../drawings/drawing${at + 1}.xml"/></Relationships>`,
1627
- );
1725
+ if (at !== -1 || externals(sheet.links).length > 0)
1726
+ part(`xl/worksheets/_rels/sheet${index + 1}.xml.rels`, sheetRels(at, sheet.links));
1628
1727
  }
1629
1728
  };
1630
1729
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "werkmap",
3
- "version": "0.7.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": {