functionalscript 0.46.1 → 0.47.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.
Files changed (178) hide show
  1. package/fjs/basen/cbase32/module.f.mjs +18 -10
  2. package/fjs/basen/cbase32/proof.f.d.mts +3 -0
  3. package/fjs/basen/cbase32/proof.f.mjs +24 -2
  4. package/fjs/basen/proof.f.mjs +8 -16
  5. package/fjs/bnf/ll1/proof.f.d.mts +2 -0
  6. package/fjs/bnf/ll1/proof.f.mjs +33 -18
  7. package/fjs/bnf/module.f.d.mts +2 -2
  8. package/fjs/bnf/module.f.mjs +2 -2
  9. package/fjs/cas/proof.f.mjs +4 -26
  10. package/fjs/ci/common/module.f.mjs +6 -0
  11. package/fjs/ci/config/module.f.d.mts +7 -7
  12. package/fjs/ci/config/module.f.mjs +7 -7
  13. package/fjs/cli/module.f.mjs +14 -10
  14. package/fjs/crypto/vdf/module.f.d.mts +0 -1
  15. package/fjs/crypto/vdf/module.f.mjs +3 -14
  16. package/fjs/dev/module.f.mjs +10 -20
  17. package/fjs/djs/parser/module.f.d.mts +101 -53
  18. package/fjs/djs/parser/module.f.mjs +732 -525
  19. package/fjs/djs/parser/proof.f.d.mts +6 -0
  20. package/fjs/djs/parser/proof.f.mjs +315 -11
  21. package/fjs/djs/parser/types.d.ts +29 -2
  22. package/fjs/djs/tokenizer/module.f.mjs +122 -105
  23. package/fjs/djs/tokenizer/proof.f.mjs +17 -0
  24. package/fjs/djs/transpiler/module.f.mjs +4 -5
  25. package/fjs/djs/transpiler/proof.f.d.mts +2 -0
  26. package/fjs/djs/transpiler/proof.f.mjs +25 -0
  27. package/fjs/edag/amnesia/module.f.d.mts +20 -0
  28. package/fjs/edag/amnesia/module.f.mjs +303 -0
  29. package/fjs/edag/amnesia/proof.f.d.mts +79 -0
  30. package/fjs/edag/amnesia/proof.f.mjs +573 -0
  31. package/fjs/edag/amnesia/types.d.ts +15 -0
  32. package/fjs/edag/module.f.d.mts +410 -0
  33. package/fjs/edag/module.f.mjs +559 -0
  34. package/fjs/edag/proof.f.d.mts +116 -0
  35. package/fjs/edag/proof.f.mjs +686 -0
  36. package/fjs/edag/types.d.ts +55 -0
  37. package/fjs/effects/node/memory/module.d.mts +37 -3
  38. package/fjs/effects/node/memory/module.mjs +38 -4
  39. package/fjs/effects/node/memory/proof.d.mts +5 -2
  40. package/fjs/effects/node/memory/proof.mjs +36 -6
  41. package/fjs/effects/node/module.d.mts +9 -1
  42. package/fjs/effects/node/module.f.d.mts +39 -0
  43. package/fjs/effects/node/module.f.mjs +46 -0
  44. package/fjs/effects/node/module.mjs +234 -32
  45. package/fjs/effects/node/types.d.ts +28 -2
  46. package/fjs/effects/node/virtual/module.f.d.mts +19 -10
  47. package/fjs/effects/node/virtual/module.f.mjs +246 -32
  48. package/fjs/effects/node/virtual/proof.f.d.mts +17 -2
  49. package/fjs/effects/node/virtual/proof.f.mjs +232 -8
  50. package/fjs/effects/node/virtual/types.d.ts +57 -1
  51. package/fjs/emergent_testing/browser/proof.d.mts +55 -0
  52. package/fjs/emergent_testing/browser/proof.mjs +352 -0
  53. package/fjs/emergent_testing/browser/species.proof.d.mts +4 -0
  54. package/fjs/emergent_testing/browser/species.proof.mjs +45 -0
  55. package/fjs/emergent_testing/browser.d.mts +79 -0
  56. package/fjs/emergent_testing/browser.mjs +432 -0
  57. package/fjs/emergent_testing/proof.f.d.mts +1 -1
  58. package/fjs/emergent_testing/proof.f.mjs +2 -2
  59. package/fjs/js/string_escape/module.f.d.mts +47 -0
  60. package/fjs/js/string_escape/module.f.mjs +95 -0
  61. package/fjs/js/string_escape/proof.f.d.mts +8 -0
  62. package/fjs/js/string_escape/proof.f.mjs +68 -0
  63. package/fjs/js/tokenizer/module.f.mjs +14 -12
  64. package/fjs/mcp/cas/module.f.d.mts +1 -7
  65. package/fjs/mcp/cas/module.f.mjs +24 -15
  66. package/fjs/mcp/evo/module.f.mjs +9 -24
  67. package/fjs/mcp/module.f.d.mts +3 -1
  68. package/fjs/mcp/module.f.mjs +4 -2
  69. package/fjs/media/json/rtti/module.f.d.mts +2 -2
  70. package/fjs/media/json/rtti/module.f.mjs +1 -1
  71. package/fjs/media/json/schema/module.f.d.mts +15 -5
  72. package/fjs/media/json/schema/module.f.mjs +42 -15
  73. package/fjs/media/json/schema/proof.f.d.mts +10 -1
  74. package/fjs/media/json/schema/proof.f.mjs +88 -16
  75. package/fjs/media/json/serializer/module.f.mjs +14 -26
  76. package/fjs/media/json/types.d.ts +1 -1
  77. package/fjs/media/lock/module.f.d.mts +8 -3
  78. package/fjs/media/lock/module.f.mjs +9 -3
  79. package/fjs/media/lock/proof.f.d.mts +1 -1
  80. package/fjs/media/lock/proof.f.mjs +2 -2
  81. package/fjs/media/lock/types.d.ts +1 -3
  82. package/fjs/media/module.f.d.mts +20 -13
  83. package/fjs/media/module.f.mjs +29 -16
  84. package/fjs/media/note/module.f.d.mts +14 -7
  85. package/fjs/media/note/module.f.mjs +14 -7
  86. package/fjs/media/note/proof.f.mjs +1 -1
  87. package/fjs/media/proof.f.mjs +5 -5
  88. package/fjs/media/revision/module.f.d.mts +8 -2
  89. package/fjs/media/revision/module.f.mjs +9 -3
  90. package/fjs/media/revision/proof.f.mjs +1 -1
  91. package/fjs/media/revision/types.d.ts +1 -1
  92. package/fjs/media/type/module.f.d.mts +20 -0
  93. package/fjs/media/type/module.f.mjs +74 -0
  94. package/fjs/media/type/proof.f.d.mts +5 -0
  95. package/fjs/media/type/proof.f.mjs +24 -1
  96. package/fjs/module.f.mjs +6 -0
  97. package/fjs/path/module.f.d.mts +60 -3
  98. package/fjs/path/module.f.mjs +138 -14
  99. package/fjs/path/proof.f.d.mts +3 -0
  100. package/fjs/path/proof.f.mjs +298 -2
  101. package/fjs/protocol/json_rpc/module.f.d.mts +65 -28
  102. package/fjs/protocol/json_rpc/module.f.mjs +49 -13
  103. package/fjs/protocol/json_rpc/proof.f.d.mts +4 -0
  104. package/fjs/protocol/json_rpc/proof.f.mjs +29 -0
  105. package/fjs/protocol/json_rpc/types.d.ts +12 -2
  106. package/fjs/protocol/mcp/module.f.d.mts +89 -54
  107. package/fjs/protocol/mcp/module.f.mjs +110 -56
  108. package/fjs/protocol/mcp/proof.f.d.mts +13 -0
  109. package/fjs/protocol/mcp/proof.f.mjs +107 -7
  110. package/fjs/protocol/mcp/stdio/module.f.mjs +3 -4
  111. package/fjs/protocol/mcp/stdio/proof.f.mjs +2 -2
  112. package/fjs/protocol/mcp/types.d.ts +14 -2
  113. package/fjs/text/utf8/module.f.mjs +27 -1
  114. package/fjs/text/utf8/proof.f.mjs +37 -0
  115. package/fjs/types/array/module.f.mjs +18 -11
  116. package/fjs/types/array/proof.f.mjs +27 -1
  117. package/fjs/types/bigfloat/module.f.d.mts +48 -9
  118. package/fjs/types/bigfloat/module.f.mjs +147 -59
  119. package/fjs/types/bigfloat/proof.f.d.mts +2 -0
  120. package/fjs/types/bigfloat/proof.f.mjs +136 -4
  121. package/fjs/types/bigfloat/types.d.ts +17 -0
  122. package/fjs/types/function/module.f.d.mts +11 -2
  123. package/fjs/types/function/module.f.mjs +20 -2
  124. package/fjs/types/function/proof.f.mjs +5 -1
  125. package/fjs/types/phantom/types.d.ts +21 -0
  126. package/fjs/types/result/module.f.d.mts +4 -4
  127. package/fjs/types/result/module.f.mjs +2 -2
  128. package/fjs/types/result/proof.f.d.mts +6 -2
  129. package/fjs/types/result/proof.f.mjs +17 -1
  130. package/fjs/types/rtti/common/module.f.d.mts +78 -5
  131. package/fjs/types/rtti/common/module.f.mjs +168 -3
  132. package/fjs/types/rtti/common/proof.f.d.mts +14 -0
  133. package/fjs/types/rtti/common/proof.f.mjs +61 -2
  134. package/fjs/types/rtti/common/types.d.ts +23 -1
  135. package/fjs/types/rtti/data/module.f.d.mts +57 -1
  136. package/fjs/types/rtti/data/module.f.mjs +241 -47
  137. package/fjs/types/rtti/data/proof.f.d.mts +10 -0
  138. package/fjs/types/rtti/data/proof.f.mjs +272 -39
  139. package/fjs/types/rtti/data/types.d.ts +14 -7
  140. package/fjs/types/rtti/host.proof.d.mts +33 -0
  141. package/fjs/types/rtti/host.proof.mjs +170 -0
  142. package/fjs/types/rtti/module.f.d.mts +51 -6
  143. package/fjs/types/rtti/module.f.mjs +50 -3
  144. package/fjs/types/rtti/parse/module.f.d.mts +31 -24
  145. package/fjs/types/rtti/parse/module.f.mjs +197 -59
  146. package/fjs/types/rtti/parse/proof.f.d.mts +37 -2
  147. package/fjs/types/rtti/parse/proof.f.mjs +171 -47
  148. package/fjs/types/rtti/proof.f.d.mts +6 -0
  149. package/fjs/types/rtti/proof.f.mjs +42 -1
  150. package/fjs/types/rtti/ts/module.f.d.mts +9 -3
  151. package/fjs/types/rtti/ts/module.f.mjs +68 -15
  152. package/fjs/types/rtti/ts/proof.f.d.mts +42 -2
  153. package/fjs/types/rtti/ts/proof.f.mjs +139 -6
  154. package/fjs/types/rtti/ts/types.d.ts +164 -16
  155. package/fjs/types/rtti/types.d.ts +31 -3
  156. package/fjs/types/rtti/validate/module.f.d.mts +42 -25
  157. package/fjs/types/rtti/validate/module.f.mjs +187 -53
  158. package/fjs/types/rtti/validate/proof.f.d.mts +28 -2
  159. package/fjs/types/rtti/validate/proof.f.mjs +430 -75
  160. package/fjs/types/ts/types.d.ts +4 -0
  161. package/fjs/web/module.f.d.mts +115 -0
  162. package/fjs/web/module.f.mjs +581 -0
  163. package/fjs/web/proof.f.d.mts +39 -0
  164. package/fjs/web/proof.f.mjs +413 -0
  165. package/fjs/web/types.d.ts +39 -0
  166. package/fjs/website/browser-prepare.d.mts +5 -0
  167. package/fjs/website/browser-prepare.mjs +115 -0
  168. package/fjs/website/browser-source.d.mts +37 -0
  169. package/fjs/website/browser-source.mjs +202 -0
  170. package/fjs/website/browser-source.proof.d.mts +31 -0
  171. package/fjs/website/browser-source.proof.mjs +162 -0
  172. package/fjs/website/browser.d.mts +5 -0
  173. package/fjs/website/browser.mjs +8 -0
  174. package/fjs/website/module.f.d.mts +3 -3
  175. package/fjs/website/module.f.mjs +62 -9
  176. package/fjs/website/proof.f.d.mts +4 -0
  177. package/fjs/website/proof.f.mjs +18 -3
  178. package/package.json +2 -3
