@itsthecsguy/linework 0.1.2 → 0.1.4

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.
@@ -2,7 +2,9 @@
2
2
  // SVG gets a subset of the faces it uses (only the glyphs in its text), so a
3
3
  // diagram looks the same on GitHub, in a browser and in a doc, and text that fit
4
4
  // when linework sized the boxes still fits wherever it is shown.
5
+ import crypto from 'node:crypto';
5
6
  import fs from 'node:fs';
7
+ import os from 'node:os';
6
8
  import path from 'node:path';
7
9
  import subsetFont from 'subset-font';
8
10
  import { assetsDir } from './browser.js';
@@ -54,7 +56,7 @@ export async function embedFonts(svg) {
54
56
  text = [...new Set(text + text.toUpperCase() + ' ')].join('');
55
57
  const rules = [];
56
58
  for (const f of FACES.filter((x) => families.includes(x.family))) {
57
- const sub = await subsetFont(read(f), text, { targetFormat: 'woff2' });
59
+ const sub = await subset(f, text);
58
60
  rules.push(`@font-face{font-family:"${f.family}";font-weight:${f.weight};font-style:normal;src:url(data:font/woff2;base64,${sub.toString('base64')}) format("woff2");}`);
59
61
  }
60
62
  const style = `<style id="aiviz-fonts" type="text/css">${rules.join('')}</style>`;
@@ -64,3 +66,65 @@ export async function embedFonts(svg) {
64
66
  return svg.replace(/<defs[^>]*>/, (m) => m + style);
65
67
  return svg.replace(/<svg\b[^>]*>/, (m) => `${m}<defs>${style}</defs>`);
66
68
  }
69
+ // ---------- subset cache ----------
70
+ //
71
+ // Compressing a subset to WOFF2 is most of embedFonts' time, and rebuilding a
72
+ // diagram after a small edit usually needs the same characters again. A subset
73
+ // depends only on the font file and the set of characters (not their order), so
74
+ // that pair is the key. Kept in memory and in the user's own cache folder, never
75
+ // a shared temp dir: whatever is there ends up inside the SVGs.
76
+ const memo = new Map();
77
+ const fileHash = new Map();
78
+ /** Drop the in-memory copies, so the next lookup reads the disk cache. For tests. */
79
+ export function forgetSubsets() {
80
+ memo.clear();
81
+ }
82
+ function cacheDir() {
83
+ if (process.env.LINEWORK_NO_CACHE)
84
+ return undefined;
85
+ const home = os.homedir();
86
+ const base = process.env.XDG_CACHE_HOME ||
87
+ (process.platform === 'darwin' ? path.join(home, 'Library', 'Caches') : process.platform === 'win32' ? process.env.LOCALAPPDATA || path.join(home, 'AppData', 'Local') : path.join(home, '.cache'));
88
+ return path.join(base, 'linework', 'fonts');
89
+ }
90
+ async function subset(f, text) {
91
+ const font = read(f);
92
+ let fh = fileHash.get(f.file);
93
+ if (!fh)
94
+ fileHash.set(f.file, (fh = crypto.createHash('sha256').update(font).digest('hex')));
95
+ const chars = [...new Set(text)].sort().join('');
96
+ const key = crypto.createHash('sha256').update(`${fh}\0${chars}`).digest('hex').slice(0, 32);
97
+ const hit = memo.get(key);
98
+ if (hit)
99
+ return hit;
100
+ const dir = cacheDir();
101
+ const file = dir && path.join(dir, `${key}.woff2`);
102
+ let out;
103
+ try {
104
+ const b = file ? fs.readFileSync(file) : undefined;
105
+ // A WOFF2 file starts with its signature; anything else is a broken write.
106
+ if (b && b.subarray(0, 4).toString('latin1') === 'wOF2')
107
+ out = b;
108
+ }
109
+ catch {
110
+ // not cached yet
111
+ }
112
+ if (!out) {
113
+ out = await subsetFont(font, text, { targetFormat: 'woff2' });
114
+ if (file) {
115
+ try {
116
+ fs.mkdirSync(dir, { recursive: true });
117
+ const tmp = `${file}.${process.pid}.tmp`;
118
+ fs.writeFileSync(tmp, out);
119
+ fs.renameSync(tmp, file);
120
+ }
121
+ catch {
122
+ // A read-only or full disk only costs the cache.
123
+ }
124
+ }
125
+ }
126
+ if (memo.size >= 64)
127
+ memo.delete(memo.keys().next().value);
128
+ memo.set(key, out);
129
+ return out;
130
+ }
@@ -36,7 +36,9 @@ export const KINDS = {
36
36
  person: { shape: 'box', color: 'pink', description: 'a human actor' },
37
37
  client: { shape: 'box', color: 'blue', description: 'browser, mobile app, CLI' },
38
38
  function: { shape: 'box', color: 'violet', dashed: true, description: 'serverless function or job' },
39
- database: { shape: 'box', color: 'green', description: 'database or cache' },
39
+ database: { shape: 'box', color: 'green', description: 'relational or document database' },
40
+ cache: { shape: 'box', color: 'green', description: 'Redis, Memcached, CDN cache' },
41
+ storage: { shape: 'box', color: 'green', description: 'object or file storage' },
40
42
  queue: { shape: 'box', color: 'amber', description: 'queue, topic or event stream' },
41
43
  external: { shape: 'box', color: 'gray', dashed: true, description: 'third-party system' },
42
44
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@itsthecsguy/linework",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "Software diagrams an AI can write and a person can edit. YAML spec in, .drawio.svg out, editable in draw.io.",
5
5
  "keywords": [
6
6
  "diagram",
@@ -33,7 +33,14 @@ Never fall back to writing SVG or draw.io XML by hand. Tell the user what failed
33
33
  - `er`: database tables, their columns and how they relate.
34
34
 
35
35
  If the doc already has a Mermaid diagram, convert it instead of rewriting it: `linework import doc.md --block 1 -o docs/diagrams/x.drawio.svg`.
36
- 3. **Write the spec** to `/tmp/spec.yaml`, or pipe it on stdin with `-` as the file. Start from the closest file in `examples/` next to this guide: `checkout.yaml` (architecture), `serverless-aws.yaml` (architecture with AWS icons), `refund-flow.yaml` (flow), `payment-state.yaml` (state), `pay-sequence.yaml` (sequence), `billing-er.yaml` (ER). The `.drawio.svg` is the source of truth, so you don't need to keep the YAML.
36
+ 3. **Write the spec** to `/tmp/spec.yaml`, or pipe it on stdin with `-` as the file. Start from the closest file in `examples/` next to this guide:
37
+ - architecture: `checkout.yaml` (small), `platform.yaml` (clients, nested groups, 17 nodes), `serverless-aws.yaml` (AWS icons), `gcp-ingest.yaml` (GCP icons)
38
+ - flow: `refund-flow.yaml` (branches), `deploy-flow.yaml` (a retry loop back to an earlier step)
39
+ - state: `payment-state.yaml`, `job-state.yaml` (pause/resume pairs and a self-transition)
40
+ - sequence: `pay-sequence.yaml` (alt, note), `auth-seq.yaml` (loop with a nested opt)
41
+ - ER: `billing-er.yaml` (small), `shop-er.yaml` (7 tables, composite keys)
42
+
43
+ The `.drawio.svg` is the source of truth, so you don't need to keep the YAML.
37
44
  4. **Build with a preview:**
38
45
  ```bash
39
46
  linework build /tmp/spec.yaml -o docs/diagrams/checkout.drawio.svg --png /tmp/checkout.png
@@ -0,0 +1,35 @@
1
+ type: sequence
2
+ title: OAuth login with refresh
3
+ participants:
4
+ user: { label: User, kind: person }
5
+ app: { label: SPA, kind: client }
6
+ bff: { label: BFF, tech: Node.js }
7
+ idp: { label: Identity provider, kind: external }
8
+ api: { label: Resource API }
9
+ cache: { label: Token cache, kind: cache }
10
+ messages:
11
+ - user -> app: Sign in
12
+ - app -> bff: GET /login
13
+ - bff --> app: 302 to /authorize
14
+ - app -> idp: /authorize?code_challenge=...
15
+ - idp -> user: Login form
16
+ - user -> idp: Credentials + MFA
17
+ - idp --> app: redirect with code
18
+ - app -> bff: GET /callback?code
19
+ - bff ==> idp: POST /token (code, verifier)
20
+ - idp --> bff: access + refresh tokens
21
+ - bff -> cache: store refresh token
22
+ - note right of bff: tokens never reach the browser
23
+ - bff --> app: Set-Cookie session
24
+ - loop: every API call
25
+ do:
26
+ - app -> bff: GET /orders
27
+ - opt: access token expired
28
+ do:
29
+ - bff -> cache: read refresh token
30
+ - bff -> idp: POST /token (refresh)
31
+ - idp --> bff: new access token
32
+ - bff -> api: GET /orders + Bearer
33
+ - api -> api: validate JWT
34
+ - api --> bff: 200 orders
35
+ - bff --> app: orders
@@ -0,0 +1,32 @@
1
+ type: flow
2
+ title: Deploy pipeline
3
+ nodes:
4
+ push: { label: Push to main, kind: terminal }
5
+ build: { label: Build and unit test }
6
+ ok: { label: Tests pass?, kind: decision }
7
+ fix: { label: Notify author, kind: error }
8
+ stage: { label: Deploy to staging }
9
+ smoke: { label: Run smoke tests against staging }
10
+ smokeok: { label: Healthy?, kind: decision }
11
+ canary: { label: Canary 5% of traffic }
12
+ metrics: { label: Error rate under 0.1%?, kind: decision }
13
+ roll: { label: Roll back canary, kind: error }
14
+ full: { label: Promote to 100% }
15
+ done: { label: Released, kind: terminal }
16
+ artifact: { label: Container image, kind: data }
17
+ edges:
18
+ - push -> build
19
+ - build -> ok
20
+ - ok -> fix: "no"
21
+ - ok -> artifact: "yes"
22
+ - artifact -> stage
23
+ - stage -> smoke
24
+ - smoke -> smokeok
25
+ - smokeok -> canary: "yes"
26
+ - smokeok -> roll: "no"
27
+ - canary -> metrics
28
+ - metrics -> full: "yes"
29
+ - metrics -> roll: "no"
30
+ - roll -> fix
31
+ - fix -> build: retry after fix
32
+ - full -> done
@@ -0,0 +1,21 @@
1
+ type: architecture
2
+ title: Ingest pipeline on GCP
3
+ groups:
4
+ gcp: { label: Google Cloud project }
5
+ nodes:
6
+ devices: { label: Sensors, kind: client, tech: MQTT }
7
+ lb: { label: Load balancer, icon: gcp.cloud_load_balancing, in: gcp }
8
+ run: { label: Ingest API, icon: gcp.cloud_run, tech: Cloud Run, in: gcp }
9
+ ps: { label: telemetry, kind: queue, icon: gcp.pubsub, tech: Pub/Sub, in: gcp }
10
+ df: { label: Stream job, icon: gcp.dataflow, tech: Dataflow, in: gcp }
11
+ bq: { label: Warehouse, kind: database, icon: gcp.bigquery, tech: BigQuery, in: gcp }
12
+ gcs: { label: Raw archive, kind: storage, icon: gcp.cloud_storage, tech: GCS, in: gcp }
13
+ looker: { label: Looker, kind: external, tech: Dashboards }
14
+ edges:
15
+ - devices -> lb: HTTPS
16
+ - lb -> run
17
+ - run -.-> ps: publish
18
+ - ps -.-> df: subscribe
19
+ - df -> bq: stream insert
20
+ - df -> gcs: write Avro
21
+ - looker -> bq: SQL
@@ -0,0 +1,25 @@
1
+ type: state
2
+ title: Background job
3
+ nodes:
4
+ queued: {}
5
+ running: {}
6
+ paused: {}
7
+ retrying: { label: Waiting to retry }
8
+ succeeded: { kind: success }
9
+ failed: { kind: error }
10
+ cancelled: {}
11
+ edges:
12
+ - "[*] -> queued"
13
+ - queued -> running: worker picks up
14
+ - running -> succeeded: exit 0
15
+ - running -> retrying: error, attempts < 5
16
+ - retrying -> queued: backoff elapsed
17
+ - running -> failed: error, attempts = 5
18
+ - running -> paused: pause
19
+ - paused -> running: resume
20
+ - queued -> cancelled: cancel
21
+ - paused -> cancelled: cancel
22
+ - running -> running: heartbeat
23
+ - "succeeded -> [*]"
24
+ - "failed -> [*]"
25
+ - "cancelled -> [*]"
@@ -0,0 +1,45 @@
1
+ type: architecture
2
+ title: Platform
3
+ groups:
4
+ edge: { label: Edge }
5
+ cluster: { label: Kubernetes cluster }
6
+ core: { label: Core services, in: cluster }
7
+ data: { label: Data tier }
8
+ nodes:
9
+ user: { label: Customer, kind: person }
10
+ admin: { label: Support agent, kind: person }
11
+ mobile: { label: Mobile app, kind: client, tech: Swift }
12
+ web: { label: Web dashboard, kind: client, tech: React }
13
+ cdn: { label: CDN, kind: cache, tech: CloudFront, in: edge }
14
+ gw: { label: API gateway, tech: Kong, in: edge }
15
+ auth: { label: Auth service, tech: Go, in: core }
16
+ orders: { label: Orders service, tech: Kotlin, in: core, highlight: true }
17
+ search: { label: Search service, tech: Rust, in: core }
18
+ notify: { label: Notification worker, kind: function, tech: Node.js, in: cluster }
19
+ bus: { label: domain-events, kind: queue, tech: Kafka, in: cluster }
20
+ pg: { label: Orders DB, kind: database, tech: Postgres 16, in: data }
21
+ es: { label: Search index, kind: database, tech: OpenSearch, in: data }
22
+ redis: { label: Session store, kind: cache, tech: Redis, in: data }
23
+ s3: { label: Media bucket, kind: storage, tech: S3, in: data }
24
+ twilio: { label: Twilio, kind: external, tech: SMS }
25
+ idp: { label: Okta, kind: external, tech: OIDC }
26
+ edges:
27
+ - user -> mobile
28
+ - user -> web
29
+ - admin -> web
30
+ - mobile -> cdn: assets
31
+ - web -> cdn: assets
32
+ - mobile ==> gw: HTTPS
33
+ - web ==> gw: HTTPS
34
+ - cdn -> s3: origin fetch
35
+ - gw -> auth: verify token
36
+ - auth -> idp: OIDC
37
+ - auth -> redis: sessions
38
+ - gw ==> orders: gRPC
39
+ - gw -> search: gRPC
40
+ - orders ==> pg: SQL
41
+ - orders -.-> bus: OrderPlaced
42
+ - bus -.-> search: reindex
43
+ - bus -.-> notify: consume
44
+ - search -> es: query
45
+ - notify -> twilio: send SMS
@@ -0,0 +1,26 @@
1
+ type: er
2
+ title: Storefront schema
3
+ nodes:
4
+ customers:
5
+ fields: [id: uuid pk, email: text unique, name: text, created_at: timestamptz]
6
+ addresses:
7
+ fields: [id: uuid pk, customer_id: uuid fk, line1: text, city: text, country: char(2)]
8
+ orders:
9
+ fields: [id: uuid pk, customer_id: uuid fk, shipping_address_id: uuid fk, status: order_status, total_cents: bigint, placed_at: timestamptz null]
10
+ order_items:
11
+ fields: [order_id: uuid pk fk, product_id: uuid pk fk, quantity: int, unit_price_cents: bigint]
12
+ products:
13
+ fields: [id: uuid pk, sku: text unique, title: text, price_cents: bigint]
14
+ inventory:
15
+ fields: [product_id: uuid pk fk, warehouse: text pk, on_hand: int]
16
+ payments:
17
+ kind: external
18
+ fields: [id: text pk, order_id: uuid fk, provider: text]
19
+ edges:
20
+ - addresses }o--|| customers: has
21
+ - orders }o--|| customers: placed by
22
+ - orders }o--|| addresses: ships to
23
+ - order_items }|--|| orders: contains
24
+ - order_items }o--|| products: of
25
+ - inventory }o--|| products: stocks
26
+ - payments |o..|| orders: pays
@@ -38,9 +38,17 @@ Linework handles the layout and the look. These rules are about content, which i
38
38
  - **Keep error and success kinds for real errors and successes.** They're red and green in every style.
39
39
  - **Pick the style for the medium**: `clean` for docs and READMEs, `dark` for dark-mode pages, `soft` for slides, `sketch` for early drafts, `blueprint` for technical deep dives. Use one style across all the diagrams in a doc.
40
40
 
41
+ ## Order
42
+
43
+ Layout reads the spec top to bottom, so the order you write things in shows up in the picture.
44
+
45
+ - **List nodes and edges in reading order**: the entry point first, then each step as the request or flow reaches it. A flow's main path then runs straight down, and an edge back to an earlier node (a retry, a resume) is drawn as the loop it is.
46
+ - **Write a reply after its call** (`api -> stripe`, then `stripe -.-> api`). The pair is drawn side by side.
47
+ - **In ER, name foreign keys after the table they point at**: `customer_id` for `customers`, `shipping_address_id` for `addresses`. The relationship line then attaches to that row and to the other table's primary key. A key named anything else attaches to the table as a whole.
48
+
41
49
  ## Direction
42
50
 
43
- - Leave out `direction` on the first build. `auto` tries both and keeps the one that fits a doc page, which is about 800px wide.
51
+ - Leave out `direction` on the first build. `auto` tries both and keeps the one that fits a doc page, which is about 800px wide. ER diagrams always run left to right, since relationships attach to the sides of the key rows.
44
52
  - Set it only after looking at the result, and only if it reads wrong. A short linear pipeline of 5 or fewer steps can read better as LR.
45
53
  - Don't force LR on a diagram with more than about 6 nodes in a row. It comes out 1,500px or more wide, and embedded in a doc its text shrinks to unreadable. `check` warns about this as `too-wide`.
46
54