@proseql/core 0.1.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 (342) hide show
  1. package/LICENSE +21 -0
  2. package/dist/errors/crud-errors.d.ts +98 -0
  3. package/dist/errors/crud-errors.d.ts.map +1 -0
  4. package/dist/errors/crud-errors.js +23 -0
  5. package/dist/errors/crud-errors.js.map +1 -0
  6. package/dist/errors/index.d.ts +16 -0
  7. package/dist/errors/index.d.ts.map +1 -0
  8. package/dist/errors/index.js +12 -0
  9. package/dist/errors/index.js.map +1 -0
  10. package/dist/errors/migration-errors.d.ts +22 -0
  11. package/dist/errors/migration-errors.d.ts.map +1 -0
  12. package/dist/errors/migration-errors.js +14 -0
  13. package/dist/errors/migration-errors.js.map +1 -0
  14. package/dist/errors/plugin-errors.d.ts +15 -0
  15. package/dist/errors/plugin-errors.d.ts.map +1 -0
  16. package/dist/errors/plugin-errors.js +11 -0
  17. package/dist/errors/plugin-errors.js.map +1 -0
  18. package/dist/errors/query-errors.d.ts +31 -0
  19. package/dist/errors/query-errors.d.ts.map +1 -0
  20. package/dist/errors/query-errors.js +11 -0
  21. package/dist/errors/query-errors.js.map +1 -0
  22. package/dist/errors/storage-errors.d.ts +30 -0
  23. package/dist/errors/storage-errors.d.ts.map +1 -0
  24. package/dist/errors/storage-errors.js +11 -0
  25. package/dist/errors/storage-errors.js.map +1 -0
  26. package/dist/factories/crud-factory-with-relationships.d.ts +28 -0
  27. package/dist/factories/crud-factory-with-relationships.d.ts.map +1 -0
  28. package/dist/factories/crud-factory-with-relationships.js +8 -0
  29. package/dist/factories/crud-factory-with-relationships.js.map +1 -0
  30. package/dist/factories/crud-factory.d.ts +25 -0
  31. package/dist/factories/crud-factory.d.ts.map +1 -0
  32. package/dist/factories/crud-factory.js +8 -0
  33. package/dist/factories/crud-factory.js.map +1 -0
  34. package/dist/factories/database-effect.d.ts +241 -0
  35. package/dist/factories/database-effect.d.ts.map +1 -0
  36. package/dist/factories/database-effect.js +859 -0
  37. package/dist/factories/database-effect.js.map +1 -0
  38. package/dist/hooks/hook-runner.d.ts +60 -0
  39. package/dist/hooks/hook-runner.d.ts.map +1 -0
  40. package/dist/hooks/hook-runner.js +107 -0
  41. package/dist/hooks/hook-runner.js.map +1 -0
  42. package/dist/index.d.ts +84 -0
  43. package/dist/index.d.ts.map +1 -0
  44. package/dist/index.js +110 -0
  45. package/dist/index.js.map +1 -0
  46. package/dist/indexes/index-lookup.d.ts +33 -0
  47. package/dist/indexes/index-lookup.d.ts.map +1 -0
  48. package/dist/indexes/index-lookup.js +180 -0
  49. package/dist/indexes/index-lookup.js.map +1 -0
  50. package/dist/indexes/index-manager.d.ts +118 -0
  51. package/dist/indexes/index-manager.d.ts.map +1 -0
  52. package/dist/indexes/index-manager.js +345 -0
  53. package/dist/indexes/index-manager.js.map +1 -0
  54. package/dist/indexes/search-index.d.ts +179 -0
  55. package/dist/indexes/search-index.d.ts.map +1 -0
  56. package/dist/indexes/search-index.js +405 -0
  57. package/dist/indexes/search-index.js.map +1 -0
  58. package/dist/migrations/migration-runner.d.ts +70 -0
  59. package/dist/migrations/migration-runner.d.ts.map +1 -0
  60. package/dist/migrations/migration-runner.js +271 -0
  61. package/dist/migrations/migration-runner.js.map +1 -0
  62. package/dist/migrations/migration-types.d.ts +63 -0
  63. package/dist/migrations/migration-types.d.ts.map +1 -0
  64. package/dist/migrations/migration-types.js +5 -0
  65. package/dist/migrations/migration-types.js.map +1 -0
  66. package/dist/operations/crud/create-with-relationships.d.ts +44 -0
  67. package/dist/operations/crud/create-with-relationships.d.ts.map +1 -0
  68. package/dist/operations/crud/create-with-relationships.js +483 -0
  69. package/dist/operations/crud/create-with-relationships.js.map +1 -0
  70. package/dist/operations/crud/create.d.ts +48 -0
  71. package/dist/operations/crud/create.d.ts.map +1 -0
  72. package/dist/operations/crud/create.js +333 -0
  73. package/dist/operations/crud/create.js.map +1 -0
  74. package/dist/operations/crud/delete-with-relationships.d.ts +63 -0
  75. package/dist/operations/crud/delete-with-relationships.d.ts.map +1 -0
  76. package/dist/operations/crud/delete-with-relationships.js +395 -0
  77. package/dist/operations/crud/delete-with-relationships.js.map +1 -0
  78. package/dist/operations/crud/delete.d.ts +58 -0
  79. package/dist/operations/crud/delete.d.ts.map +1 -0
  80. package/dist/operations/crud/delete.js +267 -0
  81. package/dist/operations/crud/delete.js.map +1 -0
  82. package/dist/operations/crud/unique-check.d.ts +114 -0
  83. package/dist/operations/crud/unique-check.d.ts.map +1 -0
  84. package/dist/operations/crud/unique-check.js +383 -0
  85. package/dist/operations/crud/unique-check.js.map +1 -0
  86. package/dist/operations/crud/update-with-relationships.d.ts +45 -0
  87. package/dist/operations/crud/update-with-relationships.d.ts.map +1 -0
  88. package/dist/operations/crud/update-with-relationships.js +516 -0
  89. package/dist/operations/crud/update-with-relationships.js.map +1 -0
  90. package/dist/operations/crud/update.d.ts +91 -0
  91. package/dist/operations/crud/update.d.ts.map +1 -0
  92. package/dist/operations/crud/update.js +505 -0
  93. package/dist/operations/crud/update.js.map +1 -0
  94. package/dist/operations/crud/upsert.d.ts +52 -0
  95. package/dist/operations/crud/upsert.d.ts.map +1 -0
  96. package/dist/operations/crud/upsert.js +386 -0
  97. package/dist/operations/crud/upsert.js.map +1 -0
  98. package/dist/operations/query/aggregate.d.ts +30 -0
  99. package/dist/operations/query/aggregate.d.ts.map +1 -0
  100. package/dist/operations/query/aggregate.js +227 -0
  101. package/dist/operations/query/aggregate.js.map +1 -0
  102. package/dist/operations/query/cursor-stream.d.ts +18 -0
  103. package/dist/operations/query/cursor-stream.d.ts.map +1 -0
  104. package/dist/operations/query/cursor-stream.js +199 -0
  105. package/dist/operations/query/cursor-stream.js.map +1 -0
  106. package/dist/operations/query/filter-stream.d.ts +12 -0
  107. package/dist/operations/query/filter-stream.d.ts.map +1 -0
  108. package/dist/operations/query/filter-stream.js +167 -0
  109. package/dist/operations/query/filter-stream.js.map +1 -0
  110. package/dist/operations/query/filter.d.ts +13 -0
  111. package/dist/operations/query/filter.d.ts.map +1 -0
  112. package/dist/operations/query/filter.js +267 -0
  113. package/dist/operations/query/filter.js.map +1 -0
  114. package/dist/operations/query/paginate-stream.d.ts +11 -0
  115. package/dist/operations/query/paginate-stream.d.ts.map +1 -0
  116. package/dist/operations/query/paginate-stream.js +22 -0
  117. package/dist/operations/query/paginate-stream.js.map +1 -0
  118. package/dist/operations/query/query-helpers.d.ts +14 -0
  119. package/dist/operations/query/query-helpers.d.ts.map +1 -0
  120. package/dist/operations/query/query-helpers.js +22 -0
  121. package/dist/operations/query/query-helpers.js.map +1 -0
  122. package/dist/operations/query/resolve-computed.d.ts +142 -0
  123. package/dist/operations/query/resolve-computed.d.ts.map +1 -0
  124. package/dist/operations/query/resolve-computed.js +197 -0
  125. package/dist/operations/query/resolve-computed.js.map +1 -0
  126. package/dist/operations/query/search.d.ts +110 -0
  127. package/dist/operations/query/search.d.ts.map +1 -0
  128. package/dist/operations/query/search.js +188 -0
  129. package/dist/operations/query/search.js.map +1 -0
  130. package/dist/operations/query/select-stream.d.ts +27 -0
  131. package/dist/operations/query/select-stream.d.ts.map +1 -0
  132. package/dist/operations/query/select-stream.js +88 -0
  133. package/dist/operations/query/select-stream.js.map +1 -0
  134. package/dist/operations/query/select.d.ts +54 -0
  135. package/dist/operations/query/select.d.ts.map +1 -0
  136. package/dist/operations/query/select.js +159 -0
  137. package/dist/operations/query/select.js.map +1 -0
  138. package/dist/operations/query/sort-stream.d.ts +46 -0
  139. package/dist/operations/query/sort-stream.d.ts.map +1 -0
  140. package/dist/operations/query/sort-stream.js +158 -0
  141. package/dist/operations/query/sort-stream.js.map +1 -0
  142. package/dist/operations/query/sort.d.ts +9 -0
  143. package/dist/operations/query/sort.d.ts.map +1 -0
  144. package/dist/operations/query/sort.js +58 -0
  145. package/dist/operations/query/sort.js.map +1 -0
  146. package/dist/operations/relationships/populate-stream.d.ts +29 -0
  147. package/dist/operations/relationships/populate-stream.d.ts.map +1 -0
  148. package/dist/operations/relationships/populate-stream.js +159 -0
  149. package/dist/operations/relationships/populate-stream.js.map +1 -0
  150. package/dist/operations/relationships/populate.d.ts +15 -0
  151. package/dist/operations/relationships/populate.d.ts.map +1 -0
  152. package/dist/operations/relationships/populate.js +228 -0
  153. package/dist/operations/relationships/populate.js.map +1 -0
  154. package/dist/plugins/plugin-hooks.d.ts +25 -0
  155. package/dist/plugins/plugin-hooks.d.ts.map +1 -0
  156. package/dist/plugins/plugin-hooks.js +64 -0
  157. package/dist/plugins/plugin-hooks.js.map +1 -0
  158. package/dist/plugins/plugin-registry.d.ts +26 -0
  159. package/dist/plugins/plugin-registry.d.ts.map +1 -0
  160. package/dist/plugins/plugin-registry.js +150 -0
  161. package/dist/plugins/plugin-registry.js.map +1 -0
  162. package/dist/plugins/plugin-types.d.ts +95 -0
  163. package/dist/plugins/plugin-types.d.ts.map +1 -0
  164. package/dist/plugins/plugin-types.js +6 -0
  165. package/dist/plugins/plugin-types.js.map +1 -0
  166. package/dist/plugins/plugin-validation.d.ts +49 -0
  167. package/dist/plugins/plugin-validation.d.ts.map +1 -0
  168. package/dist/plugins/plugin-validation.js +295 -0
  169. package/dist/plugins/plugin-validation.js.map +1 -0
  170. package/dist/reactive/change-event.d.ts +44 -0
  171. package/dist/reactive/change-event.d.ts.map +1 -0
  172. package/dist/reactive/change-event.js +49 -0
  173. package/dist/reactive/change-event.js.map +1 -0
  174. package/dist/reactive/change-pubsub.d.ts +32 -0
  175. package/dist/reactive/change-pubsub.d.ts.map +1 -0
  176. package/dist/reactive/change-pubsub.js +31 -0
  177. package/dist/reactive/change-pubsub.js.map +1 -0
  178. package/dist/reactive/evaluate-query.d.ts +62 -0
  179. package/dist/reactive/evaluate-query.d.ts.map +1 -0
  180. package/dist/reactive/evaluate-query.js +57 -0
  181. package/dist/reactive/evaluate-query.js.map +1 -0
  182. package/dist/reactive/watch-by-id.d.ts +53 -0
  183. package/dist/reactive/watch-by-id.d.ts.map +1 -0
  184. package/dist/reactive/watch-by-id.js +55 -0
  185. package/dist/reactive/watch-by-id.js.map +1 -0
  186. package/dist/reactive/watch.d.ts +78 -0
  187. package/dist/reactive/watch.d.ts.map +1 -0
  188. package/dist/reactive/watch.js +133 -0
  189. package/dist/reactive/watch.js.map +1 -0
  190. package/dist/serializers/codecs/hjson.d.ts +33 -0
  191. package/dist/serializers/codecs/hjson.d.ts.map +1 -0
  192. package/dist/serializers/codecs/hjson.js +40 -0
  193. package/dist/serializers/codecs/hjson.js.map +1 -0
  194. package/dist/serializers/codecs/json.d.ts +22 -0
  195. package/dist/serializers/codecs/json.d.ts.map +1 -0
  196. package/dist/serializers/codecs/json.js +28 -0
  197. package/dist/serializers/codecs/json.js.map +1 -0
  198. package/dist/serializers/codecs/json5.d.ts +26 -0
  199. package/dist/serializers/codecs/json5.d.ts.map +1 -0
  200. package/dist/serializers/codecs/json5.js +33 -0
  201. package/dist/serializers/codecs/json5.js.map +1 -0
  202. package/dist/serializers/codecs/jsonc.d.ts +29 -0
  203. package/dist/serializers/codecs/jsonc.d.ts.map +1 -0
  204. package/dist/serializers/codecs/jsonc.js +38 -0
  205. package/dist/serializers/codecs/jsonc.js.map +1 -0
  206. package/dist/serializers/codecs/jsonl.d.ts +17 -0
  207. package/dist/serializers/codecs/jsonl.d.ts.map +1 -0
  208. package/dist/serializers/codecs/jsonl.js +31 -0
  209. package/dist/serializers/codecs/jsonl.js.map +1 -0
  210. package/dist/serializers/codecs/prose.d.ts +419 -0
  211. package/dist/serializers/codecs/prose.d.ts.map +1 -0
  212. package/dist/serializers/codecs/prose.js +1060 -0
  213. package/dist/serializers/codecs/prose.js.map +1 -0
  214. package/dist/serializers/codecs/toml.d.ts +23 -0
  215. package/dist/serializers/codecs/toml.d.ts.map +1 -0
  216. package/dist/serializers/codecs/toml.js +66 -0
  217. package/dist/serializers/codecs/toml.js.map +1 -0
  218. package/dist/serializers/codecs/toon.d.ts +20 -0
  219. package/dist/serializers/codecs/toon.d.ts.map +1 -0
  220. package/dist/serializers/codecs/toon.js +33 -0
  221. package/dist/serializers/codecs/toon.js.map +1 -0
  222. package/dist/serializers/codecs/yaml.d.ts +24 -0
  223. package/dist/serializers/codecs/yaml.d.ts.map +1 -0
  224. package/dist/serializers/codecs/yaml.js +31 -0
  225. package/dist/serializers/codecs/yaml.js.map +1 -0
  226. package/dist/serializers/format-codec.d.ts +53 -0
  227. package/dist/serializers/format-codec.d.ts.map +1 -0
  228. package/dist/serializers/format-codec.js +148 -0
  229. package/dist/serializers/format-codec.js.map +1 -0
  230. package/dist/serializers/presets.d.ts +48 -0
  231. package/dist/serializers/presets.d.ts.map +1 -0
  232. package/dist/serializers/presets.js +72 -0
  233. package/dist/serializers/presets.js.map +1 -0
  234. package/dist/serializers/serializer-service.d.ts +11 -0
  235. package/dist/serializers/serializer-service.d.ts.map +1 -0
  236. package/dist/serializers/serializer-service.js +4 -0
  237. package/dist/serializers/serializer-service.js.map +1 -0
  238. package/dist/state/collection-state.d.ts +19 -0
  239. package/dist/state/collection-state.d.ts.map +1 -0
  240. package/dist/state/collection-state.js +15 -0
  241. package/dist/state/collection-state.js.map +1 -0
  242. package/dist/state/state-operations.d.ts +38 -0
  243. package/dist/state/state-operations.d.ts.map +1 -0
  244. package/dist/state/state-operations.js +65 -0
  245. package/dist/state/state-operations.js.map +1 -0
  246. package/dist/storage/in-memory-adapter-layer.d.ts +16 -0
  247. package/dist/storage/in-memory-adapter-layer.d.ts.map +1 -0
  248. package/dist/storage/in-memory-adapter-layer.js +81 -0
  249. package/dist/storage/in-memory-adapter-layer.js.map +1 -0
  250. package/dist/storage/persistence-effect.d.ts +244 -0
  251. package/dist/storage/persistence-effect.d.ts.map +1 -0
  252. package/dist/storage/persistence-effect.js +551 -0
  253. package/dist/storage/persistence-effect.js.map +1 -0
  254. package/dist/storage/storage-service.d.ts +22 -0
  255. package/dist/storage/storage-service.d.ts.map +1 -0
  256. package/dist/storage/storage-service.js +4 -0
  257. package/dist/storage/storage-service.js.map +1 -0
  258. package/dist/storage/transforms.d.ts +183 -0
  259. package/dist/storage/transforms.d.ts.map +1 -0
  260. package/dist/storage/transforms.js +263 -0
  261. package/dist/storage/transforms.js.map +1 -0
  262. package/dist/transactions/transaction.d.ts +87 -0
  263. package/dist/transactions/transaction.d.ts.map +1 -0
  264. package/dist/transactions/transaction.js +240 -0
  265. package/dist/transactions/transaction.js.map +1 -0
  266. package/dist/types/aggregate-types.d.ts +73 -0
  267. package/dist/types/aggregate-types.d.ts.map +1 -0
  268. package/dist/types/aggregate-types.js +14 -0
  269. package/dist/types/aggregate-types.js.map +1 -0
  270. package/dist/types/computed-types.d.ts +71 -0
  271. package/dist/types/computed-types.d.ts.map +1 -0
  272. package/dist/types/computed-types.js +8 -0
  273. package/dist/types/computed-types.js.map +1 -0
  274. package/dist/types/crud-relationship-types.d.ts +180 -0
  275. package/dist/types/crud-relationship-types.d.ts.map +1 -0
  276. package/dist/types/crud-relationship-types.js +17 -0
  277. package/dist/types/crud-relationship-types.js.map +1 -0
  278. package/dist/types/crud-types.d.ts +343 -0
  279. package/dist/types/crud-types.d.ts.map +1 -0
  280. package/dist/types/crud-types.js +43 -0
  281. package/dist/types/crud-types.js.map +1 -0
  282. package/dist/types/cursor-types.d.ts +52 -0
  283. package/dist/types/cursor-types.d.ts.map +1 -0
  284. package/dist/types/cursor-types.js +2 -0
  285. package/dist/types/cursor-types.js.map +1 -0
  286. package/dist/types/database-config-types.d.ts +196 -0
  287. package/dist/types/database-config-types.d.ts.map +1 -0
  288. package/dist/types/database-config-types.js +11 -0
  289. package/dist/types/database-config-types.js.map +1 -0
  290. package/dist/types/hook-types.d.ts +158 -0
  291. package/dist/types/hook-types.d.ts.map +1 -0
  292. package/dist/types/hook-types.js +6 -0
  293. package/dist/types/hook-types.js.map +1 -0
  294. package/dist/types/index-types.d.ts +42 -0
  295. package/dist/types/index-types.d.ts.map +1 -0
  296. package/dist/types/index-types.js +8 -0
  297. package/dist/types/index-types.js.map +1 -0
  298. package/dist/types/operators.d.ts +5 -0
  299. package/dist/types/operators.d.ts.map +1 -0
  300. package/dist/types/operators.js +297 -0
  301. package/dist/types/operators.js.map +1 -0
  302. package/dist/types/query-overloads.d.ts +54 -0
  303. package/dist/types/query-overloads.d.ts.map +1 -0
  304. package/dist/types/query-overloads.js +3 -0
  305. package/dist/types/query-overloads.js.map +1 -0
  306. package/dist/types/reactive-types.d.ts +75 -0
  307. package/dist/types/reactive-types.d.ts.map +1 -0
  308. package/dist/types/reactive-types.js +7 -0
  309. package/dist/types/reactive-types.js.map +1 -0
  310. package/dist/types/schema-types.d.ts +56 -0
  311. package/dist/types/schema-types.d.ts.map +1 -0
  312. package/dist/types/schema-types.js +8 -0
  313. package/dist/types/schema-types.js.map +1 -0
  314. package/dist/types/search-types.d.ts +82 -0
  315. package/dist/types/search-types.d.ts.map +1 -0
  316. package/dist/types/search-types.js +110 -0
  317. package/dist/types/search-types.js.map +1 -0
  318. package/dist/types/types.d.ts +286 -0
  319. package/dist/types/types.d.ts.map +1 -0
  320. package/dist/types/types.js +2 -0
  321. package/dist/types/types.js.map +1 -0
  322. package/dist/utils/id-generator.d.ts +97 -0
  323. package/dist/utils/id-generator.d.ts.map +1 -0
  324. package/dist/utils/id-generator.js +247 -0
  325. package/dist/utils/id-generator.js.map +1 -0
  326. package/dist/utils/nested-path.d.ts +56 -0
  327. package/dist/utils/nested-path.d.ts.map +1 -0
  328. package/dist/utils/nested-path.js +119 -0
  329. package/dist/utils/nested-path.js.map +1 -0
  330. package/dist/utils/path.d.ts +16 -0
  331. package/dist/utils/path.d.ts.map +1 -0
  332. package/dist/utils/path.js +24 -0
  333. package/dist/utils/path.js.map +1 -0
  334. package/dist/validators/foreign-key.d.ts +49 -0
  335. package/dist/validators/foreign-key.d.ts.map +1 -0
  336. package/dist/validators/foreign-key.js +153 -0
  337. package/dist/validators/foreign-key.js.map +1 -0
  338. package/dist/validators/schema-validator.d.ts +19 -0
  339. package/dist/validators/schema-validator.d.ts.map +1 -0
  340. package/dist/validators/schema-validator.js +34 -0
  341. package/dist/validators/schema-validator.js.map +1 -0
  342. package/package.json +57 -0