@@ -91,8 +91,14 @@ export declare const lockField: LockFieldSchema;
91
91
  * rtti schema for a `revision` BLOB. See the README for the full semantics of
92
92
  * each field; `dialect` is the type discriminant, matched here as an exact
93
93
  * literal so structural validation alone rejects any other dialect's blob.
94
+ *
95
+ * `open`, and deliberately so: a bare struct is closed, so an older reader
96
+ * would reject a blob a newer writer had added a field to, and this dialect's
97
+ * own versioning rule — additive extension keeps the tag, see `./README.md` —
98
+ * is stated in terms of that older reader accepting it. Do not drop the
99
+ * wrapper.
94
100
  */
95
- export declare const revisionSchema: {
101
+ export declare const revisionSchema: import("../../types/rtti/types.ts").Rest<{
96
102
  readonly dialect: "vnd.fjs.revision";
97
103
  readonly subject: import("../../types/rtti/types.ts")._Type0<"string">;
98
104
  readonly parents: import("../../types/rtti/types.ts").Type1<"array", import("../../types/rtti/types.ts")._Type0<"string">>;
@@ -100,7 +106,7 @@ export declare const revisionSchema: {
100
106
  readonly generation: import("../../types/rtti/types.ts")._Type0<"number">;
101
107
  readonly archived: import("../../types/rtti/types.ts").Or<readonly [true, undefined]>;
102
108
  readonly lock: import("../../types/rtti/types.ts").Or<readonly [LockFieldSchema, undefined]>;
103
- };
109
+ }, import("../../types/rtti/types.ts").Unknown>;
104
110
  /** Serializes a revision canonically, recursively sorting every object's property names.
105
111
  * @type {(revision: Revision) => string}
106
112
  */
@@ -21,7 +21,7 @@
21
21
  * @import { LockField, LockFieldSchema, LockMap, LockSchema, Revision, RevisionError } from './types.ts'
22
22
  */
23
23
 
24
- import { array, number, option, string } from '../../types/rtti/module.f.mjs'
24
+ import { array, number, open, option, string } from '../../types/rtti/module.f.mjs'
25
25
  import { parse as rttiParse } from '../../types/rtti/parse/module.f.mjs'
26
26
  import { parse as parseJson } from '../json/module.f.mjs'
27
27
  import { cBase32ToVec } from '../../basen/cbase32/module.f.mjs'
@@ -113,8 +113,14 @@ export const lockField = () => ['or', hash, lock]
113
113
  * rtti schema for a `revision` BLOB. See the README for the full semantics of
114
114
  * each field; `dialect` is the type discriminant, matched here as an exact
115
115
  * literal so structural validation alone rejects any other dialect's blob.
116
+ *
117
+ * `open`, and deliberately so: a bare struct is closed, so an older reader
118
+ * would reject a blob a newer writer had added a field to, and this dialect's
119
+ * own versioning rule — additive extension keeps the tag, see `./README.md` —
120
+ * is stated in terms of that older reader accepting it. Do not drop the
121
+ * wrapper.
116
122
  */
117
- export const revisionSchema = /** @type {const} */ ({
123
+ export const revisionSchema = open(/** @type {const} */ ({
118
124
  dialect,
119
125
  subject: string,
120
126
  parents: array(hash),
@@ -122,7 +128,7 @@ export const revisionSchema = /** @type {const} */ ({
122
128
  generation: number,
123
129
  archived: option(true),
124
130
  lock: option(lockField),
125
- })
131
+ }))
126
132
 
127
133
  /** Serializes a revision canonically, recursively sorting every object's property names.
128
134
  * @type {(revision: Revision) => string}
@@ -228,7 +228,7 @@ export const proof = {
228
228
  assertEq(t, 'error')
229
229
  },
230
230
 
231
- // rtti structs are open: extra fields don't break validation — the
231
+ // `revisionSchema` says `open`: extra fields don't break validation — the
232
232
  // additive forward-compatibility path the versioning rule relies on.
233
233
  extraFieldsAccepted: () => {
234
234
  const [t] = validate(revisionOf({ future: 'field' }))
@@ -5,7 +5,7 @@
5
5
  *
6
6
  * `LockMap` is written by hand rather than derived, so that the recursion
7
7
  * reads directly, and is then pinned against the module's rtti schema with
8
- * `Assert<Equal<LockMap, Ts<typeof lock>>>` — the same arrangement the JSON
8
+ * `Assert<Check<LockMap, typeof lock>>` — the same arrangement the JSON
9
9
  * data model uses in [`../json/types.ts`](../json/types.ts). `LockSchema` is
10
10
  * the schema side of the same recursion: `lock` cannot infer its own type
11
11
  * (a `const` may not reference itself in its own initializer), so it carries
@@ -31,6 +31,12 @@
31
31
  * sits between the `RIFF` and `WEBP` markers, so its pattern carries four
32
32
  * wildcard bytes rather than being one contiguous run.
33
33
  *
34
+ * Beside the two byte sniffers, `detectPath` answers the same question from the
35
+ * other end — the `Content-Type` a *file name* implies, read off its extension.
36
+ * A sniffer cannot serve that question at all: `text/html`, `text/css` and
37
+ * `text/javascript` are byte-identical UTF-8 text, and only the name tells them
38
+ * apart.
39
+ *
34
40
  * See `./types.ts` for the type-level API.
35
41
  *
36
42
  * @module
@@ -130,3 +136,17 @@ export declare const detectVec: (bytes: Vec) => DetectMeta;
130
136
  * @returns {Effect<O, DetectMeta, IoChannel>}
131
137
  */
132
138
  export declare const detectStream: <O extends Operation>(stream: List<O, Vec, IoChannel>) => Effect<O, DetectMeta, IoChannel>;
139
+ /**
140
+ * The `Content-Type` a file name implies, from its extension alone — the bytes
141
+ * are never read. Text types carry `; charset=utf-8`, so the result is a
142
+ * complete header value rather than a bare media type; an absent or unknown
143
+ * extension is `application/octet-stream`.
144
+ *
145
+ * This is the counterpart of {@link detect}, not a replacement for it: sniffing
146
+ * answers what unlabelled bytes are, and this answers what a named file claims
147
+ * to be. A server needs the second — the three text formats a browser treats
148
+ * differently are byte-identical to a sniffer.
149
+ *
150
+ * @type {(path: string) => string}
151
+ */
152
+ export declare const detectPath: (path: string) => string;
@@ -31,6 +31,12 @@
31
31
  * sits between the `RIFF` and `WEBP` markers, so its pattern carries four
32
32
  * wildcard bytes rather than being one contiguous run.
33
33
  *
34
+ * Beside the two byte sniffers, `detectPath` answers the same question from the
35
+ * other end — the `Content-Type` a *file name* implies, read off its extension.
36
+ * A sniffer cannot serve that question at all: `text/html`, `text/css` and
37
+ * `text/javascript` are byte-identical UTF-8 text, and only the name tells them
38
+ * apart.
39
+ *
34
40
  * See `./types.ts` for the type-level API.
35
41
  *
36
42
  * @module
@@ -49,6 +55,7 @@ import { iterable } from '../../types/list/module.f.mjs'
49
55
  import { pureOk, step as ioStep } from '../../effects/module.f.mjs'
50
56
  import { isValidCodePoint, isTextCodePoint } from '../../text/code_point/module.f.mjs'
51
57
  import { utf8ByteToCodePointOp } from '../../text/utf8/module.f.mjs'
58
+ import { at } from '../../types/object/module.f.mjs'
52
59
 
53
60
  // ── Magic-byte signatures ─────────────────────────────────────────────────────────
54
61
  //
@@ -269,3 +276,70 @@ export const detectStream = stream => {
269
276
  })
270
277
  return loop(detectInit)(stream)
271
278
  }
279
+
280
+ // ── Extension table ───────────────────────────────────────────────────────────────
281
+ //
282
+ // The same question asked from the other end: not "what do these bytes look like"
283
+ // but "what does this file name claim to be". A server answering `Content-Type`
284
+ // needs the second, because the first cannot tell `text/html` from `text/css` from
285
+ // `text/javascript` — every one of them is plain UTF-8 text, and only the extension
286
+ // distinguishes them.
287
+
288
+ /**
289
+ * Media type by lower-case file-name extension, without the `charset` parameter
290
+ * {@link detectPath} adds. Deliberately small: the types the pages under
291
+ * `fjs/website` are built from, plus the image formats {@link detect} already
292
+ * recognizes.
293
+ */
294
+ const byExtension = /** @type {const} */ ({
295
+ html: 'text/html',
296
+ css: 'text/css',
297
+ js: 'text/javascript',
298
+ mjs: 'text/javascript',
299
+ json: 'application/json',
300
+ svg: 'image/svg+xml',
301
+ png: 'image/png',
302
+ jpg: 'image/jpeg',
303
+ jpeg: 'image/jpeg',
304
+ gif: 'image/gif',
305
+ webp: 'image/webp',
306
+ wasm: 'application/wasm',
307
+ txt: 'text/plain',
308
+ })
309
+
310
+ /** The answer for a name whose extension is absent or unknown.
311
+ *
312
+ * @type {string}
313
+ */
314
+ const octetStream = 'application/octet-stream'
315
+
316
+ /**
317
+ * The lower-case extension of the last segment of `path`, or `''` when it has
318
+ * none. A leading dot is a name, not an extension: `.gitignore` has none.
319
+ *
320
+ * @type {(path: string) => string}
321
+ */
322
+ const extension = path => {
323
+ const name = path.slice(path.lastIndexOf('/') + 1)
324
+ const dot = name.lastIndexOf('.')
325
+ return dot > 0 ? name.slice(dot + 1).toLowerCase() : ''
326
+ }
327
+
328
+ /**
329
+ * The `Content-Type` a file name implies, from its extension alone — the bytes
330
+ * are never read. Text types carry `; charset=utf-8`, so the result is a
331
+ * complete header value rather than a bare media type; an absent or unknown
332
+ * extension is `application/octet-stream`.
333
+ *
334
+ * This is the counterpart of {@link detect}, not a replacement for it: sniffing
335
+ * answers what unlabelled bytes are, and this answers what a named file claims
336
+ * to be. A server needs the second — the three text formats a browser treats
337
+ * differently are byte-identical to a sniffer.
338
+ *
339
+ * @type {(path: string) => string}
340
+ */
341
+ export const detectPath = path => {
342
+ const mime = at(extension(path))(byExtension)
343
+ if (mime === null) { return octetStream }
344
+ return mime.startsWith('text/') ? `${mime}; charset=utf-8` : mime
345
+ }
@@ -54,4 +54,9 @@ export declare const proof: {
54
54
  octetStream: () => void;
55
55
  nul: () => void;
56
56
  };
57
+ detectPath: {
58
+ text: () => void;
59
+ binary: () => void;
60
+ fallback: () => void;
61
+ };
57
62
  };
