brane 0.3.2 → 0.3.3
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/CHANGELOG.md +21 -0
- package/README.md +7 -19
- package/dist/export.js +1 -1
- package/dist/markdown-diagnostics.js +1 -1
- package/dist/math-syntax.js +354 -0
- package/dist/shared/membrane-quips.js +77 -0
- package/dist/shared/mo-glyph.js +4 -0
- package/dist/shared/view-url.js +64 -2
- package/dist/test/markdown-math.test.js +1 -1
- package/dist/test/membrane-quips.test.js +62 -0
- package/dist/test/mo-glyph.test.js +3 -1
- package/dist/test/view-url.test.js +106 -1
- package/docs/math.md +3 -0
- package/docs/view-urls.md +36 -3
- package/expo/dist/_expo/static/js/web/{entry-807575a48b8a2f18f8bffe53c8b35fbd.js → entry-dd90cc09dc13952c6fa96ce25b41c64d.js} +28 -27
- package/expo/dist/index.html +1 -1
- package/expo/dist/sw.js +2 -2
- package/package.json +5 -2
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { describe, it } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { QuipLoop, shuffled } from "../shared/membrane-quips.js";
|
|
4
|
+
// The caption's order: one shuffle per page load, then a fixed loop through
|
|
5
|
+
// it — every line once before any line twice, the same order each time round.
|
|
6
|
+
const list = ["a", "b", "c", "d", "e"];
|
|
7
|
+
/** A seeded generator, so a "random" shuffle is the same on every run. */
|
|
8
|
+
function seeded(seed) {
|
|
9
|
+
return () => {
|
|
10
|
+
seed = (seed * 1103515245 + 12345) % 2 ** 31;
|
|
11
|
+
return seed / 2 ** 31;
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
describe("shuffled", () => {
|
|
15
|
+
it("is a permutation, and leaves its input alone", () => {
|
|
16
|
+
const before = [...list];
|
|
17
|
+
const out = shuffled(list, seeded(7));
|
|
18
|
+
assert.deepEqual([...out].sort(), before);
|
|
19
|
+
assert.deepEqual(list, before);
|
|
20
|
+
});
|
|
21
|
+
it("can produce every order", () => {
|
|
22
|
+
const seen = new Set();
|
|
23
|
+
for (let s = 1; s < 2000; s++)
|
|
24
|
+
seen.add(shuffled(list.slice(0, 3), seeded(s)).join(""));
|
|
25
|
+
assert.equal(seen.size, 6);
|
|
26
|
+
});
|
|
27
|
+
it("clamps a rand that returns 1", () => {
|
|
28
|
+
assert.equal(shuffled(list, () => 1).length, list.length);
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
describe("QuipLoop", () => {
|
|
32
|
+
it("shows every line once per cycle, and the same cycle each time round", () => {
|
|
33
|
+
const loop = new QuipLoop(list, seeded(3));
|
|
34
|
+
const first = [loop.current()];
|
|
35
|
+
for (let i = 1; i < list.length; i++)
|
|
36
|
+
first.push(loop.next());
|
|
37
|
+
assert.equal(new Set(first).size, list.length);
|
|
38
|
+
const second = [loop.next()];
|
|
39
|
+
for (let i = 1; i < list.length; i++)
|
|
40
|
+
second.push(loop.next());
|
|
41
|
+
assert.deepEqual(second, first);
|
|
42
|
+
});
|
|
43
|
+
it("never shows the same line twice in a row", () => {
|
|
44
|
+
const loop = new QuipLoop(list, seeded(11));
|
|
45
|
+
let prev = loop.current();
|
|
46
|
+
for (let i = 0; i < list.length * 3; i++) {
|
|
47
|
+
const q = loop.next();
|
|
48
|
+
assert.notEqual(q, prev);
|
|
49
|
+
prev = q;
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
it("does not move until told to", () => {
|
|
53
|
+
const loop = new QuipLoop(list, seeded(5));
|
|
54
|
+
assert.equal(loop.current(), loop.current());
|
|
55
|
+
});
|
|
56
|
+
it("holds a single line", () => {
|
|
57
|
+
const one = ["solo"];
|
|
58
|
+
const loop = new QuipLoop(one);
|
|
59
|
+
assert.equal(loop.next(), one[0]);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
//# sourceMappingURL=membrane-quips.test.js.map
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { test } from "node:test";
|
|
2
2
|
import assert from "node:assert/strict";
|
|
3
|
-
import { MO_GLYPH_BOX, MO_GLYPH_PATH } from "../shared/mo-glyph.js";
|
|
3
|
+
import { MO_GLYPH_BOX, MO_GLYPH_MARGIN, MO_GLYPH_PATH } from "../shared/mo-glyph.js";
|
|
4
4
|
test("mo-glyph: closed subpaths, absolute commands only, every point inside the box", () => {
|
|
5
5
|
assert.match(MO_GLYPH_PATH, /^M/);
|
|
6
6
|
assert.match(MO_GLYPH_PATH, /Z$/);
|
|
@@ -18,5 +18,7 @@ test("mo-glyph: closed subpaths, absolute commands only, every point inside the
|
|
|
18
18
|
const mid = MO_GLYPH_BOX / 2;
|
|
19
19
|
assert.ok(Math.abs(Math.min(...xs) + Math.max(...xs) - 2 * mid) < 2);
|
|
20
20
|
assert.ok(Math.abs(Math.min(...ys) + Math.max(...ys) - 2 * mid) < 2);
|
|
21
|
+
// The caption under the glyph is spaced by this (MembraneEmpty.web.tsx).
|
|
22
|
+
assert.ok(Math.abs(Math.min(...ys) - MO_GLYPH_MARGIN) < 0.01, `ink top is ${Math.min(...ys)}`);
|
|
21
23
|
});
|
|
22
24
|
//# sourceMappingURL=mo-glyph.test.js.map
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, it } from "node:test";
|
|
2
2
|
import assert from "node:assert/strict";
|
|
3
|
-
import { CANONICAL_TABS, isIsolated, parseActiveTab, parsePinnedTabs, serializePinned, viewParams, parseLineSpec, buildIsolateUrl, buildMeldUrl, terminalOnlyHref, withoutConnectionHash, withoutConnectionParams, connectionFragment, } from "../shared/view-url.js";
|
|
3
|
+
import { CANONICAL_TABS, isIsolated, parseActiveTab, parsePinnedTabs, serializePinned, viewParams, parseLineSpec, buildIsolateUrl, buildMeldUrl, terminalOnlyHref, withoutConnectionHash, withoutConnectionParams, connectionFragment, withDaemonFragment, daemonFromHash, activeDaemonBraneId, daemonToAdopt, } from "../shared/view-url.js";
|
|
4
4
|
// These pin the URL contract that the Isolate menu item depends on. It opens
|
|
5
5
|
// the same narrative on the *other* origin — a shared app, or a daemon's own
|
|
6
6
|
// bundle — and the tab layout and open file live in sessionStorage, which is
|
|
@@ -219,6 +219,9 @@ describe("withoutConnectionHash", () => {
|
|
|
219
219
|
["#other=1&token=t", "#other=1"],
|
|
220
220
|
["host=h:1&token=t", ""],
|
|
221
221
|
["#token=a&token=b&x=1", "#x=1"],
|
|
222
|
+
// `daemon` names which daemon too, so it is consumed like the token.
|
|
223
|
+
["#daemon=brane-f66d2b", ""],
|
|
224
|
+
["#host=h:1&daemon=brane-f66d2b&token=t&x=1", "#x=1"],
|
|
222
225
|
];
|
|
223
226
|
for (const [input, expected] of cases) {
|
|
224
227
|
assert.equal(withoutConnectionHash(input), expected, JSON.stringify(input));
|
|
@@ -624,4 +627,106 @@ describe("terminalOnlyHref", () => {
|
|
|
624
627
|
assert.equal(parsePinnedTabs(url.searchParams.get("pinned") ?? undefined).size, 0);
|
|
625
628
|
});
|
|
626
629
|
});
|
|
630
|
+
describe("withDaemonFragment", () => {
|
|
631
|
+
// A link opened in a new tab arrives with no active daemon in that tab's
|
|
632
|
+
// sessionStorage; the fragment is how it finds the one the link was written
|
|
633
|
+
// from. So the question each case asks: is the daemon named, in the
|
|
634
|
+
// fragment, and is the view the href's query already described left exactly
|
|
635
|
+
// as it was?
|
|
636
|
+
const BRANE = "brane-f66d2b";
|
|
637
|
+
it("names the daemon in the fragment of a bare path", () => {
|
|
638
|
+
assert.equal(withDaemonFragment("/", BRANE), `/#daemon=${BRANE}`);
|
|
639
|
+
assert.equal(withDaemonFragment(`/${NID}`, BRANE), `/${NID}#daemon=${BRANE}`);
|
|
640
|
+
});
|
|
641
|
+
it("leaves the query, the view, exactly as it was", () => {
|
|
642
|
+
const href = `/${NID}?tab=files&path=src%2Fa.ts&line=10-20`;
|
|
643
|
+
const url = new URL(withDaemonFragment(href, BRANE), "https://example.test");
|
|
644
|
+
assert.equal(url.pathname + url.search, href);
|
|
645
|
+
assert.equal(url.searchParams.get("daemon"), null);
|
|
646
|
+
assert.equal(daemonFromHash(url.hash), BRANE);
|
|
647
|
+
});
|
|
648
|
+
it("replaces a daemon already there, and keeps any other fragment param", () => {
|
|
649
|
+
const url = new URL(withDaemonFragment(`/${NID}?scrollToSeq=4#x=1&daemon=old`, BRANE), "https://example.test");
|
|
650
|
+
const frag = new URLSearchParams(url.hash.slice(1));
|
|
651
|
+
assert.deepEqual(frag.getAll("daemon"), [BRANE]);
|
|
652
|
+
assert.equal(frag.get("x"), "1");
|
|
653
|
+
assert.equal(url.searchParams.get("scrollToSeq"), "4");
|
|
654
|
+
});
|
|
655
|
+
it("returns the href unchanged when the daemon is not known", () => {
|
|
656
|
+
for (const none of [null, undefined, ""]) {
|
|
657
|
+
assert.equal(withDaemonFragment(`/${NID}?scrollToSeq=4`, none), `/${NID}?scrollToSeq=4`);
|
|
658
|
+
}
|
|
659
|
+
});
|
|
660
|
+
it("reads back through daemonFromHash, and is what the fragment cleaner removes", () => {
|
|
661
|
+
const url = new URL(withDaemonFragment(`/${NID}?scrollToSeq=4`, BRANE), "https://example.test");
|
|
662
|
+
assert.equal(daemonFromHash(url.hash), BRANE);
|
|
663
|
+
assert.equal(daemonFromHash(url.hash.slice(1)), BRANE);
|
|
664
|
+
assert.equal(withoutConnectionHash(url.hash), "");
|
|
665
|
+
assert.equal(withoutConnectionParams(url.search), "?scrollToSeq=4");
|
|
666
|
+
});
|
|
667
|
+
it("is absent from daemonFromHash when missing or empty", () => {
|
|
668
|
+
assert.equal(daemonFromHash(""), null);
|
|
669
|
+
assert.equal(daemonFromHash("#"), null);
|
|
670
|
+
assert.equal(daemonFromHash("#daemon="), null);
|
|
671
|
+
assert.equal(daemonFromHash("#token=t"), null);
|
|
672
|
+
});
|
|
673
|
+
it("is not carried by Isolate or Meld, whose token already names the daemon", () => {
|
|
674
|
+
// Isolate and Meld seed from the page's search string; a stray `daemon`
|
|
675
|
+
// there (an old link) must not ride along in either half of the URL.
|
|
676
|
+
const opts = {
|
|
677
|
+
host: SHARED_SPA_HOST,
|
|
678
|
+
protocol: "https:",
|
|
679
|
+
search: `?scrollToSeq=4&daemon=${BRANE}`,
|
|
680
|
+
path: `/${NID}`,
|
|
681
|
+
conn: CONN,
|
|
682
|
+
view: viewParams("chat", new Set()),
|
|
683
|
+
};
|
|
684
|
+
for (const built of [buildIsolateUrl(opts), buildMeldUrl(opts)]) {
|
|
685
|
+
const { params, conn } = open(built);
|
|
686
|
+
assert.equal(params.get("daemon"), null, built);
|
|
687
|
+
assert.equal(conn.get("daemon"), null, built);
|
|
688
|
+
assert.equal(params.get("scrollToSeq"), "4", built);
|
|
689
|
+
assert.equal(conn.get("token"), CONN.token, built);
|
|
690
|
+
}
|
|
691
|
+
});
|
|
692
|
+
});
|
|
693
|
+
describe("the daemon a tab names, and the one it adopts", () => {
|
|
694
|
+
// The registry entries these read, as `expo/lib/registry.ts` stores them:
|
|
695
|
+
// `id` is the display key the tab's slot holds, `brane_id` what a link names.
|
|
696
|
+
const CHALK = { id: "ben@chalk", brane_id: "brane-aaaaaa" };
|
|
697
|
+
const SLATE = { id: "ben@slate", brane_id: "brane-bbbbbb" };
|
|
698
|
+
const BOTH = [CHALK, SLATE];
|
|
699
|
+
it("a link names the tab's active daemon", () => {
|
|
700
|
+
assert.equal(activeDaemonBraneId(BOTH, SLATE.id), SLATE.brane_id);
|
|
701
|
+
assert.equal(activeDaemonBraneId(BOTH, CHALK.id), CHALK.brane_id);
|
|
702
|
+
});
|
|
703
|
+
it("falls back to the first entry when the active id is missing or stale, as the layout connects", () => {
|
|
704
|
+
assert.equal(activeDaemonBraneId(BOTH, null), CHALK.brane_id);
|
|
705
|
+
assert.equal(activeDaemonBraneId(BOTH, "ben@gone"), CHALK.brane_id);
|
|
706
|
+
});
|
|
707
|
+
it("names nothing with nothing melded, so the link stays bare", () => {
|
|
708
|
+
assert.equal(activeDaemonBraneId([], null), null);
|
|
709
|
+
assert.equal(withDaemonFragment("/", activeDaemonBraneId([], null)), "/");
|
|
710
|
+
});
|
|
711
|
+
it("a new tab, whose slot is empty, adopts the daemon its link named", () => {
|
|
712
|
+
assert.equal(daemonToAdopt(BOTH, null, SLATE.brane_id), SLATE.id);
|
|
713
|
+
});
|
|
714
|
+
it("a tab that already has a daemon keeps it, so a reload never undoes a switch", () => {
|
|
715
|
+
// The router restores the initial fragment on its first URL write, so a
|
|
716
|
+
// tab opened on SLATE and later switched to CHALK can still say SLATE.
|
|
717
|
+
assert.equal(daemonToAdopt(BOTH, CHALK.id, SLATE.brane_id), null);
|
|
718
|
+
assert.equal(daemonToAdopt(BOTH, SLATE.id, SLATE.brane_id), null);
|
|
719
|
+
});
|
|
720
|
+
it("a daemon this browser has not melded is no instruction at all", () => {
|
|
721
|
+
assert.equal(daemonToAdopt(BOTH, null, "brane-cccccc"), null);
|
|
722
|
+
assert.equal(daemonToAdopt([], null, SLATE.brane_id), null);
|
|
723
|
+
});
|
|
724
|
+
it("round-trips: what one tab names, a fresh tab adopts", () => {
|
|
725
|
+
for (const from of BOTH) {
|
|
726
|
+
const href = withDaemonFragment(`/${NID}`, activeDaemonBraneId(BOTH, from.id));
|
|
727
|
+
const named = daemonFromHash(new URL(href, "https://example.test").hash);
|
|
728
|
+
assert.equal(daemonToAdopt(BOTH, null, named), from.id);
|
|
729
|
+
}
|
|
730
|
+
});
|
|
731
|
+
});
|
|
627
732
|
//# sourceMappingURL=view-url.test.js.map
|
package/docs/math.md
CHANGED
|
@@ -7,6 +7,7 @@ LaTeX in narrative markdown renders through **KaTeX**, in the app and in the HTM
|
|
|
7
7
|
| `renderMathHtml()` | `src/shared/markdown-math.ts` | The one KaTeX call. `htmlAndMathml`; unparseable LaTeX falls back to the literal source, not KaTeX's red error markup |
|
|
8
8
|
| `prepareMathMarkdown()` | `src/shared/markdown-math.ts` | Pre-parse normalization, run by both sides before micromark/mdast |
|
|
9
9
|
| `mathHtmlExtension()`, `katexStyleFor()` | `src/math.ts` | Export only: micromark hookup, and the inlined stylesheet |
|
|
10
|
+
| `math()` | `src/math-syntax.ts` | Server only: the `$` syntax extension, vendored from `micromark-extension-math` (see below) |
|
|
10
11
|
| `public/katex/` | `expo/` | The app's stylesheet + woff2, vendored by `npm run sync:katex` |
|
|
11
12
|
| `diagnoseMarkdown()` | `src/markdown-diagnostics.ts` | The same pipeline run to *report* rather than render — see [Rendering Warnings](rendering-warnings.md) |
|
|
12
13
|
|
|
@@ -29,4 +30,6 @@ Two more things are less obvious than they look:
|
|
|
29
30
|
|
|
30
31
|
**Fonts don't come along by themselves.** KaTeX lays its span tree out entirely in CSS and needs its woff2 faces; without them math is a pile of overlapping glyphs. The export inlines only the families the document reaches for (~166 KB of base64 for an equation-dense narrative, nothing at all for one with no math), derived from the stylesheet so a KaTeX upgrade can't silently drop a face. The app can't inline per document, so it serves `public/katex/` — vendored the way `pdf.min.mjs` already is, because metro bundles a CSS *import* but never copies the `url(fonts/…)` it points at. The `<link>` lives in **`public/index.html`**, which is the real HTML template; `app/+html.tsx` is kept in step but is not what ships.
|
|
31
32
|
|
|
33
|
+
**The server vendors the syntax half of `micromark-extension-math`.** That package's index loads its own `mathHtml()` too, and with it `katex` at the range it pins (`^0.16.0`). Root `overrides` dedupe that onto our katex in a checkout, but npm ignores a dependency's `overrides`, so every `npm install brane` got a second, older katex — never called, since `src/math.ts` is our `mathHtml`, but loaded, and flagged by `npm audit`. `src/math-syntax.ts` is upstream 3.1.0's tokenizers with TypeScript annotations and nothing else changed (80,000 fuzzed parses matched upstream event for event); the package is no longer a root dependency. The app still imports the package: it ships prebuilt, built under `expo/`'s own overrides. If upstream ever changes how it tokenizes, the two parsers can drift, so carry the change into `src/math-syntax.ts` by hand.
|
|
34
|
+
|
|
32
35
|
`src/shared/markdown-math.ts` is also the first shared module to import an npm package. Metro resolves a module's dependencies from its own directory and `src/shared` sits outside the expo project root, so `metro.config.js` maps `katex` explicitly — the next shared module with a bare import will need the same.
|
package/docs/view-urls.md
CHANGED
|
@@ -56,9 +56,15 @@ pins, Files path and line, the `?scrollToSeq=` selection) goes through
|
|
|
56
56
|
since the stack did not grow. So the URL keeps that state and Back returns to
|
|
57
57
|
it, but it never costs an extra Back: browsing ten files is one entry, not ten.
|
|
58
58
|
A bare link to the narrative you are in, with no seq, goes nowhere. The list
|
|
59
|
-
is a jump too:
|
|
60
|
-
to the narrative, whether or not a list was
|
|
61
|
-
|
|
59
|
+
is a jump too: a swipe down on the narrative's header card dismisses it to
|
|
60
|
+
a pushed list, so Back returns to the narrative, whether or not a list was
|
|
61
|
+
already beneath. **All narratives** in the switcher, in a browser, does not
|
|
62
|
+
navigate this tab at all: it is a link that opens the list in a new one, on
|
|
63
|
+
the same daemon (see "A new tab names its daemon" below). On native, and in
|
|
64
|
+
the web app launched from the home screen, it pushes like the swipe: there is
|
|
65
|
+
no tab to open, and on iOS a `_blank` link from a home-screen app lands in a
|
|
66
|
+
browser view that shares none of its storage, so it would open on the meld
|
|
67
|
+
screen. So does
|
|
62
68
|
**Archive** in the narrative's 🦠 menu, once the daemon has confirmed it (the
|
|
63
69
|
pushed list fetches on mount, and nothing is broadcast to correct a fetch that
|
|
64
70
|
raced the archive); Back returns to the archived narrative. Archiving from the
|
|
@@ -137,3 +143,30 @@ come back across an in-app card tap and two tab changes. So the module-level
|
|
|
137
143
|
consume is what gets the credentials into storage early, `clearMeldUrl` is
|
|
138
144
|
what actually clears the address bar, and `clearStoredConnection` still ends
|
|
139
145
|
the flow.
|
|
146
|
+
|
|
147
|
+
**A new tab names its daemon.** Which melded daemon a tab is on lives in that
|
|
148
|
+
tab's `sessionStorage` (`expo/lib/registry.ts`), and a tab opened from a link
|
|
149
|
+
starts with none: ⌘-click, middle-click, long-press "Open in New Tab" and
|
|
150
|
+
`target="_blank"` are all `noopener`, which gets no copy. It falls back to
|
|
151
|
+
this browser's *default* daemon, which on a shared app with several melded
|
|
152
|
+
need not be the one the link came from. So every in-app anchor names it:
|
|
153
|
+
`inAppLinkProps` (the `@nid`, `nid:seq` and file links in prose, the narrate
|
|
154
|
+
rows in tool groups) and `newTabLinkProps` (**All narratives**) render their
|
|
155
|
+
href with `#daemon=<home brane_id>` (`DAEMON_PARAM`, `withDaemonFragment`),
|
|
156
|
+
while a plain click still navigates in place by the clean path. It goes in the
|
|
157
|
+
fragment because it says which daemon, not what you are looking at: it is a
|
|
158
|
+
connection param (`CONNECTION_PARAMS`), and like the token it never reaches the
|
|
159
|
+
shared host's edge, a daemon's request log or a link unfurler. On arrival,
|
|
160
|
+
`consumeConnectionFromLocation` in `expo/lib/api.ts` hands it to
|
|
161
|
+
`adoptDaemonByBraneId` at module evaluation, before the root layout reads the
|
|
162
|
+
registry, and cleans it off with the token. The rules are pure functions
|
|
163
|
+
beside the param (`activeDaemonBraneId`, `daemonToAdopt`), tested in
|
|
164
|
+
`src/test/view-url.test.ts`; `registry.ts` only reads and writes storage for
|
|
165
|
+
them. Adoption fills only an **empty** slot. The router restores the initial fragment on its first URL write (see
|
|
166
|
+
above), so the param can sit in the address bar after a daemon
|
|
167
|
+
switched later in that tab, and a reload must not switch back; a tab that has
|
|
168
|
+
a daemon, reloaded, restored or duplicated, keeps it. A brane_id this browser
|
|
169
|
+
has not melded is ignored, and the default applies. It is `daemon`, not
|
|
170
|
+
`brane`, because one daemon hosts several branes and the list already filters
|
|
171
|
+
by one. It names a daemon and carries no credential, so it is safe to copy.
|
|
172
|
+
Isolate and Meld never carry it: their token already picks the daemon.
|