@@ -0,0 +1,1060 @@
1
+ // ============================================================================
2
+ // Template Compiler
3
+ // ============================================================================
4
+ /**
5
+ * Compiles a template string into an ordered list of segments.
6
+ * Parses `{fieldName}` placeholders and literal text into segments.
7
+ *
8
+ * @param template - The template string with {fieldName} placeholders
9
+ * @returns A CompiledTemplate with segments and field names
10
+ *
11
+ * @example
12
+ * ```typescript
13
+ * const compiled = compileTemplate('#{id} "{title}" by {author}')
14
+ * // compiled.segments = [
15
+ * // { type: "literal", text: "#" },
16
+ * // { type: "field", name: "id" },
17
+ * // { type: "literal", text: ' "' },
18
+ * // { type: "field", name: "title" },
19
+ * // { type: "literal", text: '" by ' },
20
+ * // { type: "field", name: "author" },
21
+ * // ]
22
+ * // compiled.fields = ["id", "title", "author"]
23
+ * ```
24
+ */
25
+ export const compileTemplate = (template) => {
26
+ const segments = [];
27
+ const fields = [];
28
+ let pos = 0;
29
+ let literalStart = 0;
30
+ let lastSegmentWasField = false;
31
+ while (pos < template.length) {
32
+ const char = template[pos];
33
+ if (char === "{") {
34
+ // Emit any accumulated literal text before this field
35
+ if (pos > literalStart) {
36
+ segments.push({ type: "literal", text: template.slice(literalStart, pos) });
37
+ lastSegmentWasField = false;
38
+ }
39
+ // Check for adjacent fields with no literal separator
40
+ if (lastSegmentWasField) {
41
+ throw new Error(`Adjacent fields with no literal separator at position ${pos}: fields must be separated by literal text`);
42
+ }
43
+ // Find the closing brace
44
+ const closePos = template.indexOf("}", pos + 1);
45
+ if (closePos === -1) {
46
+ throw new Error(`Unclosed brace in template at position ${pos}: "${template.slice(pos)}"`);
47
+ }
48
+ // Extract the field name
49
+ const fieldName = template.slice(pos + 1, closePos);
50
+ if (fieldName.length === 0) {
51
+ throw new Error(`Empty field name in template at position ${pos}`);
52
+ }
53
+ segments.push({ type: "field", name: fieldName });
54
+ fields.push(fieldName);
55
+ lastSegmentWasField = true;
56
+ // Move past the closing brace
57
+ pos = closePos + 1;
58
+ literalStart = pos;
59
+ }
60
+ else {
61
+ pos++;
62
+ }
63
+ }
64
+ // Emit any trailing literal text
65
+ if (pos > literalStart) {
66
+ segments.push({ type: "literal", text: template.slice(literalStart, pos) });
67
+ }
68
+ return { segments, fields };
69
+ };
70
+ /**
71
+ * Compiles an array of overflow template strings into CompiledTemplates.
72
+ * Each overflow template follows the same {fieldName} placeholder syntax as the headline template.
73
+ *
74
+ * @param overflow - Optional array of overflow template strings
75
+ * @returns An array of CompiledTemplate objects, or empty array if no overflow templates
76
+ *
77
+ * @example
78
+ * ```typescript
79
+ * const compiled = compileOverflowTemplates(['tagged {tags}', '~ {description}'])
80
+ * // compiled[0].segments = [
81
+ * // { type: "literal", text: "tagged " },
82
+ * // { type: "field", name: "tags" },
83
+ * // ]
84
+ * // compiled[0].fields = ["tags"]
85
+ * // compiled[1].segments = [
86
+ * // { type: "literal", text: "~ " },
87
+ * // { type: "field", name: "description" },
88
+ * // ]
89
+ * // compiled[1].fields = ["description"]
90
+ * ```
91
+ */
92
+ export const compileOverflowTemplates = (overflow) => {
93
+ if (!overflow || overflow.length === 0) {
94
+ return [];
95
+ }
96
+ return overflow.map((template, index) => {
97
+ try {
98
+ return compileTemplate(template);
99
+ }
100
+ catch (error) {
101
+ if (error instanceof Error) {
102
+ throw new Error(`Error in overflow template at index ${index}: ${error.message}`);
103
+ }
104
+ throw error;
105
+ }
106
+ });
107
+ };
108
+ // ============================================================================
109
+ // Value Serialization
110
+ // ============================================================================
111
+ /**
112
+ * Serializes a value to its prose format string representation.
113
+ *
114
+ * Type mapping:
115
+ * - null/undefined → `~`
116
+ * - boolean → `true` / `false`
117
+ * - number → digit characters (e.g., `42`, `-3.14`)
118
+ * - array → `[a, b, c]` with element quoting for `,` and `]`
119
+ * - string → bare text (quoting for delimiters handled by encodeHeadline)
120
+ *
121
+ * @param value - The value to serialize
122
+ * @returns The serialized string representation
123
+ *
124
+ * @example
125
+ * ```typescript
126
+ * serializeValue(42) // "42"
127
+ * serializeValue(true) // "true"
128
+ * serializeValue(null) // "~"
129
+ * serializeValue("hello") // "hello"
130
+ * serializeValue(["a", "b"]) // "[a, b]"
131
+ * ```
132
+ */
133
+ export const serializeValue = (value) => {
134
+ // null or undefined → tilde
135
+ if (value === null || value === undefined) {
136
+ return "~";
137
+ }
138
+ // boolean → true/false
139
+ if (typeof value === "boolean") {
140
+ return value ? "true" : "false";
141
+ }
142
+ // number → digit representation
143
+ if (typeof value === "number") {
144
+ return String(value);
145
+ }
146
+ // array → [element, element, ...]
147
+ if (Array.isArray(value)) {
148
+ const elements = value.map((element) => {
149
+ const serialized = serializeValue(element);
150
+ // Quote elements that contain comma, closing bracket, or double quote
151
+ if (serialized.includes(",") ||
152
+ serialized.includes("]") ||
153
+ serialized.includes('"')) {
154
+ return `"${serialized.replace(/"/g, '\\"')}"`;
155
+ }
156
+ return serialized;
157
+ });
158
+ return `[${elements.join(", ")}]`;
159
+ }
160
+ // string (or anything else) → bare text
161
+ return String(value);
162
+ };
163
+ /**
164
+ * Deserializes a prose format string back to its typed value.
165
+ * Uses heuristic type detection:
166
+ * - Numbers: matches `/^-?\d+(\.\d+)?$/`
167
+ * - Booleans: exact match `true` or `false`
168
+ * - Null: exact match `~`
169
+ * - Arrays: starts with `[`, ends with `]`
170
+ * - Strings: default (anything not matching above)
171
+ *
172
+ * @param text - The serialized string to deserialize
173
+ * @returns The deserialized value with its inferred type
174
+ *
175
+ * @example
176
+ * ```typescript
177
+ * deserializeValue("42") // 42 (number)
178
+ * deserializeValue("-3.14") // -3.14 (number)
179
+ * deserializeValue("true") // true (boolean)
180
+ * deserializeValue("false") // false (boolean)
181
+ * deserializeValue("~") // null
182
+ * deserializeValue("[a, b, c]") // ["a", "b", "c"] (array)
183
+ * deserializeValue("hello") // "hello" (string)
184
+ * ```
185
+ */
186
+ export const deserializeValue = (text) => {
187
+ // null → tilde
188
+ if (text === "~") {
189
+ return null;
190
+ }
191
+ // boolean → true/false exact match
192
+ if (text === "true") {
193
+ return true;
194
+ }
195
+ if (text === "false") {
196
+ return false;
197
+ }
198
+ // number → matches /^-?\d+(\.\d+)?$/
199
+ const numberRegex = /^-?\d+(\.\d+)?$/;
200
+ if (numberRegex.test(text)) {
201
+ return Number(text);
202
+ }
203
+ // array → starts with [, ends with ]
204
+ if (text.startsWith("[") && text.endsWith("]")) {
205
+ // Extract inner content and parse array elements
206
+ // Task 2.4 will implement full element parsing with quoting support
207
+ // For now, do a simple split respecting quoted elements
208
+ const inner = text.slice(1, -1).trim();
209
+ // Handle empty array
210
+ if (inner === "") {
211
+ return [];
212
+ }
213
+ // Parse array elements (basic version, full implementation in task 2.4)
214
+ return parseArrayElements(inner);
215
+ }
216
+ // default → string
217
+ return text;
218
+ };
219
+ // ============================================================================
220
+ // Headline Encoder
221
+ // ============================================================================
222
+ /**
223
+ * Quotes a value for embedding in a headline.
224
+ * Wraps the value in double quotes and escapes any inner double quotes.
225
+ *
226
+ * @param value - The serialized value to quote
227
+ * @returns The quoted value with escaped inner quotes
228
+ */
229
+ const quoteValue = (value) => {
230
+ return `"${value.replace(/"/g, '\\"')}"`;
231
+ };
232
+ /**
233
+ * Encodes a record into a headline string using a compiled template.
234
+ * Substitutes field values into the template, emitting literals verbatim.
235
+ * For non-last fields, if the serialized value contains the next literal
236
+ * delimiter, the value is quoted to prevent parsing ambiguity.
237
+ *
238
+ * @param record - The record object with field values
239
+ * @param template - The compiled template with segments and fields
240
+ * @returns The encoded headline string
241
+ *
242
+ * @example
243
+ * ```typescript
244
+ * const template = compileTemplate('#{id} "{title}" by {author}')
245
+ * const record = { id: "1", title: "Dune", author: "Frank Herbert" }
246
+ * encodeHeadline(record, template)
247
+ * // → '#1 "Dune" by Frank Herbert'
248
+ *
249
+ * // When value contains the next delimiter:
250
+ * const record2 = { id: "1", title: 'Say "hello"', author: "Test" }
251
+ * encodeHeadline(record2, template)
252
+ * // → '#1 "Say \"hello\"" by Test'
253
+ * ```
254
+ */
255
+ export const encodeHeadline = (record, template) => {
256
+ let result = "";
257
+ const { segments } = template;
258
+ for (let i = 0; i < segments.length; i++) {
259
+ const segment = segments[i];
260
+ if (segment.type === "literal") {
261
+ result += segment.text;
262
+ }
263
+ else {
264
+ // Field segment - serialize the value
265
+ const value = record[segment.name];
266
+ const serialized = serializeValue(value);
267
+ // Find the next literal after this field (if any)
268
+ const nextLiteral = findNextLiteral(segments, i);
269
+ // If this is not the last field (has a subsequent literal delimiter)
270
+ // and the serialized value contains that delimiter, quote it
271
+ if (nextLiteral !== null && serialized.includes(nextLiteral)) {
272
+ result += quoteValue(serialized);
273
+ }
274
+ else {
275
+ result += serialized;
276
+ }
277
+ }
278
+ }
279
+ return result;
280
+ };
281
+ /**
282
+ * Finds the next literal text after a given segment index.
283
+ * Returns null if there is no subsequent literal (meaning this is the last field).
284
+ *
285
+ * @param segments - The template segments
286
+ * @param currentIndex - The current segment index
287
+ * @returns The next literal text, or null if none exists
288
+ */
289
+ const findNextLiteral = (segments, currentIndex) => {
290
+ for (let i = currentIndex + 1; i < segments.length; i++) {
291
+ if (segments[i].type === "literal") {
292
+ return segments[i].text;
293
+ }
294
+ }
295
+ return null;
296
+ };
297
+ // ============================================================================
298
+ // Headline Decoder
299
+ // ============================================================================
300
+ /**
301
+ * Decodes a headline string back to a record using a compiled template.
302
+ * Performs a left-to-right scan matching literals and capturing field text between them.
303
+ * Returns null if the line doesn't match the template structure.
304
+ *
305
+ * @param line - The headline string to decode
306
+ * @param template - The compiled template with segments and fields
307
+ * @returns The decoded record object, or null if the line doesn't match
308
+ *
309
+ * @example
310
+ * ```typescript
311
+ * const template = compileTemplate('#{id} "{title}" by {author}')
312
+ * decodeHeadline('#1 "Dune" by Frank Herbert', template)
313
+ * // → { id: "1", title: "Dune", author: "Frank Herbert" }
314
+ *
315
+ * decodeHeadline('This does not match', template)
316
+ * // → null
317
+ * ```
318
+ */
319
+ export const decodeHeadline = (line, template) => {
320
+ const { segments } = template;
321
+ const result = {};
322
+ let pos = 0;
323
+ for (let i = 0; i < segments.length; i++) {
324
+ const segment = segments[i];
325
+ if (segment.type === "literal") {
326
+ // Check if the literal matches at the current position
327
+ if (!line.startsWith(segment.text, pos)) {
328
+ return null; // No match
329
+ }
330
+ pos += segment.text.length;
331
+ }
332
+ else {
333
+ // Field segment - capture text until the next literal (or end of line)
334
+ const nextLiteralText = findNextLiteralText(segments, i);
335
+ let fieldValue;
336
+ if (nextLiteralText === null) {
337
+ // This is the last field - greedy capture to end of line
338
+ fieldValue = line.slice(pos);
339
+ pos = line.length;
340
+ }
341
+ else {
342
+ // Find the next literal delimiter, respecting quoted values
343
+ const captureResult = captureFieldValue(line, pos, nextLiteralText);
344
+ if (captureResult === null) {
345
+ return null; // Delimiter not found, no match
346
+ }
347
+ fieldValue = captureResult.value;
348
+ pos = captureResult.endPos;
349
+ }
350
+ // Deserialize the captured field value
351
+ result[segment.name] = deserializeFieldValue(fieldValue);
352
+ }
353
+ }
354
+ // Ensure we consumed the entire line
355
+ if (pos !== line.length) {
356
+ return null;
357
+ }
358
+ return result;
359
+ };
360
+ /**
361
+ * Finds the text of the next literal segment after a given index.
362
+ * Returns null if there is no subsequent literal.
363
+ */
364
+ const findNextLiteralText = (segments, currentIndex) => {
365
+ for (let i = currentIndex + 1; i < segments.length; i++) {
366
+ if (segments[i].type === "literal") {
367
+ return segments[i].text;
368
+ }
369
+ }
370
+ return null;
371
+ };
372
+ /**
373
+ * Captures a field value from the line, handling quoted values.
374
+ * If the value starts with a quote, scans for the closing quote (respecting `\"` escapes).
375
+ * Otherwise, scans for the next occurrence of the delimiter.
376
+ *
377
+ * The endPos returned is the position where the delimiter starts (not after it),
378
+ * so the calling loop can process the literal segment normally.
379
+ *
380
+ * @param line - The full line being parsed
381
+ * @param startPos - The starting position for capture
382
+ * @param delimiter - The literal delimiter to find
383
+ * @returns The captured value and position where delimiter starts, or null if not found
384
+ */
385
+ const captureFieldValue = (line, startPos, delimiter) => {
386
+ // Check if the field value is quoted
387
+ if (line[startPos] === '"') {
388
+ // Quoted value - scan for closing quote respecting escapes
389
+ const quoteResult = scanQuotedValue(line, startPos);
390
+ if (quoteResult === null) {
391
+ return null; // Unclosed quote
392
+ }
393
+ // After the quoted value, expect the delimiter
394
+ if (!line.startsWith(delimiter, quoteResult.endPos)) {
395
+ return null; // Delimiter not found after quoted value
396
+ }
397
+ return {
398
+ value: quoteResult.value,
399
+ endPos: quoteResult.endPos, // Position after closing quote, before delimiter
400
+ };
401
+ }
402
+ // Unquoted value - find the next occurrence of the delimiter
403
+ const delimiterPos = line.indexOf(delimiter, startPos);
404
+ if (delimiterPos === -1) {
405
+ return null; // Delimiter not found
406
+ }
407
+ return {
408
+ value: line.slice(startPos, delimiterPos),
409
+ endPos: delimiterPos, // Position where delimiter starts, not ends
410
+ };
411
+ };
412
+ /**
413
+ * Scans a quoted value starting at the given position.
414
+ * Handles escaped quotes (\" inside the quoted string).
415
+ *
416
+ * @param line - The line being parsed
417
+ * @param startPos - The position of the opening quote
418
+ * @returns The unquoted, unescaped value and the position after the closing quote
419
+ */
420
+ const scanQuotedValue = (line, startPos) => {
421
+ // Skip the opening quote
422
+ let pos = startPos + 1;
423
+ let value = "";
424
+ while (pos < line.length) {
425
+ const char = line[pos];
426
+ if (char === "\\") {
427
+ // Escape sequence - check the next character
428
+ if (pos + 1 < line.length) {
429
+ const nextChar = line[pos + 1];
430
+ if (nextChar === '"') {
431
+ // Escaped quote - add the quote to value
432
+ value += '"';
433
+ pos += 2;
434
+ continue;
435
+ }
436
+ }
437
+ // Not an escape sequence, just a backslash
438
+ value += char;
439
+ pos++;
440
+ }
441
+ else if (char === '"') {
442
+ // Closing quote found
443
+ return { value, endPos: pos + 1 };
444
+ }
445
+ else {
446
+ value += char;
447
+ pos++;
448
+ }
449
+ }
450
+ // Unclosed quote
451
+ return null;
452
+ };
453
+ /**
454
+ * Deserializes a field value captured from a headline.
455
+ * This is the same as deserializeValue but handles already-unquoted values.
456
+ *
457
+ * @param fieldValue - The raw field value string
458
+ * @returns The deserialized value
459
+ */
460
+ const deserializeFieldValue = (fieldValue) => {
461
+ return deserializeValue(fieldValue);
462
+ };
463
+ // ============================================================================
464
+ // Overflow Encoder
465
+ // ============================================================================
466
+ /**
467
+ * Default indentation for overflow lines.
468
+ */
469
+ const OVERFLOW_INDENT = " ";
470
+ /**
471
+ * Deeper indentation for continuation lines (multi-line values).
472
+ */
473
+ const CONTINUATION_INDENT = " ";
474
+ /**
475
+ * Checks if any field value in a record contains newlines for the given fields.
476
+ *
477
+ * @param record - The record to check
478
+ * @param fields - The field names to check
479
+ * @returns The first field name with a multi-line value, or null if none
480
+ */
481
+ const findMultiLineField = (record, fields) => {
482
+ for (const fieldName of fields) {
483
+ const value = record[fieldName];
484
+ if (typeof value === "string" && value.includes("\n")) {
485
+ return fieldName;
486
+ }
487
+ }
488
+ return null;
489
+ };
490
+ /**
491
+ * Encodes a record with multi-line field handling.
492
+ * If the field value contains newlines, the first line goes on the template line
493
+ * and subsequent lines become continuation lines with deeper indentation.
494
+ *
495
+ * @param record - The record object with field values
496
+ * @param template - The compiled template
497
+ * @param multiLineField - The field name that contains multi-line content
498
+ * @returns Array of line strings: first line is the overflow line, rest are continuation lines
499
+ */
500
+ const encodeMultiLineOverflow = (record, template, multiLineField) => {
501
+ const value = record[multiLineField];
502
+ if (typeof value !== "string") {
503
+ // Should not happen, but handle gracefully
504
+ return [OVERFLOW_INDENT + encodeHeadline(record, template)];
505
+ }
506
+ const valueLines = value.split("\n");
507
+ const firstLineValue = valueLines[0];
508
+ // Create a modified record with only the first line of the multi-line value
509
+ const modifiedRecord = {
510
+ ...record,
511
+ [multiLineField]: firstLineValue,
512
+ };
513
+ const lines = [];
514
+ // First line: the overflow template with first line of value
515
+ lines.push(OVERFLOW_INDENT + encodeHeadline(modifiedRecord, template));
516
+ // Continuation lines: deeper indented
517
+ for (let i = 1; i < valueLines.length; i++) {
518
+ lines.push(CONTINUATION_INDENT + valueLines[i]);
519
+ }
520
+ return lines;
521
+ };
522
+ /**
523
+ * Encodes overflow fields for a record as indented lines.
524
+ * For each overflow template, if the record has a non-null/non-undefined value
525
+ * for the field in that template, emits an indented line using the template.
526
+ * Overflow fields with null or undefined values are omitted.
527
+ *
528
+ * For multi-line string values (containing newlines), the first line is encoded
529
+ * on the template line, and subsequent lines are emitted as continuation lines
530
+ * with deeper indentation.
531
+ *
532
+ * @param record - The record object with field values
533
+ * @param overflowTemplates - Array of compiled overflow templates
534
+ * @returns Array of indented overflow line strings
535
+ *
536
+ * @example
537
+ * ```typescript
538
+ * const templates = compileOverflowTemplates(['tagged {tags}', '~ {description}'])
539
+ * const record = { id: "1", title: "Dune", tags: ["classic"], description: null }
540
+ * encodeOverflowLines(record, templates)
541
+ * // → [' tagged [classic]']
542
+ * // Note: description is null, so its overflow line is omitted
543
+ *
544
+ * // Multi-line value:
545
+ * const record2 = { id: "1", description: "Line one\nLine two" }
546
+ * encodeOverflowLines(record2, compileOverflowTemplates(['~ {description}']))
547
+ * // → [' ~ Line one', ' Line two']
548
+ * ```
549
+ */
550
+ export const encodeOverflowLines = (record, overflowTemplates) => {
551
+ const lines = [];
552
+ for (const template of overflowTemplates) {
553
+ // Check if any field in this template has a non-null value
554
+ // Overflow templates typically have a single field, but we support multiple
555
+ const hasNonNullValue = template.fields.some((fieldName) => {
556
+ const value = record[fieldName];
557
+ return value !== null && value !== undefined;
558
+ });
559
+ if (hasNonNullValue) {
560
+ // Check for multi-line field values
561
+ const multiLineField = findMultiLineField(record, template.fields);
562
+ if (multiLineField !== null) {
563
+ // Handle multi-line value with continuation lines
564
+ const overflowLines = encodeMultiLineOverflow(record, template, multiLineField);
565
+ lines.push(...overflowLines);
566
+ }
567
+ else {
568
+ // Single-line value: encode normally
569
+ const overflowLine = encodeHeadline(record, template);
570
+ lines.push(OVERFLOW_INDENT + overflowLine);
571
+ }
572
+ }
573
+ }
574
+ return lines;
575
+ };
576
+ /**
577
+ * Measures the indentation level of a line (number of leading spaces/tabs).
578
+ *
579
+ * @param line - The line to measure
580
+ * @returns The number of leading whitespace characters
581
+ */
582
+ const measureIndent = (line) => {
583
+ let indent = 0;
584
+ for (const char of line) {
585
+ if (char === " " || char === "\t") {
586
+ indent++;
587
+ }
588
+ else {
589
+ break;
590
+ }
591
+ }
592
+ return indent;
593
+ };
594
+ /**
595
+ * Decodes overflow lines for a record using the configured overflow templates.
596
+ * Collects indented lines belonging to the record, tries each overflow template
597
+ * in order, skips on non-match, and captures field values on match.
598
+ *
599
+ * For each indented line:
600
+ * 1. Try matching against each overflow template (in order)
601
+ * 2. If a template matches, capture the field values and move to next line
602
+ * 3. If no template matches, check if it's a continuation line (deeper indentation)
603
+ * 4. Continuation lines are appended to the previous field's value with newline
604
+ *
605
+ * @param lines - Array of indented lines (already collected for this record)
606
+ * @param overflowTemplates - Array of compiled overflow templates
607
+ * @param baseIndent - The expected indentation level for overflow lines (default: 2)
608
+ * @returns The decoded field values and number of lines consumed
609
+ *
610
+ * @example
611
+ * ```typescript
612
+ * const templates = compileOverflowTemplates(['tagged {tags}', '~ {description}'])
613
+ * const lines = [' tagged [sci-fi]', ' ~ A classic novel']
614
+ * const result = decodeOverflowLines(lines, templates)
615
+ * // → { fields: { tags: ['sci-fi'], description: 'A classic novel' }, linesConsumed: 2 }
616
+ * ```
617
+ */
618
+ export const decodeOverflowLines = (lines, overflowTemplates, baseIndent = 2) => {
619
+ const fields = {};
620
+ let lineIndex = 0;
621
+ let lastMatchedField = null;
622
+ while (lineIndex < lines.length) {
623
+ const line = lines[lineIndex];
624
+ const indent = measureIndent(line);
625
+ // Check if line is indented enough to be part of this record's overflow
626
+ if (indent < baseIndent) {
627
+ // Line is not indented enough, stop processing
628
+ break;
629
+ }
630
+ // Check if this is a continuation line (deeper indentation than base)
631
+ if (indent > baseIndent && lastMatchedField !== null) {
632
+ // Continuation line - append to the last matched field
633
+ const existingValue = fields[lastMatchedField];
634
+ const continuationContent = line.slice(indent); // Strip all leading whitespace
635
+ if (typeof existingValue === "string") {
636
+ fields[lastMatchedField] = existingValue + "\n" + continuationContent;
637
+ }
638
+ else {
639
+ // Shouldn't happen in well-formed input, but handle it
640
+ fields[lastMatchedField] = String(existingValue) + "\n" + continuationContent;
641
+ }
642
+ lineIndex++;
643
+ continue;
644
+ }
645
+ // Strip the base indentation to get the content
646
+ const content = line.slice(baseIndent);
647
+ // Try each overflow template in order
648
+ let matched = false;
649
+ for (const template of overflowTemplates) {
650
+ const decoded = decodeHeadline(content, template);
651
+ if (decoded !== null) {
652
+ // Template matched - merge the decoded fields
653
+ for (const [fieldName, value] of Object.entries(decoded)) {
654
+ fields[fieldName] = value;
655
+ // Track the last matched field for continuation lines
656
+ // (typically the last field in the template, often the only one)
657
+ lastMatchedField = fieldName;
658
+ }
659
+ matched = true;
660
+ break;
661
+ }
662
+ }
663
+ if (!matched) {
664
+ // No template matched - this could be:
665
+ // 1. A malformed overflow line (skip it)
666
+ // 2. Or we're past the record's overflow section
667
+ // For robustness, we skip and continue trying
668
+ // If it's deeply indented, it might be a continuation without a prior match
669
+ if (indent > baseIndent && lastMatchedField !== null) {
670
+ // Treat as continuation anyway
671
+ const existingValue = fields[lastMatchedField];
672
+ const continuationContent = line.slice(indent);
673
+ if (typeof existingValue === "string") {
674
+ fields[lastMatchedField] = existingValue + "\n" + continuationContent;
675
+ }
676
+ }
677
+ // If no match and no prior field, we just skip this line
678
+ }
679
+ lineIndex++;
680
+ }
681
+ return {
682
+ fields,
683
+ linesConsumed: lineIndex,
684
+ };
685
+ };
686
+ /**
687
+ * Scans a document for the @prose directive.
688
+ * The directive is a line starting with `@prose ` (note the trailing space).
689
+ *
690
+ * Rules:
691
+ * - Exactly one @prose directive must exist in the file
692
+ * - If no directive is found, throws an error
693
+ * - If multiple directives are found, throws an error
694
+ * - All lines before the directive are preamble
695
+ *
696
+ * @param lines - Array of lines from the document
697
+ * @returns The position information for preamble and directive
698
+ * @throws Error if no directive found or multiple directives found
699
+ *
700
+ * @example
701
+ * ```typescript
702
+ * const lines = ['# My Books', '', '@prose #{id} {title}', '#1 Dune']
703
+ * const result = scanDirective(lines)
704
+ * // → { preambleEnd: 1, directiveStart: 2 }
705
+ *
706
+ * const linesNoPreable = ['@prose #{id} {title}', '#1 Dune']
707
+ * const result2 = scanDirective(linesNoPreable)
708
+ * // → { preambleEnd: -1, directiveStart: 0 }
709
+ * ```
710
+ */
711
+ export const scanDirective = (lines) => {
712
+ let directiveIndex = null;
713
+ for (let i = 0; i < lines.length; i++) {
714
+ const line = lines[i];
715
+ // Check if this line starts with "@prose "
716
+ if (line.startsWith("@prose ")) {
717
+ if (directiveIndex !== null) {
718
+ // Multiple directives found
719
+ throw new Error(`Multiple @prose directives found: first at line ${directiveIndex + 1}, second at line ${i + 1}. Only one directive per file is allowed.`);
720
+ }
721
+ directiveIndex = i;
722
+ }
723
+ }
724
+ if (directiveIndex === null) {
725
+ throw new Error("No @prose directive found. The file must contain a line starting with '@prose ' to define the record template.");
726
+ }
727
+ return {
728
+ preambleEnd: directiveIndex > 0 ? directiveIndex - 1 : -1,
729
+ directiveStart: directiveIndex,
730
+ };
731
+ };
732
+ /**
733
+ * Parses a directive block from the document.
734
+ * Extracts the headline template from the @prose line and collects
735
+ * any indented overflow templates that immediately follow.
736
+ *
737
+ * The directive block structure:
738
+ * ```
739
+ * @prose #{id} "{title}" by {author} ← headline template
740
+ * tagged {tags} ← overflow template 1
741
+ * ~ {description} ← overflow template 2
742
+ * ← blank line or non-indented = end of block
743
+ * ```
744
+ *
745
+ * Overflow templates are lines that:
746
+ * - Immediately follow the @prose line (no blank lines between)
747
+ * - Are indented (start with whitespace)
748
+ *
749
+ * @param lines - Array of lines from the document
750
+ * @param directiveStart - Index of the @prose directive line
751
+ * @returns The parsed directive block with template strings and body start index
752
+ *
753
+ * @example
754
+ * ```typescript
755
+ * const lines = [
756
+ * '@prose #{id} "{title}"',
757
+ * ' tagged {tags}',
758
+ * ' ~ {description}',
759
+ * '',
760
+ * '#1 "Dune"',
761
+ * ]
762
+ * const result = parseDirectiveBlock(lines, 0)
763
+ * // → {
764
+ * // headlineTemplate: '#{id} "{title}"',
765
+ * // overflowTemplates: ['tagged {tags}', '~ {description}'],
766
+ * // bodyStart: 3
767
+ * // }
768
+ * ```
769
+ */
770
+ export const parseDirectiveBlock = (lines, directiveStart) => {
771
+ const directiveLine = lines[directiveStart];
772
+ // Extract headline template: everything after "@prose "
773
+ const headlineTemplate = directiveLine.slice("@prose ".length);
774
+ // Collect overflow templates: indented lines immediately following
775
+ const overflowTemplates = [];
776
+ let lineIndex = directiveStart + 1;
777
+ while (lineIndex < lines.length) {
778
+ const line = lines[lineIndex];
779
+ // Check if line is indented (starts with whitespace)
780
+ if (line.length > 0 && (line[0] === " " || line[0] === "\t")) {
781
+ // This is an overflow template - strip leading whitespace
782
+ const templateContent = line.trimStart();
783
+ overflowTemplates.push(templateContent);
784
+ lineIndex++;
785
+ }
786
+ else {
787
+ // Not indented or empty line - end of directive block
788
+ break;
789
+ }
790
+ }
791
+ return {
792
+ headlineTemplate,
793
+ overflowTemplates,
794
+ bodyStart: lineIndex,
795
+ };
796
+ };
797
+ /**
798
+ * Parses the body section of a prose document.
799
+ * Iterates lines after the directive block and classifies each as:
800
+ * - Record headline (matches the compiled template)
801
+ * - Indented overflow/continuation (part of the current record)
802
+ * - Pass-through text (doesn't match, preserved verbatim)
803
+ *
804
+ * @param lines - Array of lines from the document
805
+ * @param bodyStart - Index of the first line of the body (after directive block)
806
+ * @param headlineTemplate - The compiled headline template
807
+ * @returns The parsed body with interleaved records and pass-through text
808
+ *
809
+ * @example
810
+ * ```typescript
811
+ * const lines = [
812
+ * '@prose #{id} "{title}"',
813
+ * '',
814
+ * '## Science Fiction',
815
+ * '#1 "Dune"',
816
+ * ' tagged [classic]',
817
+ * '#2 "Neuromancer"',
818
+ * '',
819
+ * '## Fantasy',
820
+ * '#3 "The Hobbit"',
821
+ * ]
822
+ * const template = compileTemplate('#{id} "{title}"')
823
+ * const result = parseBody(lines, 1, template)
824
+ * // → {
825
+ * // entries: [
826
+ * // { type: "passthrough", lines: ["", "## Science Fiction"] },
827
+ * // { type: "record", fields: { id: "1", title: "Dune" }, headline: '#1 "Dune"', overflowLines: [" tagged [classic]"] },
828
+ * // { type: "record", fields: { id: "2", title: "Neuromancer" }, headline: '#2 "Neuromancer"', overflowLines: [] },
829
+ * // { type: "passthrough", lines: ["", "## Fantasy"] },
830
+ * // { type: "record", fields: { id: "3", title: "The Hobbit" }, headline: '#3 "The Hobbit"', overflowLines: [] },
831
+ * // ]
832
+ * // }
833
+ * ```
834
+ */
835
+ export const parseBody = (lines, bodyStart, headlineTemplate) => {
836
+ const entries = [];
837
+ let lineIndex = bodyStart;
838
+ let currentPassthrough = [];
839
+ // Helper to flush accumulated pass-through lines into an entry
840
+ const flushPassthrough = () => {
841
+ if (currentPassthrough.length > 0) {
842
+ entries.push({
843
+ type: "passthrough",
844
+ lines: [...currentPassthrough],
845
+ });
846
+ currentPassthrough = [];
847
+ }
848
+ };
849
+ while (lineIndex < lines.length) {
850
+ const line = lines[lineIndex];
851
+ // Check if this line is indented (starts with whitespace)
852
+ if (line.length > 0 && (line[0] === " " || line[0] === "\t")) {
853
+ // Indented line — belongs to the previous record's overflow
854
+ // If we have a current record (last entry is a record), add to its overflow
855
+ // Otherwise, treat as pass-through (malformed input)
856
+ const lastEntry = entries[entries.length - 1];
857
+ if (lastEntry && lastEntry.type === "record") {
858
+ // Add to the record's overflow lines (we need to mutate, so cast)
859
+ lastEntry.overflowLines.push(line);
860
+ }
861
+ else {
862
+ // No record to attach to — treat as pass-through
863
+ currentPassthrough.push(line);
864
+ }
865
+ lineIndex++;
866
+ continue;
867
+ }
868
+ // Not indented — try to match against the headline template
869
+ const decoded = decodeHeadline(line, headlineTemplate);
870
+ if (decoded !== null) {
871
+ // Line matches the template — it's a record headline
872
+ // First, flush any accumulated pass-through
873
+ flushPassthrough();
874
+ // Create a new record entry
875
+ entries.push({
876
+ type: "record",
877
+ fields: decoded,
878
+ headline: line,
879
+ overflowLines: [],
880
+ });
881
+ lineIndex++;
882
+ }
883
+ else {
884
+ // Line doesn't match — it's pass-through text
885
+ currentPassthrough.push(line);
886
+ lineIndex++;
887
+ }
888
+ }
889
+ // Flush any remaining pass-through lines
890
+ flushPassthrough();
891
+ return { entries };
892
+ };
893
+ /**
894
+ * Compiles prose codec options into internal state.
895
+ * Called once at codec construction time.
896
+ *
897
+ * @param options - The prose codec options
898
+ * @returns The compiled codec state
899
+ */
900
+ const compileProseCodecOptions = (options) => {
901
+ const headlineTemplate = compileTemplate(options.template);
902
+ const overflowTemplates = compileOverflowTemplates(options.overflow);
903
+ return {
904
+ headlineTemplate,
905
+ overflowTemplates,
906
+ rawHeadlineTemplate: options.template,
907
+ rawOverflowTemplates: options.overflow ?? [],
908
+ };
909
+ };
910
+ /**
911
+ * Creates a prose format codec for human-readable, template-driven serialization.
912
+ *
913
+ * The prose format uses a `@prose` directive to define a sentence-like pattern
914
+ * mapping field names to positions within literal delimiter text. Records follow
915
+ * this pattern, producing human-readable lines.
916
+ *
917
+ * Templates use `{fieldName}` placeholders mixed with literal text:
918
+ * ```
919
+ * @prose #{id} "{title}" by {authorId} ({year}) — {genre}
920
+ * tagged {tags}
921
+ * ~ {description}
922
+ * ```
923
+ *
924
+ * The codec compiles templates at construction time and returns a standard
925
+ * FormatCodec with encode/decode functions.
926
+ *
927
+ * @param options - Codec configuration with headline and overflow templates
928
+ * @param options.template - The headline template with {fieldName} placeholders
929
+ * @param options.overflow - Optional array of overflow templates for additional fields
930
+ * @returns A FormatCodec for prose serialization
931
+ *
932
+ * @example
933
+ * ```typescript
934
+ * const codec = proseCodec({
935
+ * template: '#{id} "{title}" by {author}',
936
+ * overflow: ['tagged {tags}', '~ {description}'],
937
+ * })
938
+ *
939
+ * const layer = makeSerializerLayer([codec])
940
+ *
941
+ * // Encoded output:
942
+ * // @prose #{id} "{title}" by {author}
943
+ * // tagged {tags}
944
+ * // ~ {description}
945
+ * //
946
+ * // #1 "Dune" by Frank Herbert
947
+ * // tagged [sci-fi, classic]
948
+ * // ~ A masterpiece of science fiction
949
+ * ```
950
+ */
951
+ export const proseCodec = (options) => {
952
+ // Compile templates at construction time
953
+ const compiled = compileProseCodecOptions(options);
954
+ return {
955
+ name: "prose",
956
+ extensions: ["prose"],
957
+ encode: (data, _formatOptions) => {
958
+ if (!Array.isArray(data)) {
959
+ throw new Error("Prose codec expects an array of records to encode");
960
+ }
961
+ const lines = [];
962
+ // Write the @prose directive
963
+ lines.push(`@prose ${compiled.rawHeadlineTemplate}`);
964
+ // Write overflow template declarations (indented)
965
+ for (const overflowTemplate of compiled.rawOverflowTemplates) {
966
+ lines.push(` ${overflowTemplate}`);
967
+ }
968
+ // Blank line to separate directive block from body
969
+ lines.push("");
970
+ // Encode each record
971
+ for (const record of data) {
972
+ // Encode headline
973
+ const headline = encodeHeadline(record, compiled.headlineTemplate);
974
+ lines.push(headline);
975
+ // Encode overflow lines
976
+ const overflowLines = encodeOverflowLines(record, compiled.overflowTemplates);
977
+ lines.push(...overflowLines);
978
+ }
979
+ return lines.join("\n");
980
+ },
981
+ decode: (raw) => {
982
+ const lines = raw.split("\n");
983
+ // Scan for the directive
984
+ const scanResult = scanDirective(lines);
985
+ // Parse the directive block
986
+ const directiveBlock = parseDirectiveBlock(lines, scanResult.directiveStart);
987
+ // Compile the file's headline template for parsing
988
+ // Note: We use the file's template for decoding, ensuring self-describing files work
989
+ const fileHeadlineTemplate = compileTemplate(directiveBlock.headlineTemplate);
990
+ const fileOverflowTemplates = compileOverflowTemplates(directiveBlock.overflowTemplates);
991
+ // Parse the body
992
+ const bodyResult = parseBody(lines, directiveBlock.bodyStart, fileHeadlineTemplate);
993
+ // Extract records from entries, decoding overflow fields
994
+ const records = [];
995
+ for (const entry of bodyResult.entries) {
996
+ if (entry.type === "record") {
997
+ // Start with headline fields
998
+ const record = { ...entry.fields };
999
+ // Decode overflow lines to get additional fields
1000
+ if (entry.overflowLines.length > 0) {
1001
+ const overflowResult = decodeOverflowLines(entry.overflowLines, fileOverflowTemplates);
1002
+ // Merge overflow fields into the record
1003
+ for (const [fieldName, value] of Object.entries(overflowResult.fields)) {
1004
+ record[fieldName] = value;
1005
+ }
1006
+ }
1007
+ records.push(record);
1008
+ }
1009
+ // Pass-through entries are skipped (v1: not preserved through re-encode)
1010
+ }
1011
+ return records;
1012
+ },
1013
+ };
1014
+ };
1015
+ // ============================================================================
1016
+ // Array Parsing Helpers
1017
+ // ============================================================================
1018
+ /**
1019
+ * Parses array element string into an array of deserialized values.
1020
+ * Handles quoted elements that may contain commas or brackets.
1021
+ *
1022
+ * @param inner - The content between [ and ]
1023
+ * @returns Array of deserialized values
1024
+ */
1025
+ const parseArrayElements = (inner) => {
1026
+ const elements = [];
1027
+ let pos = 0;
1028
+ let elementStart = 0;
1029
+ let inQuotes = false;
1030
+ while (pos <= inner.length) {
1031
+ if (pos === inner.length || (!inQuotes && inner[pos] === ",")) {
1032
+ // Extract and trim the element
1033
+ const element = inner.slice(elementStart, pos).trim();
1034
+ if (element !== "") {
1035
+ // Handle quoted elements
1036
+ if (element.startsWith('"') && element.endsWith('"')) {
1037
+ // Remove quotes and unescape
1038
+ const unquoted = element.slice(1, -1).replace(/\\"/g, '"');
1039
+ elements.push(deserializeValue(unquoted));
1040
+ }
1041
+ else {
1042
+ elements.push(deserializeValue(element));
1043
+ }
1044
+ }
1045
+ elementStart = pos + 1;
1046
+ pos++;
1047
+ continue;
1048
+ }
1049
+ if (inner[pos] === '"' && (pos === 0 || inner[pos - 1] !== "\\")) {
1050
+ // Check if this is at the start of an element (accounting for whitespace)
1051
+ const elementSoFar = inner.slice(elementStart, pos).trim();
1052
+ if (elementSoFar === "" || inQuotes) {
1053
+ inQuotes = !inQuotes;
1054
+ }
1055
+ }
1056
+ pos++;
1057
+ }
1058
+ return elements;
1059
+ };
1060
+ //# sourceMappingURL=prose.js.map