@@ -12,7 +12,7 @@ import { nonEmpty, empty as emptyList } from '../../effects/list/module.f.mjs'
12
12
  import { pureError } from '../../effects/module.f.mjs'
13
13
  import { error, ok } from '../../types/result/module.f.mjs'
14
14
  import { ioError } from '../../effects/node/module.f.mjs'
15
- import { detect, detectStream, detectVec } from './module.f.mjs'
15
+ import { detect, detectPath, detectStream, detectVec } from './module.f.mjs'
16
16
 
17
17
  // Builds a big-endian `Vec` from a list of byte values — mirrors how the CAS
18
18
  // store would hold the leading bytes of a stored blob.
@@ -342,4 +342,27 @@ export const proof = {
342
342
  assertEq(m.length, 3n)
343
343
  },
344
344
  },
345
+ detectPath: {
346
+ // A text type carries the charset, so the answer is a complete header
347
+ // value rather than a bare media type.
348
+ text: () => {
349
+ assertEq(detectPath('index.html'), 'text/html; charset=utf-8')
350
+ assertEq(detectPath('a/b/main.css'), 'text/css; charset=utf-8')
351
+ assertEq(detectPath('module.f.mjs'), 'text/javascript; charset=utf-8')
352
+ },
353
+ // A binary type does not: `charset` says nothing about bytes.
354
+ binary: () => {
355
+ assertEq(detectPath('logo.png'), 'image/png')
356
+ assertEq(detectPath('photo.JPG'), 'image/jpeg')
357
+ assertEq(detectPath('vm.wasm'), 'application/wasm')
358
+ },
359
+ // The names that carry no extension, plus an extension nothing claims.
360
+ fallback: () => {
361
+ assertEq(detectPath('README'), 'application/octet-stream')
362
+ assertEq(detectPath('.gitignore'), 'application/octet-stream')
363
+ assertEq(detectPath('archive.tar.zst'), 'application/octet-stream')
364
+ // A dot in a parent directory is not this file's extension.
365
+ assertEq(detectPath('v1.2/README'), 'application/octet-stream')
366
+ },
367
+ },
345
368
  }
