From b05cdab771001228ec3cdda7158529da67e52342 Mon Sep 17 00:00:00 2001
From: Tangent Wantwight <tangent128@gmail.com>
Date: Sat, 27 Jan 2024 23:57:09 -0500
Subject: [PATCH 01/13] Simplify tick.ts typing

---
 lib/tick.ts | 8 ++------
 1 file changed, 2 insertions(+), 6 deletions(-)

diff --git a/lib/tick.ts b/lib/tick.ts
index a4b8c2b..d158357 100644
--- a/lib/tick.ts
+++ b/lib/tick.ts
@@ -1,13 +1,9 @@
-import { Cancel, Source } from "./source";
+import { Source } from "./source";
 
 export type PhysicsTick = ["physics"];
 export type RenderTick = ["render", number];
 
 export function tick(fps: number): Source<PhysicsTick | RenderTick> {
-  function tickSource(): PhysicsTick;
-  function tickSource(
-    callback: (tick: PhysicsTick | RenderTick) => void
-  ): Cancel;
   function tickSource(callback?: (tick: PhysicsTick | RenderTick) => void) {
     if (callback) {
       let lastPhysicsTick: number = new Date().getTime();
@@ -32,5 +28,5 @@ export function tick(fps: number): Source<PhysicsTick | RenderTick> {
     }
   }
 
-  return tickSource;
+  return tickSource as Source<PhysicsTick | RenderTick>;
 }

From b04ba4e62249b8c38c4f9c93b002936e068f7287 Mon Sep 17 00:00:00 2001
From: Tangent Wantwight <tangent128@gmail.com>
Date: Sat, 27 Jan 2024 23:57:36 -0500
Subject: [PATCH 02/13] WIP KeyControl event source

---
 lib/keys.ts | 67 +++++++++++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 67 insertions(+)
 create mode 100644 lib/keys.ts

diff --git a/lib/keys.ts b/lib/keys.ts
new file mode 100644
index 0000000..c3b335f
--- /dev/null
+++ b/lib/keys.ts
@@ -0,0 +1,67 @@
+import { Source } from "./source";
+
+export type KeyName = "up" | "down" | "left" | "right" | "a" | "b" | "menu";
+
+const KEY_NAMES: Record<string, KeyName> = {
+  // compact keys (WASD+ZXC)
+  KeyZ: "a",
+  KeyX: "b",
+  KeyC: "menu",
+  KeyW: "up",
+  KeyS: "down",
+  KeyA: "left",
+  KeyD: "right",
+  // full-board keys (arrows+space/shift/enter)
+  Space: "a",
+  ShiftLeft: "b",
+  ShiftRight: "b",
+  Enter: "menu",
+  ArrowUp: "up",
+  ArrowDown: "down",
+  ArrowLeft: "left",
+  ArrowRight: "right",
+};
+
+/** A keypress/release event for an abstract button, or else ["focus", "release"] if for some reason future release events might not be registered. */
+export type KeyEvent = [KeyName | "focus", "press" | "release"];
+
+export function keyControl(source: HTMLElement): Source<KeyEvent> {
+  const tabIndex = source.getAttribute("tabIndex");
+  source.setAttribute(
+    "tabindex",
+    tabIndex == "" || tabIndex == null ? "-1" : tabIndex
+  );
+
+  return ((callback?: (keyEvent: KeyEvent) => void) => {
+    if (callback) {
+      const handle = (evt: KeyboardEvent, action: "press" | "release") => {
+        const keyName = KEY_NAMES[evt.code];
+        if (keyName != undefined) {
+          evt.preventDefault();
+          evt.stopPropagation();
+
+          callback([keyName, action]);
+        }
+      };
+      const keyUp = (evt: KeyboardEvent) => handle(evt, "release");
+      const keyDown = (evt: KeyboardEvent) => handle(evt, "press");
+      const focus = () => callback(["focus", "press"]);
+      const blur = () => callback(["focus", "release"]);
+
+      source.addEventListener("keyup", keyUp, false);
+      source.addEventListener("keydown", keyDown, false);
+      source.addEventListener("focus", focus, false);
+      source.addEventListener("blur", blur, false);
+      source.focus({ focusVisible: true } as FocusOptions);
+
+      return () => {
+        source.removeEventListener("keyup", keyUp, false);
+        source.removeEventListener("keydown", keyDown, false);
+        source.removeEventListener("focus", focus, false);
+        source.removeEventListener("blur", blur, false);
+      };
+    } else {
+      return ["blur", "release"];
+    }
+  }) as Source<KeyEvent>;
+}

From 497731f62b299517f3831c13f42836d689d065c7 Mon Sep 17 00:00:00 2001
From: Tangent Wantwight <tangent128@gmail.com>
Date: Sat, 3 Feb 2024 15:36:29 -0500
Subject: [PATCH 03/13] Skeleton for pixelflood

---
 pixelflood.html | 11 +++++++++++
 pixelflood.ts   | 45 +++++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 56 insertions(+)
 create mode 100644 pixelflood.html
 create mode 100644 pixelflood.ts

diff --git a/pixelflood.html b/pixelflood.html
new file mode 100644
index 0000000..a9d9d7f
--- /dev/null
+++ b/pixelflood.html
@@ -0,0 +1,11 @@
+<html>
+  <head>
+    <title>Pixelflood Art</title>
+  </head>
+  <body>
+    <script src="js/pixelflood.js"></script>
+    <script>
+      document.body.append(...PixelfloodApplet());
+    </script>
+  </body>
+</html>
diff --git a/pixelflood.ts b/pixelflood.ts
new file mode 100644
index 0000000..ba288a7
--- /dev/null
+++ b/pixelflood.ts
@@ -0,0 +1,45 @@
+// TODO choose generator
+// TODO get/put image data in render/tick loop
+// TODO rediscover RGB/HSV pixelflood techniques
+
+import { canvas2d, h } from "./lib/html";
+
+interface Controls {
+  seed: HTMLInputElement;
+}
+
+function PixelfloodApplet() {
+  const [canvas, cx] = canvas2d({});
+  const [controls, controlUi] = ControlUi();
+
+  return [canvas, ...controlUi];
+}
+
+function numInput(init: number) {
+  return h("input", { type: "number", valueAsNumber: init });
+}
+
+function ControlUi(): [Controls, HTMLElement[]] {
+  let seed, width, height;
+
+  const html = [
+    h("h2", {}, "Controls"),
+    h(
+      "div",
+      {},
+      h(
+        "label",
+        {},
+        "Width: ",
+        (width = numInput(128)),
+        "Height: ",
+        (height = numInput(128))
+      )
+    ),
+    h("div", {}, h("label", {}, "Random Seed: ", (seed = numInput(128)))),
+  ];
+
+  return [{ seed }, html];
+}
+
+Object.assign(globalThis, { PixelfloodApplet });

From a2df9a4142194e00c36e4bd7a1ad3f40cd3fe5f3 Mon Sep 17 00:00:00 2001
From: Tangent Wantwight <tangent128@gmail.com>
Date: Sun, 7 Jul 2024 17:28:10 -0400
Subject: [PATCH 04/13] Use vscode folder format

---
 .vscode/settings.json    |  3 +++
 .vscode/tasks.json       | 17 +++++++++++++++++
 js_sketch.code-workspace | 27 ---------------------------
 3 files changed, 20 insertions(+), 27 deletions(-)
 create mode 100644 .vscode/settings.json
 create mode 100644 .vscode/tasks.json
 delete mode 100644 js_sketch.code-workspace

diff --git a/.vscode/settings.json b/.vscode/settings.json
new file mode 100644
index 0000000..ad92582
--- /dev/null
+++ b/.vscode/settings.json
@@ -0,0 +1,3 @@
+{
+  "editor.formatOnSave": true
+}
diff --git a/.vscode/tasks.json b/.vscode/tasks.json
new file mode 100644
index 0000000..4261f3e
--- /dev/null
+++ b/.vscode/tasks.json
@@ -0,0 +1,17 @@
+{
+  "version": "2.0.0",
+  "tasks": [
+    {
+      "label": "Serve Esbuild",
+      "detail": "Run a local server serving html files & transpiling TS -> JS",
+      "command": "npm",
+      "type": "process",
+      "args": ["run", "serve"],
+      "problemMatcher": ["$esbuild-watch"],
+      "presentation": {
+        "reveal": "always",
+        "panel": "dedicated"
+      }
+    }
+  ]
+}
diff --git a/js_sketch.code-workspace b/js_sketch.code-workspace
deleted file mode 100644
index 85ed6cf..0000000
--- a/js_sketch.code-workspace
+++ /dev/null
@@ -1,27 +0,0 @@
-{
-  "folders": [
-    {
-      "path": "."
-    }
-  ],
-  "settings": {
-    "editor.formatOnSave": true
-  },
-  "tasks": {
-    "version": "2.0.0",
-    "tasks": [
-      {
-        "label": "Serve Esbuild",
-        "detail": "Run a local server serving html files & transpiling TS -> JS",
-        "command": "npm",
-        "type": "process",
-        "args": ["run", "serve"],
-        "problemMatcher": ["$esbuild-watch"],
-        "presentation": {
-          "reveal": "always",
-          "panel": "dedicated"
-        }
-      }
-    ]
-  }
-}

From e90727a06c787f1e117c7fee1dbb89462556051a Mon Sep 17 00:00:00 2001
From: Tangent Wantwight <tangent128@gmail.com>
Date: Sun, 7 Jul 2024 21:00:20 -0400
Subject: [PATCH 05/13] Stub out Idv parser

---
 debug.html   |  1 +
 debug.ts     |  3 ++-
 debug/idv.ts | 43 +++++++++++++++++++++++++++++++
 lib/idv.ts   | 72 ++++++++++++++++++++++++++++++++++++++++++++++++++++
 4 files changed, 118 insertions(+), 1 deletion(-)
 create mode 100644 debug/idv.ts
 create mode 100644 lib/idv.ts

diff --git a/debug.html b/debug.html
index c79ae55..7019993 100644
--- a/debug.html
+++ b/debug.html
@@ -6,6 +6,7 @@
     <script src="js/debug.js"></script>
     <script>
       document.body.append(...TickDebug());
+      document.body.append(...IdvDebug());
     </script>
   </body>
 </html>
diff --git a/debug.ts b/debug.ts
index 19908a0..b181e09 100644
--- a/debug.ts
+++ b/debug.ts
@@ -1,3 +1,4 @@
 import { TickDebug } from "./debug/tick";
+import { IdvDebug } from "./debug/idv";
 
-Object.assign(globalThis, { TickDebug });
+Object.assign(globalThis, { TickDebug, IdvDebug });
diff --git a/debug/idv.ts b/debug/idv.ts
new file mode 100644
index 0000000..43dbd7e
--- /dev/null
+++ b/debug/idv.ts
@@ -0,0 +1,43 @@
+import { h } from "../lib/html";
+import { Idv, StringFromDocument } from "../lib/idv";
+
+export function IdvDebug() {
+  const textarea = h(
+    "textarea",
+    {
+      cols: 80,
+      rows: 40,
+      oninput(ev) {
+        parse();
+      },
+    },
+    `# Idv Testing Ground
+Uid: 0
+Shell: tclsh
+Group: users
+Group: sudo
+    `
+  );
+
+  const pre = h("pre");
+
+  function parse() {
+    try {
+      const idv = Idv.parse(textarea.value);
+
+      pre.textContent = JSON.stringify(
+        {
+          shell: idv.getProperty("Shell", String, StringFromDocument),
+          groups: idv.getList("Group", String, StringFromDocument),
+        },
+        null,
+        2
+      );
+    } catch (e) {
+      pre.textContent = String(e);
+    }
+  }
+  parse();
+
+  return [textarea, pre];
+}
diff --git a/lib/idv.ts b/lib/idv.ts
new file mode 100644
index 0000000..2521e82
--- /dev/null
+++ b/lib/idv.ts
@@ -0,0 +1,72 @@
+export type DistinguisherParser<T> = (distinguisher: string) => T;
+export type DocumentParser<T> = (document: string[]) => T;
+
+const LEADING_WHITESPACE = /^([ \t]+)/;
+const ENTRY = /^(.+?):\s*(.*)/;
+
+export class Idv {
+  collections: Record<string, undefined | [string, string[]][]> = {};
+
+  public static parse(input: string): Idv {
+    const lines = input.split("\n").map((line) => line.trimEnd());
+    return Idv.parseLines(lines);
+  }
+  static parseLines(input: string[]): Idv {
+    const idv = new Idv();
+    let currentDocument: string[] = [];
+
+    input.forEach((line) => {
+      const indent = LEADING_WHITESPACE.exec(line)?.[1];
+      if (indent) {
+        // TODO
+      } else if (line == "") {
+        // TODO
+      } else if (line[0] == "#") {
+        // skip
+      } else {
+        const matches = ENTRY.exec(line);
+        if (matches) {
+          const [, collection, distinguisher] = matches;
+
+          if (idv.collections[collection] == undefined) {
+            idv.collections[collection] = [];
+          }
+
+          currentDocument = [];
+          idv.collections[collection].push([distinguisher, currentDocument]);
+        } else {
+          throw new Error("Failed to parse a property");
+        }
+      }
+    });
+
+    return idv;
+  }
+
+  public getProperty<T>(
+    name: string,
+    parseDistinguisher: DistinguisherParser<T>,
+    parseDocument: DocumentParser<T>
+  ): T | null {
+    const firstEntry = this.collections[name]?.[0];
+    return firstEntry && firstEntry[1].length > 0
+      ? parseDocument(firstEntry[1])
+      : firstEntry?.[0]
+      ? parseDistinguisher(firstEntry[0])
+      : null;
+  }
+
+  public getList<T>(
+    name: string,
+    parseDistinguisher: DistinguisherParser<T>,
+    parseDocument: DocumentParser<T>
+  ): T[] {
+    return (this.collections[name] ?? []).map(([distinguisher, document]) =>
+      document.length > 0
+        ? parseDocument(document)
+        : parseDistinguisher(distinguisher)
+    );
+  }
+}
+
+export const StringFromDocument = (lines: string[]) => lines.join("\n");

From 2f9bb73107078b231316f056b107058ac19180f1 Mon Sep 17 00:00:00 2001
From: Tangent Wantwight <tangent128@gmail.com>
Date: Wed, 10 Jul 2024 00:17:33 -0400
Subject: [PATCH 06/13] parse indented documents

---
 debug/idv.ts |  6 +++++-
 lib/idv.ts   | 36 +++++++++++++++++++++++++++---------
 2 files changed, 32 insertions(+), 10 deletions(-)

diff --git a/debug/idv.ts b/debug/idv.ts
index 43dbd7e..b54354c 100644
--- a/debug/idv.ts
+++ b/debug/idv.ts
@@ -16,7 +16,11 @@ Uid: 0
 Shell: tclsh
 Group: users
 Group: sudo
-    `
+Banner:
+  +------------------+
+  |Welcome to Debian!|
+  +------------------+
+#`
   );
 
   const pre = h("pre");
