{
  "schema": "leumas.docs.page/1",
  "id": "pkg:@leumas/exporters",
  "slug": "packages/exporters",
  "kind": "capabilities",
  "bucket": "package",
  "title": "@leumas/exporters",
  "name": "@leumas/exporters",
  "eyebrow": null,
  "chip": null,
  "summary": "The ONE way anything in Leumas leaves the app — serialize a value to JSON/NDJSON/CSV/text, hand it to the browser as a download, and wrap it in a versioned envelope so a file exported from one Leumas...",
  "keywords": [
    "exporters",
    "ndjson",
    "serialize",
    "wrap",
    "download",
    "leumas exporters",
    "envelope",
    "leaves"
  ],
  "audience": "both",
  "funnel": {
    "product": null,
    "cta": null
  },
  "body": "# @leumas/exporters\n\nThe ONE way anything in Leumas leaves the app.\n\nBefore this existed, `pnpm atlas find \"download file blob export csv\"` answered *\"nothing like it\"* while\ntwenty-odd surfaces each hand-rolled `URL.createObjectURL` inline. This package is what they should all\nhave been calling.\n\n```js\nimport { exportAs, copyText } from '@leumas/exporters';\n\nexportAs('csv', events, 'mqtt-capture');   // → \"mqtt-capture_2026-08-11_14-30-05.csv\"\nawait copyText(toJSON(result));\n```\n\n## Three layers\n\n| Subpath | Needs a DOM | What it is |\n|---|---|---|\n| `@leumas/exporters/serialize` | no | a value → the text of a file — `toJSON` `toNDJSON` `toCSV` `toText` `serialize` `FORMATS` |\n| `@leumas/exporters/download` | **yes** | that text → the user's downloads — `saveAs` `downloadAs` `exportAs` `copyText` `readFileText` `canDownload` |\n| `@leumas/exporters/envelope` | no | the portable wrapper — `wrap` `read` |\n| `@leumas/exporters/filename` | no | `stampName` `exportFilename` |\n\nThe barrel (`@leumas/exporters`) re-exports all four. Nothing runs at module scope, so importing the\nbarrel in Node is safe — `canDownload()` simply answers false and `saveAs` becomes a no-op.\n\n**The UI is not in here.** `<ExportMenu>` lives in `@leumas/ui`; a package that renders nothing must not\ndepend on React.\n\n## The envelope — why an export is not just JSON\n\nA bare `JSON.stringify(request)` is not portable, it is a blob of JSON that happens to work today.\n`wrap()` adds the three things a payload cannot say about itself:\n\n```json\n{ \"leumas\": 1, \"kind\": \"protocols.request\", \"version\": 1,\n  \"createdAt\": \"2026-08-11T14:30:05.000Z\",\n  \"meta\": { \"transport\": \"mqtt\" },\n  \"data\": { }\n}\n```\n\n`read(text, 'protocols.request')` returns `{ok, kind, version, meta, data, error}` and **never throws** —\nit is fed files a human dragged in, half of which are the wrong file. A parse error, a missing marker,\nthe wrong kind and a newer envelope are all normal inputs, and each comes back as a sentence you can put\nstraight in a `<Notice>`.\n\nA bare payload with no envelope is rejected on purpose. Accepting it would mean guessing what it was.\n\n## The four things that were wrong in the hand-rolled copies\n\nEach is pinned by a test in `test/exporters.test.js`, and none of them throws — which is why none was\never noticed by the person who wrote it.\n\n1. **`JSON.stringify` on anything holding a socket, a DOM node or a parent pointer throws** from inside\n   a click handler, so the button appears to do nothing at all. `toJSON` is total.\n2. **The obvious circular-reference guard eats non-circular data.** A `WeakSet` of everything seen also\n   fires on a reference that merely appears twice — the same `headers` object on the request and the\n   response — and silently writes `\"[Circular]\"` over real data. `safeReplacer` tracks *ancestors*.\n3. **CSV is not `rows.map(r => r.join(','))`.** A value holding a comma, a quote or a newline must be\n   quoted with its quotes doubled (RFC 4180), or the file loses column alignment from that row on — and\n   a spreadsheet opens it without complaining, which is worse.\n4. **`URL.revokeObjectURL` in the same tick as the click** races the browser's read of the href and\n   produces a zero-byte file on some versions; never revoking pins the blob for the life of the tab.\n   Revoke on the next frame. And the anchor must be **in the document** — a detached `<a>` is silently\n   ignored by Firefox and works fine in Chrome.\n\n## Related\n\n`@leumas/ui` → `ExportMenu` (the control) and `@leumas/ui/dnd` (the import direction).\n`@leumas/devices/media` → `downloadBlob` is now a thin re-export of `saveAs`.\n",
  "source": {
    "path": "shared/packages/exporters/README.md",
    "blobSha": "",
    "commit": "",
    "committedAt": "",
    "provenance": "no-git",
    "bytes": 3800,
    "hash": "3fec481c3005361c57cbeb492550e93970d8bd30"
  },
  "urls": {
    "html": "/p/packages/exporters",
    "json": "/docs/packages/exporters.json",
    "md": "/docs/packages/exporters.md"
  },
  "links": {
    "composes": [],
    "usedBy": [
      "pkg:@leumas/devices",
      "pkg:@leumas/studio",
      "pkg:@leumas/ui"
    ],
    "product": [
      "pkg:@leumas/admin",
      "pkg:@leumas/converter-web",
      "pkg:@leumas/docs",
      "pkg:@leumas/studio",
      "pkg:@leumas/web"
    ],
    "howTo": [],
    "skills": []
  },
  "exports": {
    "total": 42,
    "component": 5,
    "hook": 0,
    "helper": 37,
    "names": [
      {
        "n": "CLIPBOARD_IMAGE_TYPES",
        "k": "component"
      },
      {
        "n": "ENVELOPE_VERSION",
        "k": "component"
      },
      {
        "n": "ENVELOPE_VERSION",
        "k": "component"
      },
      {
        "n": "FORMATS",
        "k": "component"
      },
      {
        "n": "FORMATS",
        "k": "component"
      },
      {
        "n": "canCopyImage",
        "k": "helper"
      },
      {
        "n": "canDownload",
        "k": "helper"
      },
      {
        "n": "canDownload",
        "k": "helper"
      },
      {
        "n": "copyBlob",
        "k": "helper"
      },
      {
        "n": "copyImage",
        "k": "helper"
      },
      {
        "n": "copyText",
        "k": "helper"
      },
      {
        "n": "copyText",
        "k": "helper"
      },
      {
        "n": "downloadAs",
        "k": "helper"
      },
      {
        "n": "downloadAs",
        "k": "helper"
      },
      {
        "n": "exportAs",
        "k": "helper"
      },
      {
        "n": "exportAs",
        "k": "helper"
      },
      {
        "n": "exportFilename",
        "k": "helper"
      },
      {
        "n": "exportFilename",
        "k": "helper"
      },
      {
        "n": "formatOf",
        "k": "helper"
      },
      {
        "n": "formatOf",
        "k": "helper"
      },
      {
        "n": "formatXml",
        "k": "helper"
      },
      {
        "n": "formatXml",
        "k": "helper"
      },
      {
        "n": "read",
        "k": "helper"
      },
      {
        "n": "read",
        "k": "helper"
      },
      {
        "n": "readFileText",
        "k": "helper"
      },
      {
        "n": "readFileText",
        "k": "helper"
      },
      {
        "n": "saveAs",
        "k": "helper"
      },
      {
        "n": "saveAs",
        "k": "helper"
      },
      {
        "n": "serialize",
        "k": "helper"
      },
      {
        "n": "serialize",
        "k": "helper"
      },
      {
        "n": "stampName",
        "k": "helper"
      },
      {
        "n": "stampName",
        "k": "helper"
      },
      {
        "n": "toCSV",
        "k": "helper"
      },
      {
        "n": "toCSV",
        "k": "helper"
      },
      {
        "n": "toJSON",
        "k": "helper"
      },
      {
        "n": "toJSON",
        "k": "helper"
      },
      {
        "n": "toNDJSON",
        "k": "helper"
      },
      {
        "n": "toNDJSON",
        "k": "helper"
      },
      {
        "n": "toText",
        "k": "helper"
      },
      {
        "n": "toText",
        "k": "helper"
      },
      {
        "n": "wrap",
        "k": "helper"
      },
      {
        "n": "wrap",
        "k": "helper"
      }
    ]
  }
}