package/fjs/module.f.mjs CHANGED
@@ -14,6 +14,7 @@ import { main as ciMain } from './ci/module.f.mjs'
14
14
  import { errorExit, errorMessage, exitStep, import_ } from './effects/node/module.f.mjs'
15
15
  import { dispatch } from './cli/module.f.mjs'
16
16
  import { casMcpServer } from './mcp/module.f.mjs'
17
+ import { main as webMain } from './web/module.f.mjs'
17
18
  import { resultStep } from './effects/module.f.mjs'
18
19
 
19
20
  /** @type {Commands<NodeOp>} */
@@ -53,6 +54,11 @@ const commands = [
53
54
  description: 'Generate the GitHub Actions CI workflow',
54
55
  handler: ciMain,
55
56
  },
57
+ {
58
+ names: ['web', 'w'],
59
+ description: 'Serve a directory over HTTP',
60
+ handler: webMain,
61
+ },
56
62
  {
57
63
  names: ['run', 'r'],
58
64
  description: 'Run a FunctionalScript module as a NodeProgram',
@@ -1,6 +1,12 @@
1
1
  /**
2
2
  * Path parsing and normalization helpers for portable module paths.
3
3
  *
4
+ * A path is a {@link root} followed by segments. The root is what makes the
5
+ * path absolute — `/`, `//` for a UNC path, or `C:/` for a Windows drive —
6
+ * and it is not a segment: it survives normalization, and `..` cannot escape
7
+ * it. Everything after it folds by the usual rules, so `a//b` is `a/b` and
8
+ * `a/../b` is `b`.
9
+ *
4
10
  * @module
5
11
  *
6
12
  * @import { Fold, Reduce, Unary } from '../types/function/operator/types.ts'
@@ -14,17 +20,62 @@ import type { Reduce, Unary } from '../types/function/operator/types.ts';
14
20
  */
15
21
  export declare const toPosix: (path: string) => string;
16
22
  /**
17
- * Splits a path into normalized segments.
23
+ * The root of a path, carrying its trailing separator: `'/'` for a POSIX
24
+ * absolute path, `'//'` for a UNC one, `'C:/'` for a Windows drive, and `''`
25
+ * for a relative path.
26
+ *
27
+ * `..` cannot escape whatever this answers, so it is also the definition of
28
+ * how far up a path can go. On Windows that is not quite the whole story: the
29
+ * `server/share` of a UNC path is part of its root there and is an ordinary
30
+ * segment here, so `..` can still climb past a share — see
31
+ * {@link split} for why the root stops at `//`.
32
+ *
33
+ * A drive root is the one that folding can *create*. `/` and `//` are prefixes
34
+ * of the text, so no amount of `.`/`..` collapsing produces one; `C:/` is a
35
+ * segment's content, so a relative path whose first segment happens to be a
36
+ * drive letter becomes drive-rooted once folded — `root('./C:/a')` is `''`
37
+ * while `root(normalize('./C:/a'))` is `'C:/'`. On Windows that is the reading
38
+ * that is wanted; on a POSIX host with a directory actually named `C:` it is
39
+ * not, and this module has no way to tell the two apart after folding.
40
+ *
41
+ * @type {(path: string) => string}
42
+ */
43
+ export declare const root: (path: string) => string;
44
+ /**
45
+ * Splits a path into normalized segments, *without* its root — `parse('/a/b')`
46
+ * and `parse('a/b')` are both `['a', 'b']`. Use {@link root} to ask whether the
47
+ * path was absolute, or {@link normalize} for a string that keeps it.
18
48
  *
19
49
  * Empty (`""`) and current-directory (`"."`) segments are removed, parent-directory
20
50
  * (`".."`) segments collapse the previous segment when possible, and Windows
21
- * separators are converted to POSIX separators.
51
+ * separators are converted to POSIX separators. A `".."` that would escape a
52
+ * root is dropped rather than kept.
22
53
  *
23
54
  * @type {(path: string) => readonly string[]}
24
55
  */
25
56
  export declare const parse: (path: string) => readonly string[];
26
57
  /**
27
- * Normalizes a path string by parsing and rejoining it with POSIX separators.
58
+ * Whether a `..` in `path` would climb above its root what a caller asks when
59
+ * it has to *refuse* a traversal rather than clamp one.
60
+ *
61
+ * {@link parse} cannot answer it: `parse` folds with the root in place, and the
62
+ * escaping `..` is precisely what that removes. Here the root comes off first
63
+ * and the remainder folds as a relative path, where a `..` with nothing left to
64
+ * cancel survives to be counted.
65
+ *
66
+ * Taking the root off is not the same as dropping a leading `/`. The remainder
67
+ * of `///../x` is `//../x` and the remainder of `/C:/../../x` is
68
+ * `C:/../../x` — both read as rooted again, and folding them a second time
69
+ * would clamp the very `..` being looked for. The fold is told there is no
70
+ * root instead of being handed a string that looks like it has none.
71
+ *
72
+ * @type {(path: string) => boolean}
73
+ */
74
+ export declare const escapes: (path: string) => boolean;
75
+ /**
76
+ * Normalizes a path string by parsing and rejoining it with POSIX separators,
77
+ * keeping the root: `normalize('/a/./b')` is `'/a/b'` and `normalize('/')` is
78
+ * `'/'`.
28
79
  *
29
80
  * @type {Unary<string, string>}
30
81
  */
@@ -32,6 +83,12 @@ export declare const normalize: Unary<string, string>;
32
83
  /**
33
84
  * Concatenates two path fragments and returns a normalized path.
34
85
  *
86
+ * An absolute `b` names a path on its own, so it replaces `a` rather than being
87
+ * appended to it. Otherwise the root comes from `a` and the segments from the
88
+ * two joined — taking the root from `a` rather than from the joined string is
89
+ * what stops the separator between them from being read as a root of its own,
90
+ * so `concat('')('../x')` is `'../x'` and not `'x'`.
91
+ *
35
92
  * @type {Reduce<string>}
36
93
  */
37
94
  export declare const concat: Reduce<string>;
@@ -1,6 +1,12 @@
1
1
  /**
2
2
  * Path parsing and normalization helpers for portable module paths.
3
3
  *
4
+ * A path is a {@link root} followed by segments. The root is what makes the
5
+ * path absolute — `/`, `//` for a UNC path, or `C:/` for a Windows drive —
6
+ * and it is not a segment: it survives normalization, and `..` cannot escape
7
+ * it. Everything after it folds by the usual rules, so `a//b` is `a/b` and
8
+ * `a/../b` is `b`.
9
+ *
4
10
  * @module
5
11
  *
6
12
  * @import { Fold, Reduce, Unary } from '../types/function/operator/types.ts'
@@ -10,13 +16,19 @@
10
16
  import { fold, last, take, length, concat as listConcat, toArray } from '../types/list/module.f.mjs'
11
17
  import { join as listJoin, concat as stringConcat } from '../types/string/module.f.mjs'
12
18
 
13
- /** @type {Fold<string, List<string>>} */
14
- const foldNormalizeOp = input => state => {
19
+ /**
20
+ * `rooted` is the only thing the fold needs to know about the root: whether a
21
+ * leading `..` has anywhere to go. `/a/../..` is `/`, because there is no
22
+ * parent of the root to name, while `a/../..` stays `..`.
23
+ *
24
+ * @type {(rooted: boolean) => Fold<string, List<string>>}
25
+ */
26
+ const foldNormalizeOp = rooted => input => state => {
15
27
  switch(input) {
16
28
  case '': case '.': { return state }
17
29
  case '..': {
18
30
  switch(last(undefined)(state)) {
19
- case undefined:
31
+ case undefined: { return rooted ? state : listConcat(state)([input]) }
20
32
  case '..': { return listConcat(state)([input]) }
21
33
  }
22
34
  return take(length(state) - 1)(state)
@@ -32,38 +44,150 @@ const foldNormalizeOp = input => state => {
32
44
  */
33
45
  export const toPosix = path => path.replaceAll('\\', '/')
34
46
 
47
+ /** @type {(c: string) => boolean} */
48
+ const isDriveLetter = c => (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z')
49
+
50
+ /**
51
+ * A Windows drive root, and only in its absolute spelling: `C:/` roots the
52
+ * path, while a bare `C:` and the drive-relative `C:foo` — which names the
53
+ * current directory *on* drive C — do not, and stay ordinary segments.
54
+ *
55
+ * @type {(p: string) => boolean}
56
+ */
57
+ const isDriveRoot = p => p.length >= 3 && p[1] === ':' && p[2] === '/' && isDriveLetter(p[0])
58
+
59
+ /**
60
+ * A bare drive, which {@link isDriveRoot} deliberately excludes — and which
61
+ * {@link concat} must therefore not turn into one. The separator `concat`
62
+ * inserts between its two arguments is what would do it: `C:` and `dir` joined
63
+ * with a `/` is the drive root `C:/dir` rather than the drive-relative `C:dir`,
64
+ * a different place on the disk. Joined without one, the answer stays the kind
65
+ * of path it started as.
66
+ *
67
+ * This keeps `concat` from changing a path's kind. It does not make the
68
+ * drive-relative form *resolve* like Windows: `C:dir` is one ordinary segment
69
+ * here, so `concat('C:dir')('../..')` is `'..'` where Windows says `C:..`.
70
+ * Modelling that needs a third kind of path, which this module does not have.
71
+ *
72
+ * @type {(p: string) => boolean}
73
+ */
74
+ const isBareDrive = p => p.length === 2 && p[1] === ':' && isDriveLetter(p[0])
75
+
76
+ /**
77
+ * Splits an already-POSIX path into its root and everything after it, so that
78
+ * `root + rest` is the path again. The root carries its own trailing separator
79
+ * — `'/'`, `'//'`, `'C:/'`, or `''` — which is what lets {@link rejoin} put a
80
+ * path back together without a separator of its own.
81
+ *
82
+ * A root is a fixed-width prefix here, never a parsed one. `//` marks a UNC
83
+ * path but stops there rather than swallowing `server/share`: those are two
84
+ * arbitrary segments, and a path like `//a/../../etc/passwd` would fold `../`
85
+ * *into* the root, which is precisely what a root must not do. Three or more
86
+ * leading slashes are an ordinary root followed by empty segments, which is
87
+ * what POSIX requires.
88
+ *
89
+ * @type {(p: string) => readonly [root: string, rest: string]}
90
+ */
91
+ const split = p =>
92
+ p.startsWith('//') && !p.startsWith('///') ? ['//', p.slice(2)]
93
+ : p.startsWith('/') ? ['/', p.slice(1)]
94
+ : isDriveRoot(p) ? [p.slice(0, 3), p.slice(3)]
95
+ : ['', p]
96
+
97
+ /** @type {(rooted: boolean) => (rest: string) => readonly string[]} */
98
+ const posixSegments = rooted => rest => toArray(fold(foldNormalizeOp(rooted))([])(rest.split('/')))
99
+
100
+ /** @type {(s: readonly [string, string]) => string} */
101
+ const rejoin = ([r, rest]) => stringConcat([r, listJoin('/')(posixSegments(r !== '')(rest))])
102
+
103
+ /**
104
+ * The root of a path, carrying its trailing separator: `'/'` for a POSIX
105
+ * absolute path, `'//'` for a UNC one, `'C:/'` for a Windows drive, and `''`
106
+ * for a relative path.
107
+ *
108
+ * `..` cannot escape whatever this answers, so it is also the definition of
109
+ * how far up a path can go. On Windows that is not quite the whole story: the
110
+ * `server/share` of a UNC path is part of its root there and is an ordinary
111
+ * segment here, so `..` can still climb past a share — see
112
+ * {@link split} for why the root stops at `//`.
113
+ *
114
+ * A drive root is the one that folding can *create*. `/` and `//` are prefixes
115
+ * of the text, so no amount of `.`/`..` collapsing produces one; `C:/` is a
116
+ * segment's content, so a relative path whose first segment happens to be a
117
+ * drive letter becomes drive-rooted once folded — `root('./C:/a')` is `''`
118
+ * while `root(normalize('./C:/a'))` is `'C:/'`. On Windows that is the reading
119
+ * that is wanted; on a POSIX host with a directory actually named `C:` it is
120
+ * not, and this module has no way to tell the two apart after folding.
121
+ *
122
+ * @type {(path: string) => string}
123
+ */
124
+ export const root = path => split(toPosix(path))[0]
125
+
35
126
  /**
36
- * Splits a path into normalized segments.
127
+ * Splits a path into normalized segments, *without* its root — `parse('/a/b')`
128
+ * and `parse('a/b')` are both `['a', 'b']`. Use {@link root} to ask whether the
129
+ * path was absolute, or {@link normalize} for a string that keeps it.
37
130
  *
38
131
  * Empty (`""`) and current-directory (`"."`) segments are removed, parent-directory
39
132
  * (`".."`) segments collapse the previous segment when possible, and Windows
40
- * separators are converted to POSIX separators.
133
+ * separators are converted to POSIX separators. A `".."` that would escape a
134
+ * root is dropped rather than kept.
41
135
  *
42
136
  * @type {(path: string) => readonly string[]}
43
137
  */
44
138
  export const parse = path => {
45
- const split = toPosix(path).split('/')
46
- return toArray(fold(foldNormalizeOp)([])(split))
139
+ const [r, rest] = split(toPosix(path))
140
+ return posixSegments(r !== '')(rest)
47
141
  }
48
142
 
49
143
  /**
50
- * Normalizes a path string by parsing and rejoining it with POSIX separators.
144
+ * Whether a `..` in `path` would climb above its root what a caller asks when
145
+ * it has to *refuse* a traversal rather than clamp one.
146
+ *
147
+ * {@link parse} cannot answer it: `parse` folds with the root in place, and the
148
+ * escaping `..` is precisely what that removes. Here the root comes off first
149
+ * and the remainder folds as a relative path, where a `..` with nothing left to
150
+ * cancel survives to be counted.
151
+ *
152
+ * Taking the root off is not the same as dropping a leading `/`. The remainder
153
+ * of `///../x` is `//../x` and the remainder of `/C:/../../x` is
154
+ * `C:/../../x` — both read as rooted again, and folding them a second time
155
+ * would clamp the very `..` being looked for. The fold is told there is no
156
+ * root instead of being handed a string that looks like it has none.
157
+ *
158
+ * @type {(path: string) => boolean}
159
+ */
160
+ export const escapes = path => posixSegments(false)(split(toPosix(path))[1]).includes('..')
161
+
162
+ /**
163
+ * Normalizes a path string by parsing and rejoining it with POSIX separators,
164
+ * keeping the root: `normalize('/a/./b')` is `'/a/b'` and `normalize('/')` is
165
+ * `'/'`.
51
166
  *
52
167
  * @type {Unary<string, string>}
53
168
  */
54
- export const normalize = path => {
55
- const foldResult = parse(path)
56
- return listJoin('/')(foldResult)
57
- }
169
+ export const normalize = path => rejoin(split(toPosix(path)))
58
170
 
59
171
  /**
60
172
  * Concatenates two path fragments and returns a normalized path.
61
173
  *
174
+ * An absolute `b` names a path on its own, so it replaces `a` rather than being
175
+ * appended to it. Otherwise the root comes from `a` and the segments from the
176
+ * two joined — taking the root from `a` rather than from the joined string is
177
+ * what stops the separator between them from being read as a root of its own,
178
+ * so `concat('')('../x')` is `'../x'` and not `'x'`.
179
+ *
62
180
  * @type {Reduce<string>}
63
181
  */
64
182
  export const concat = a => b => {
65
- const s = stringConcat([a, '/', b])
66
- return normalize(s)
183
+ const [rb, restb] = split(toPosix(b))
184
+ if (rb !== '') { return rejoin([rb, restb]) }
185
+ // `a` is normalized before its root is read, because what `a` *is* decides
186
+ // the join and only the folded form answers that: `./C:` and `x/../C:` are
187
+ // both the bare drive `C:`, and reading them unfolded would insert the
188
+ // separator that makes a drive root out of one.
189
+ const [ra, resta] = split(normalize(a))
190
+ return rejoin([ra, stringConcat([resta, isBareDrive(resta) ? '' : '/', restb])])
67
191
  }
68
192
 
69
193
  /**
@@ -1,5 +1,8 @@
1
1
  export declare const proof: {
2
2
  normalizeTest: (() => void)[];
3
+ escapesTest: (() => void)[];
4
+ rootTest: (() => void)[];
5
+ parseTest: (() => void)[];
3
6
  concatTest: (() => void)[];
4
7
  joinTest: (() => void)[];
5
8
  relativizeTest: (() => void)[];