diff --git a/lib/idv.ts b/lib/idv.ts
index 2521e82..a656145 100644
--- a/lib/idv.ts
+++ b/lib/idv.ts
@@ -13,14 +13,30 @@ export class Idv {
   }
   static parseLines(input: string[]): Idv {
     const idv = new Idv();
-    let currentDocument: string[] = [];
+    let currentDocument: string[] | undefined = undefined;
+    let currentIndent: string | undefined = undefined;
+    let bufferedBlankLines: string[] = [];
 
     input.forEach((line) => {
       const indent = LEADING_WHITESPACE.exec(line)?.[1];
       if (indent) {
-        // TODO
+        if (currentDocument == undefined) {
+          throw new Error("Indented document found before an entry");
+        }
+        if (currentIndent == undefined) {
+          currentIndent = indent;
+        }
+        if (line.startsWith(currentIndent)) {
+          currentDocument.push(...bufferedBlankLines);
+          bufferedBlankLines = [];
+          currentDocument.push(line.substring(currentIndent.length));
+        } else {
+          throw new Error(
+            "Inconsistent indentation- line indented less than the first line of its document"
+          );
+        }
       } else if (line == "") {
-        // TODO
+        bufferedBlankLines.push("");
       } else if (line[0] == "#") {
         // skip
       } else {
@@ -33,6 +49,7 @@ export class Idv {
           }
 
           currentDocument = [];
+          currentIndent = undefined;
           idv.collections[collection].push([distinguisher, currentDocument]);
         } else {
           throw new Error("Failed to parse a property");
@@ -47,13 +64,14 @@ export class Idv {
     name: string,
     parseDistinguisher: DistinguisherParser<T>,
     parseDocument: DocumentParser<T>
-  ): T | null {
+  ): T | undefined {
     const firstEntry = this.collections[name]?.[0];
-    return firstEntry && firstEntry[1].length > 0
-      ? parseDocument(firstEntry[1])
-      : firstEntry?.[0]
-      ? parseDistinguisher(firstEntry[0])
-      : null;
+    return (
+      firstEntry &&
+      (firstEntry[1].length > 0
+        ? parseDocument(firstEntry[1])
+        : parseDistinguisher(firstEntry[0]))
+    );
   }
 
   public getList<T>(

From 525c6fa954572de3b8766fa7b34f3bb3b614add0 Mon Sep 17 00:00:00 2001
From: Tangent Wantwight <tangent128@gmail.com>
Date: Wed, 10 Jul 2024 00:31:33 -0400
Subject: [PATCH 07/13] impl map helper

---
 debug/idv.ts | 38 +++++++++++++++++++++++++-------------
 lib/idv.ts   | 12 ++++++++++++
 2 files changed, 37 insertions(+), 13 deletions(-)

diff --git a/debug/idv.ts b/debug/idv.ts
index b54354c..de17339 100644
--- a/debug/idv.ts
+++ b/debug/idv.ts
@@ -12,15 +12,21 @@ export function IdvDebug() {
       },
     },
     `# Idv Testing Ground
-Uid: 0
-Shell: tclsh
-Group: users
-Group: sudo
-Banner:
-  +------------------+
-  |Welcome to Debian!|
-  +------------------+
-#`
+User: tangent128
+  Uid: 10000
+  Shell: tclsh
+  Group: users
+  Group: sudo
+  Banner:
+    +------------------+
+    |Welcome to Debian!|
+    +------------------+
+
+User: tirga
+  Uid: 10101
+  Shell: bash
+  Group: users
+`
   );
 
   const pre = h("pre");
@@ -30,10 +36,7 @@ Banner:
       const idv = Idv.parse(textarea.value);
 
       pre.textContent = JSON.stringify(
-        {
-          shell: idv.getProperty("Shell", String, StringFromDocument),
-          groups: idv.getList("Group", String, StringFromDocument),
-        },
+        idv.getMap("User", UserFromDocument),
         null,
         2
       );
@@ -45,3 +48,12 @@ Banner:
 
   return [textarea, pre];
 }
+
+const UserFromDocument = (lines: string[]) => {
+  const idv = Idv.parseLines(lines);
+  return {
+    shell: idv.getProperty("Shell", String, StringFromDocument),
+    groups: idv.getList("Group", String, StringFromDocument),
+    banner: idv.getProperty("Banner", String, StringFromDocument),
+  };
+};
diff --git a/lib/idv.ts b/lib/idv.ts
index a656145..ddccc28 100644
--- a/lib/idv.ts
+++ b/lib/idv.ts
@@ -85,6 +85,18 @@ export class Idv {
         : parseDistinguisher(distinguisher)
     );
   }
+
+  public getMap<T>(
+    name: string,
+    parseDocument: DocumentParser<T>
+  ): Record<string, T> {
+    return Object.fromEntries(
+      (this.collections[name] ?? []).map(([distinguisher, document]) => [
+        distinguisher,
+        parseDocument(document),
+      ])
+    );
+  }
 }
 
 export const StringFromDocument = (lines: string[]) => lines.join("\n");

From dc19a5ed9e1a7e707db5427b6f31298bb2460199 Mon Sep 17 00:00:00 2001
From: Tangent Wantwight <tangent128@gmail.com>
Date: Sun, 14 Jul 2024 21:33:01 -0400
Subject: [PATCH 08/13] Refactor Idv to support merging documents

---
 lib/idv.ts | 23 +++++++++++++++--------
 1 file changed, 15 insertions(+), 8 deletions(-)

diff --git a/lib/idv.ts b/lib/idv.ts
index ddccc28..4db02a6 100644
--- a/lib/idv.ts
+++ b/lib/idv.ts
@@ -8,11 +8,18 @@ export class Idv {
   collections: Record<string, undefined | [string, string[]][]> = {};
 
   public static parse(input: string): Idv {
-    const lines = input.split("\n").map((line) => line.trimEnd());
-    return Idv.parseLines(lines);
-  }
-  static parseLines(input: string[]): Idv {
     const idv = new Idv();
+    return idv.import(input);
+  }
+  public static parseLines(input: string[]): Idv {
+    const idv = new Idv();
+    return idv.importLines(input);
+  }
+  public import(input: string): Idv {
+    const lines = input.split("\n").map((line) => line.trimEnd());
+    return this.importLines(lines);
+  }
+  public importLines(input: string[]): Idv {
     let currentDocument: string[] | undefined = undefined;
     let currentIndent: string | undefined = undefined;
     let bufferedBlankLines: string[] = [];
@@ -44,20 +51,20 @@ export class Idv {
         if (matches) {
           const [, collection, distinguisher] = matches;
 
-          if (idv.collections[collection] == undefined) {
-            idv.collections[collection] = [];
+          if (this.collections[collection] == undefined) {
+            this.collections[collection] = [];
           }
 
           currentDocument = [];
           currentIndent = undefined;
-          idv.collections[collection].push([distinguisher, currentDocument]);
+          this.collections[collection].push([distinguisher, currentDocument]);
         } else {
           throw new Error("Failed to parse a property");
         }
       }
     });
 
-    return idv;
+    return this;
   }
 
   public getProperty<T>(

From 63c41f07cbe3b8249aa41f7b4cf5c0ed3c88eafe Mon Sep 17 00:00:00 2001
From: Tangent Wantwight <tangent128@gmail.com>
Date: Sun, 14 Jul 2024 22:19:26 -0400
Subject: [PATCH 09/13] Implement MergedMap pattern

---
 debug/idv.ts |  5 ++---
 lib/idv.ts   | 25 +++++++++++++++++++++++++
 2 files changed, 27 insertions(+), 3 deletions(-)

diff --git a/debug/idv.ts b/debug/idv.ts
index de17339..7583dbe 100644
--- a/debug/idv.ts
+++ b/debug/idv.ts
@@ -36,7 +36,7 @@ User: tirga
       const idv = Idv.parse(textarea.value);
 
       pre.textContent = JSON.stringify(
-        idv.getMap("User", UserFromDocument),
+        idv.getMergedMap("User", UserFromDocument),
         null,
         2
       );
@@ -49,8 +49,7 @@ User: tirga
   return [textarea, pre];
 }
 
-const UserFromDocument = (lines: string[]) => {
-  const idv = Idv.parseLines(lines);
+const UserFromDocument = (idv: Idv) => {
   return {
     shell: idv.getProperty("Shell", String, StringFromDocument),
     groups: idv.getList("Group", String, StringFromDocument),
diff --git a/lib/idv.ts b/lib/idv.ts
index 4db02a6..b0c753a 100644
--- a/lib/idv.ts
+++ b/lib/idv.ts
@@ -1,5 +1,6 @@
 export type DistinguisherParser<T> = (distinguisher: string) => T;
 export type DocumentParser<T> = (document: string[]) => T;
+export type IdvConverter<T> = (idv: Idv) => T;
 
 const LEADING_WHITESPACE = /^([ \t]+)/;
 const ENTRY = /^(.+?):\s*(.*)/;
@@ -104,6 +105,30 @@ export class Idv {
       ])
     );
   }
+
+  public getMergedMap<T>(
+    name: string,
+    convertIdv: IdvConverter<T>
+  ): Record<string, T> {
+    const idvMap: Map<string, Idv> = new Map();
+
+    (this.collections[name] ?? []).forEach(([distinguisher, document]) => {
+      let idv = idvMap.get(distinguisher);
+      if (idv == undefined) {
+        idvMap.set(distinguisher, (idv = new Idv()));
+      }
+
+      idv.importLines(document);
+    });
+
+    const result: Record<string, T> = {};
+
+    for (const [distinguisher, idv] of idvMap.entries()) {
+      result[distinguisher] = convertIdv(idv);
+    }
+
+    return result;
+  }
 }
 
 export const StringFromDocument = (lines: string[]) => lines.join("\n");

From 6c6c9386be94cccb10b23b431003817972ccb0b6 Mon Sep 17 00:00:00 2001
From: Tangent Wantwight <tangent128@gmail.com>
Date: Fri, 19 Jul 2024 23:41:11 -0400
Subject: [PATCH 10/13] Change signature for Property pattern helpers

---
 debug/idv.ts |  8 ++++----
 lib/idv.ts   | 29 +++++++++--------------------
 2 files changed, 13 insertions(+), 24 deletions(-)

diff --git a/debug/idv.ts b/debug/idv.ts
index 7583dbe..f5447bb 100644
--- a/debug/idv.ts
+++ b/debug/idv.ts
@@ -1,5 +1,5 @@
 import { h } from "../lib/html";
-import { Idv, StringFromDocument } from "../lib/idv";
+import { Idv, StringProperty } from "../lib/idv";
 
 export function IdvDebug() {
   const textarea = h(
@@ -51,8 +51,8 @@ User: tirga
 
 const UserFromDocument = (idv: Idv) => {
   return {
-    shell: idv.getProperty("Shell", String, StringFromDocument),
-    groups: idv.getList("Group", String, StringFromDocument),
-    banner: idv.getProperty("Banner", String, StringFromDocument),
+    shell: idv.getProperty("Shell", StringProperty),
+    groups: idv.getList("Group", StringProperty),
+    banner: idv.getProperty("Banner", StringProperty),
   };
 };
diff --git a/lib/idv.ts b/lib/idv.ts
index b0c753a..c8339f5 100644
--- a/lib/idv.ts
+++ b/lib/idv.ts
@@ -1,5 +1,6 @@
 export type DistinguisherParser<T> = (distinguisher: string) => T;
 export type DocumentParser<T> = (document: string[]) => T;
+export type UnionParser<T> = (distinguisher: string, document: string[]) => T;
 export type IdvConverter<T> = (idv: Idv) => T;
 
 const LEADING_WHITESPACE = /^([ \t]+)/;
@@ -58,6 +59,7 @@ export class Idv {
 
           currentDocument = [];
           currentIndent = undefined;
+          // TODO: implement backslash escaping in the distinguisher
           this.collections[collection].push([distinguisher, currentDocument]);
         } else {
           throw new Error("Failed to parse a property");
@@ -68,29 +70,14 @@ export class Idv {
     return this;
   }
 
-  public getProperty<T>(
-    name: string,
-    parseDistinguisher: DistinguisherParser<T>,
-    parseDocument: DocumentParser<T>
-  ): T | undefined {
+  public getProperty<T>(name: string, parse: UnionParser<T>): T | undefined {
     const firstEntry = this.collections[name]?.[0];
-    return (
-      firstEntry &&
-      (firstEntry[1].length > 0
-        ? parseDocument(firstEntry[1])
-        : parseDistinguisher(firstEntry[0]))
-    );
+    return firstEntry && parse(firstEntry[0], firstEntry[1]);
   }
 
-  public getList<T>(
-    name: string,
-    parseDistinguisher: DistinguisherParser<T>,
-    parseDocument: DocumentParser<T>
-  ): T[] {
+  public getList<T>(name: string, parse: UnionParser<T>): T[] {
     return (this.collections[name] ?? []).map(([distinguisher, document]) =>
-      document.length > 0
-        ? parseDocument(document)
-        : parseDistinguisher(distinguisher)
+      parse(distinguisher, document)
     );
   }
 
@@ -131,4 +118,6 @@ export class Idv {
   }
 }
 
-export const StringFromDocument = (lines: string[]) => lines.join("\n");
+// TODO: implement backslash-escaping in the document?
+export const StringProperty = (distinguisher: string, lines: string[]) =>
+  lines.length == 0 ? distinguisher : lines.join("\n");

From af07866ac0af4acbc69eb898aa94d8195b79c6a3 Mon Sep 17 00:00:00 2001
From: Tangent Wantwight <tangent128@gmail.com>
Date: Sat, 20 Jul 2024 00:08:32 -0400
Subject: [PATCH 11/13] refactor IDV parser to use map why not

---
 lib/idv.ts    | 18 ++++++++++--------
 tsconfig.json |  1 +
 2 files changed, 11 insertions(+), 8 deletions(-)

diff --git a/lib/idv.ts b/lib/idv.ts
index c8339f5..5cf7d2b 100644
--- a/lib/idv.ts
+++ b/lib/idv.ts
@@ -7,7 +7,7 @@ const LEADING_WHITESPACE = /^([ \t]+)/;
 const ENTRY = /^(.+?):\s*(.*)/;
 
 export class Idv {
-  collections: Record<string, undefined | [string, string[]][]> = {};
+  collections: Map<string, undefined | [string, string[]][]> = new Map();
 
   public static parse(input: string): Idv {
     const idv = new Idv();
@@ -53,14 +53,16 @@ export class Idv {
         if (matches) {
           const [, collection, distinguisher] = matches;
 
-          if (this.collections[collection] == undefined) {
-            this.collections[collection] = [];
+          if (!this.collections.has(collection)) {
+            this.collections.set(collection, []);
           }
 
           currentDocument = [];
           currentIndent = undefined;
           // TODO: implement backslash escaping in the distinguisher
-          this.collections[collection].push([distinguisher, currentDocument]);
+          this.collections
+            .get(collection)!
+            .push([distinguisher, currentDocument]);
         } else {
           throw new Error("Failed to parse a property");
         }
@@ -71,12 +73,12 @@ export class Idv {
   }
 
   public getProperty<T>(name: string, parse: UnionParser<T>): T | undefined {
-    const firstEntry = this.collections[name]?.[0];
+    const firstEntry = this.collections.get(name)?.[0];
     return firstEntry && parse(firstEntry[0], firstEntry[1]);
   }
 
   public getList<T>(name: string, parse: UnionParser<T>): T[] {
-    return (this.collections[name] ?? []).map(([distinguisher, document]) =>
+    return (this.collections.get(name) ?? []).map(([distinguisher, document]) =>
       parse(distinguisher, document)
     );
   }
@@ -86,7 +88,7 @@ export class Idv {
     parseDocument: DocumentParser<T>
   ): Record<string, T> {
     return Object.fromEntries(
-      (this.collections[name] ?? []).map(([distinguisher, document]) => [
+      (this.collections.get(name) ?? []).map(([distinguisher, document]) => [
         distinguisher,
         parseDocument(document),
       ])
@@ -99,7 +101,7 @@ export class Idv {
   ): Record<string, T> {
     const idvMap: Map<string, Idv> = new Map();
 
-    (this.collections[name] ?? []).forEach(([distinguisher, document]) => {
+    (this.collections.get(name) ?? []).forEach(([distinguisher, document]) => {
       let idv = idvMap.get(distinguisher);
       if (idv == undefined) {
         idvMap.set(distinguisher, (idv = new Idv()));
diff --git a/tsconfig.json b/tsconfig.json
index 41fc1da..d1ec214 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -3,6 +3,7 @@
     "isolatedModules": true,
     "lib": ["ES2019", "DOM"],
     "strict": true,
+    "target": "ES2020",
     "noEmit": true
   }
 }

From 763bfbc8cf20ad50169b599800cee5b8dd995684 Mon Sep 17 00:00:00 2001
From: Tangent Wantwight <tangent128@gmail.com>
Date: Sat, 20 Jul 2024 00:52:21 -0400
Subject: [PATCH 12/13] Record first set of IDV spec thoughts

---
 idv.md | 83 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 83 insertions(+)
 create mode 100644 idv.md

diff --git a/idv.md b/idv.md
new file mode 100644
index 0000000..904a32d
--- /dev/null
+++ b/idv.md
@@ -0,0 +1,83 @@
+# The Indented Document Values Format
+
+## Overview
+
+The Indented Document Values (IDV) format is a text-based, whitespace-sensitive serialization format.
+
+IDV is designed to prioritize human readability and writability by minimizing visual noise- there are no sigils, quotes, or brackets, only colons, indentation, and (when necessary) backslash escapes.
+
+As a tradeoff, IDV is not a self-describing data format- you have to know what type of data an IDV document represents at the time you parse it.
+
+### Example
+
+> TODO: need something both concise and nontrivial. LDAP user data is certainly an option
+
+## Syntax
+
+IDV is a line-oriented format. Before any other parsing is done, the input is split into lines, and any trailing whitespace on a line (including line separators) is ignored.
+
+> TODO: possible redraft: sequence of comments, entry headers, and documents, defined by line types (blank, comment, entry header, indented)
+
+The lines of an IDV document represent a single flat list of Comments and Entries.
+
+A **Comment** is any line whose first character is a `#` character. Comment lines are for human use and are ignored by the parser.
+
+```
+# This line is ignored
+```
+
+An **Entry**'s first line is unindented and contains the name of a **Category**, up to the first `:` character, followed by a **Distinguisher**. All following lines with indentation, if any, are the entry's **Document**:
+
+```
+Collection: distinguisher
+  Indented
+  document
+
+  with a blank line
+```
+
+1. The Category and Distinguisher are both trimmed of surrounding whitespace before being interpreted, but internal whitespace is left intact.
+1. Backslash unescaping is performed on the Category and Distinguisher.
+1. The Distinguisher may contain literal colons; these are treated as regular characters and carry no special meaning.
+1. The first line of a Document defines the document's indentation- subsequent lines can be indented deeper, but no line may be indented _less_ than the first line.
+1. It is ambiguous whether blank lines are part of a document or just aesthetic spacing for Entries; to resolve this, blank lines before and after a Document are ignored, but internal blank lines are considered part of the Document.
+1. Backslash unescaping is **not** performed on the Document. However, backslashes may be processed later, when the document is interpreted.
+
+## Data Model
+
+> TODO: tuples, can be interpreted according to patterns
+
+## Patterns
+
+### Primitive Property
+
+> TODO: one of distinguisher | document non-empty, parsing based on expected type
+
+### Object Property
+
+> TODO: distinguisher ignored, document is IDV
+
+### Union Property
+
+> TODO: distinguisher determines how the document is parsed
+
+### List
+
+> TODO: property specified multiple times
+
+### Map
+
+> TODO: distinguisher defines key, document parsed for value
+
+### Property Map
+
+> TODO: Category defines key, parsed as property for value
+
+### Merged Map
+
+## See Also
+
+> TODO:
+>
+> - yaml
+> - dpkg control files

From 924d8ccf48277629a3544e415f610f2354417196 Mon Sep 17 00:00:00 2001
From: Tangent Wantwight <tangent128@gmail.com>
Date: Tue, 6 Aug 2024 21:19:45 -0400
Subject: [PATCH 13/13] Revisions to IDV spec

---
 idv.md | 121 ++++++++++++++++++++++++++++++++++++++++++++++++---------
 1 file changed, 103 insertions(+), 18 deletions(-)

diff --git a/idv.md b/idv.md
index 904a32d..9ad7de1 100644
--- a/idv.md
+++ b/idv.md
@@ -2,23 +2,36 @@
 
 ## Overview
 
-The Indented Document Values (IDV) format is a text-based, whitespace-sensitive serialization format.
+The Indented Document Values (IDV) format is a meta-syntax for machine-readable textual data.
 
 IDV is designed to prioritize human readability and writability by minimizing visual noise- there are no sigils, quotes, or brackets, only colons, indentation, and (when necessary) backslash escapes.
 
-As a tradeoff, IDV is not a self-describing data format- you have to know what type of data an IDV document represents at the time you parse it.
+As a tradeoff, IDV is not a self-describing data format- while it can be used for defining a serialization or configuration format, systems using it need to layer their own semantics on top of it.
 
 ### Example
 
-> TODO: need something both concise and nontrivial. LDAP user data is certainly an option
+```
+Person: Alice
+  Uid: 1000
+  Phone: 555-1234
+  Group: users
+  Group: sudo
+  Banner:
+    ============================
+    This is my ASCII art login message
+    ============================
+
+Person: Bob
+  Uid: 1001
+  Phone: 555-5656
+  Group: users
+```
 
 ## Syntax
 
 IDV is a line-oriented format. Before any other parsing is done, the input is split into lines, and any trailing whitespace on a line (including line separators) is ignored.
 
-> TODO: possible redraft: sequence of comments, entry headers, and documents, defined by line types (blank, comment, entry header, indented)
-
-The lines of an IDV document represent a single flat list of Comments and Entries.
+### Comments
 
 A **Comment** is any line whose first character is a `#` character. Comment lines are for human use and are ignored by the parser.
 
@@ -26,26 +39,98 @@ A **Comment** is any line whose first character is a `#` character. Comment line
 # This line is ignored
 ```
 
-An **Entry**'s first line is unindented and contains the name of a **Category**, up to the first `:` character, followed by a **Distinguisher**. All following lines with indentation, if any, are the entry's **Document**:
+### Blank Lines
+
+A **Blank Line** is any line that only contains whitespace. Because trailing whitespace is always trimmed, all Blank Lines are indistinguishable from each other.
+
+Blank Lines are ignored unless they are part of a Document. (see below)
+
+### Entries
+
+An **Entry** is composed of one or more lines:
+
+#### Tags
+
+Each entry begins with a **Tag**, terminated by a colon (`:`). A Tag can contain any characters except leading or trailing whitespace, newlines, and colons:
 
 ```
-Collection: distinguisher
-  Indented
-  document
-
-  with a blank line
+Tag:
 ```
 
-1. The Category and Distinguisher are both trimmed of surrounding whitespace before being interpreted, but internal whitespace is left intact.
-1. Backslash unescaping is performed on the Category and Distinguisher.
+#### Distinguishers
+
+Optionally, a Distinguisher can follow the Tag on the same line. A Distinguisher can contain any characters except leading or trailing whitespace, and newlines:
+
+```
+Tag: distinguisher
+```
+
+#### Escapes
+
+Within Tags and Distinguishers, backslash escapes may be used to represent non-permitted or inconvenient characters:
+
+```
+Tag With \: And Spaces:
+
+Tag: \ distinguisher with leading whitespace and\nA newline
+```
+
+| Escape sequence | Replacement       |
+| --------------- | ----------------- |
+| \\_\<space>_    | A literal space   |
+| \\n             | A newline         |
+| \\:             | A colon (`:`)     |
+| \\\\            | A backslash (`\`) |
+
+> TODO: additional escapes? ie, hex or unicode?
+
+#### Documents
+
+After the first line of an entry, any indented lines make up the **Document** portion of the entry:
+
+```
+Tag: distinguisher
+  First Line
+    Second Line
+  Third Line
+```
+
+The first line of a Document defines the Document's indentation- subsequent lines can be indented deeper, but no line may be indented _less_ than the first line. This indentation is removed from the beginning of each line when determining the Document's value.
+
+Blank Lines can not carry indentation information. To resolve this ambiguity, Documents may not begin or end with Blank Lines- such lines are ignored. Blank Lines that occur _between_ indented lines _are_ considered part of the Document.
+
+```
+Tag:
+
+  The above blank line is ignored.
+  The below blank line is part of the Document.
+
+  The below blank line is ignored.
+
+Tag:
+  Other stuff
+```
+
+Backslash escapes are _not_ processed within a Document. However, backslashes may be processed later, by higher-layered semantics.
+
+In many cases the Document will contain recursive IDV data, and the rules above are designed to play nicely with this case- but it is up to the concrete format to decide how to parse the Document. It could just as easily contain free text, XML, or a base64 blob.
+
+#### Disambiguations:
+
+1. The Tag and Distinguisher are both trimmed of surrounding whitespace before being interpreted, but internal whitespace is left intact.
 1. The Distinguisher may contain literal colons; these are treated as regular characters and carry no special meaning.
-1. The first line of a Document defines the document's indentation- subsequent lines can be indented deeper, but no line may be indented _less_ than the first line.
-1. It is ambiguous whether blank lines are part of a document or just aesthetic spacing for Entries; to resolve this, blank lines before and after a Document are ignored, but internal blank lines are considered part of the Document.
-1. Backslash unescaping is **not** performed on the Document. However, backslashes may be processed later, when the document is interpreted.
 
 ## Data Model
 
-> TODO: tuples, can be interpreted according to patterns
+Applying minimal interpretation, IDV data can be represented as a list of Entries.
+
+An Entry can be represented as a 3-tuple of:
+
+1. a string (the Tag)
+2. a string (the optional Distinguisher)
+3. a list of strings (the lines of the Document)
+
+How Entries are interpreted by the appication is not specified, but see below for some suggested patterns that should line up with things people usually want to do.
 
 ## Patterns