@tonguetoquill/collection 0.1.13 → 0.1.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tonguetoquill/collection",
3
- "version": "0.1.13",
3
+ "version": "0.1.15",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/nibsbin/tonguetoquill-collection.git"
@@ -22,7 +22,7 @@
22
22
  "type": "module",
23
23
  "scripts": {
24
24
  "update-subtrees": "node scripts/update-subtrees.mjs",
25
- "test": "node scripts/test.mjs",
25
+ "test": "node scripts/check-duplicates.mjs && node scripts/validate-quills.mjs",
26
26
  "build": "echo \"No build specified\"",
27
27
  "preversion": "npm test",
28
28
  "postversion": "git push --follow-tags",
@@ -35,7 +35,7 @@
35
35
  },
36
36
  "dependencies": {},
37
37
  "devDependencies": {
38
- "@quillmark/registry": "^0.5.3",
38
+ "@quillmark/registry": "^0.6.1",
39
39
  "@quillmark/wasm": "^0.39.0"
40
40
  }
41
41
  }
@@ -31,115 +31,67 @@
31
31
  paragraph-config.numbering-formats.at(level, default: "i.")
32
32
  }
33
33
 
34
- /// Generates paragraph number for a given level with proper formatting.
35
- ///
36
- /// Creates properly formatted paragraph numbers for the hierarchical numbering
37
- /// system using Typst's native counter display capabilities.
38
- ///
39
- /// - level (int): Paragraph nesting level (0-based)
40
- /// - counter-value (none | int): Optional explicit counter value to use (for measuring widths)
41
- /// - increment (bool): Whether to increment the counter after display
42
- /// -> content
43
- #let generate-paragraph-number(level, counter-value: none) = {
44
- let paragraph-counter = counter(paragraph-config.counter-prefix + str(level))
45
- let numbering-format = get-paragraph-numbering-format(level)
46
-
47
- if counter-value != none {
48
- // For measuring widths: create temporary counter at specific value
49
- assert(counter-value >= 0, message: "Counter value of `" + str(counter-value) + "` cannot be less than 0")
50
- let temp-counter = counter("temp-counter")
51
- temp-counter.update(counter-value)
52
- temp-counter.display(numbering-format)
53
- } else {
54
- // Standard case: display and increment
55
- let result = paragraph-counter.display(numbering-format)
56
- paragraph-counter.step()
57
- result
58
- }
59
- }
60
-
61
- /// Calculates proper indentation width for a paragraph level.
62
- ///
63
- /// AFH 33-337 "The Text of the Official Memorandum" §4-5:
64
- /// - "The first paragraph is never indented; it is numbered and flush left"
65
- /// - "Indent the first line of sub-paragraphs to align the number or letter with
66
- /// the first character of its parent level paragraph"
34
+ /// Calculates indentation width using explicit counter values.
67
35
  ///
68
36
  /// Computes the exact indentation needed for hierarchical paragraph alignment
69
37
  /// by measuring the cumulative width of all ancestor paragraph numbers and their
70
- /// spacing. Uses direct iteration instead of recursion for better performance.
71
- ///
72
- /// Per AFH 33-337: Sub-paragraph text aligns with first character of parent text,
73
- /// which means indentation = sum of all ancestor number widths + spacing.
38
+ /// spacing. Uses the provided counter values directly (no Typst counter reads).
74
39
  ///
75
40
  /// - level (int): Paragraph nesting level (0-based)
41
+ /// - level-counts (dictionary): Maps level index strings to their current counter values
76
42
  /// -> length
77
- #let calculate-paragraph-indent(level) = {
78
- assert(level >= 0)
43
+ #let calculate-indent-from-counts(level, level-counts) = {
79
44
  if level == 0 {
80
45
  return 0pt
81
46
  }
82
-
83
- // Accumulate widths of all ancestor numbers iteratively
84
47
  let total-indent = 0pt
85
48
  for ancestor-level in range(level) {
86
- let ancestor-counter-value = counter(paragraph-config.counter-prefix + str(ancestor-level)).get().at(0)
87
- let ancestor-number = generate-paragraph-number(ancestor-level, counter-value: ancestor-counter-value)
88
- // Measure number + spacing buffer
49
+ let ancestor-value = level-counts.at(str(ancestor-level), default: 1)
50
+ let ancestor-format = get-paragraph-numbering-format(ancestor-level)
51
+ let ancestor-number = numbering(ancestor-format, ancestor-value)
89
52
  let width = measure([#ancestor-number#" "]).width
90
53
  total-indent += width
91
54
  }
92
-
93
55
  total-indent
94
56
  }
95
57
 
96
- /// Global state for tracking current paragraph level.
97
- /// Used internally by the paragraph numbering system to maintain proper nesting.
98
- /// -> state
99
- #let PAR_LEVEL_STATE = state("PAR_LEVEL", 0)
100
-
101
-
102
- /// Sets the current paragraph level state.
58
+ /// Formats a numbered paragraph with proper indentation.
103
59
  ///
104
- /// Internal function used by the paragraph numbering system to track
105
- /// the current nesting level for proper indentation and numbering.
60
+ /// Generates a properly formatted paragraph with AFH 33-337 compliant numbering
61
+ /// and indentation. Uses explicit level and counter values to avoid nested-context
62
+ /// state propagation issues.
106
63
  ///
107
- /// - level (int): Paragraph nesting level to set
64
+ /// - body (content): Paragraph content to format
65
+ /// - level (int): Paragraph nesting level (0-based)
66
+ /// - level-counts (dictionary): Current counter values per level
108
67
  /// -> content
109
- #let SET_PAR_LEVEL(level) = {
110
- context {
111
- PAR_LEVEL_STATE.update(level)
112
- if level == 0 {
113
- counter(paragraph-config.counter-prefix + str(level + 1)).update(1)
114
- }
115
- }
68
+ #let format-numbered-par(body, level, level-counts) = {
69
+ let current-value = level-counts.at(str(level), default: 1)
70
+ let format = get-paragraph-numbering-format(level)
71
+ let number-text = numbering(format, current-value)
72
+ let indent-width = calculate-indent-from-counts(level, level-counts)
73
+ [#h(indent-width)#number-text#" "#body]
116
74
  }
117
75
 
118
- /// Creates a formatted paragraph with automatic numbering and indentation.
119
- ///
120
- /// Generates a properly formatted paragraph with AFH 33-337 compliant numbering,
121
- /// indentation, and spacing. Automatically manages counter incrementation and
122
- /// nested paragraph state. Used internally by the body rendering system.
76
+ /// Formats a continuation paragraph within a multi-block list item.
123
77
  ///
124
- /// Features:
125
- /// - Automatic paragraph number generation and formatting
126
- /// - Proper indentation based on nesting level via direct width measurement
127
- /// - Counter management for hierarchical numbering
128
- /// - Widow/orphan prevention settings
78
+ /// Renders a paragraph that belongs to the same list item as the preceding
79
+ /// numbered paragraph. The text is indented to align with the first character
80
+ /// of the preceding numbered paragraph's text (past the number and spacing),
81
+ /// but no new number is generated.
129
82
  ///
130
- /// - content (content): Paragraph content to format
83
+ /// - body (content): Continuation paragraph content to format
84
+ /// - level (int): Paragraph nesting level (0-based)
85
+ /// - level-counts (dictionary): Current counter values per level
131
86
  /// -> content
132
- #let memo-par(content) = context {
133
- let level = PAR_LEVEL_STATE.get()
134
- let paragraph-number = generate-paragraph-number(level)
135
- // Reset child level counter
136
- counter(paragraph-config.counter-prefix + str(level + 1)).update(1)
137
- let indent-width = calculate-paragraph-indent(level)
138
-
139
-
140
- // Number + two spaces + content, with left padding for nesting
141
- //pad(left: indent-width, paragraph-number + " " + content)
142
- [#h(indent-width)#paragraph-number#" "#content]
87
+ #let format-continuation-par(body, level, level-counts) = {
88
+ let indent-width = calculate-indent-from-counts(level, level-counts)
89
+ // Add the width of the current level's number + spacing to align with text
90
+ let current-value = level-counts.at(str(level), default: 1)
91
+ let format = get-paragraph-numbering-format(level)
92
+ let number-text = numbering(format, current-value)
93
+ let number-width = measure([#number-text#" "]).width
94
+ [#h(indent-width + number-width)#body]
143
95
  }
144
96
 
145
97
  // =============================================================================
@@ -160,10 +112,11 @@
160
112
  NEST_UP.update(0)
161
113
  let IS_HEADING = state("IS_HEADING")
162
114
  IS_HEADING.update(false)
163
- // Initialize level counters to 1 (Typst counters default to 0)
164
- for i in range(0, 5) {
165
- counter(paragraph-config.counter-prefix + "0").update(1)
166
- }
115
+ // Tracks whether the next paragraph is the first block in a list item.
116
+ // When true, the next `show par` captures a numbered item; subsequent
117
+ // paragraphs within the same item are continuations (no new number).
118
+ let ITEM_FIRST_PAR = state("ITEM_FIRST_PAR")
119
+ ITEM_FIRST_PAR.update(false)
167
120
 
168
121
  // The first pass parses paragraphs, list items, etc. into standardized arrays
169
122
  let first_pass = {
@@ -171,18 +124,36 @@
171
124
  show par: p => context {
172
125
  let nest_level = NEST_DOWN.get().at(0) - NEST_UP.get().at(0)
173
126
  let is_heading = IS_HEADING.get()
127
+ let is_first_par = ITEM_FIRST_PAR.get()
128
+
129
+ // Determine if this is a continuation block within a multi-block list item.
130
+ // A continuation is a non-first paragraph inside a list item (nest_level > 0).
131
+ let is_continuation = nest_level > 0 and not is_first_par
174
132
 
175
133
  PAR_BUFFER.update(pars => {
176
- // Item tuple: (content, nest_level, is_heading, is_table)
177
- pars.push((text([#p.body]), nest_level, is_heading, false))
134
+ pars.push((
135
+ content: text([#p.body]),
136
+ nest_level: nest_level,
137
+ kind: if is_heading { "heading" } else if is_continuation { "continuation" } else { "par" },
138
+ ))
178
139
  pars
179
140
  })
141
+
142
+ // After the first paragraph of a list item, mark subsequent ones as continuations
143
+ if nest_level > 0 and is_first_par {
144
+ ITEM_FIRST_PAR.update(false)
145
+ }
146
+
180
147
  p
181
148
  }
182
149
  // Collect tables — captured as-is without paragraph numbering
183
150
  show table: t => context {
184
151
  PAR_BUFFER.update(pars => {
185
- pars.push((t, -1, false, true))
152
+ pars.push((
153
+ content: t,
154
+ nest_level: -1,
155
+ kind: "table",
156
+ ))
186
157
  pars
187
158
  })
188
159
  t
@@ -199,11 +170,13 @@
199
170
  // layout convergence issues with many list items
200
171
  show enum.item: it => {
201
172
  NEST_DOWN.step()
173
+ ITEM_FIRST_PAR.update(true)
202
174
  [#parbreak()#it.body#parbreak()]
203
175
  NEST_UP.step()
204
176
  }
205
177
  show list.item: it => {
206
178
  NEST_DOWN.step()
179
+ ITEM_FIRST_PAR.update(true)
207
180
  [#parbreak()#it.body#parbreak()]
208
181
  NEST_UP.step()
209
182
  }
@@ -231,71 +204,106 @@
231
204
  // Use place() to prevent hidden content from affecting layout flow
232
205
  place(hide(first_pass))
233
206
 
234
- //Second pass: consume par buffer
207
+ // Second pass: consume par buffer
235
208
  //
236
- // PAR_BUFFER item tuple layout:
237
- // item.at(0) content : the paragraph body or table element
238
- // item.at(1)nest_level : nesting depth (−1 for tables)
239
- // item.at(2) is_heading : bool, true if item is a heading paragraph
240
- // item.at(3) — is_table : bool, true if item is a table element
241
- let ITEM_IS_TABLE = 3
209
+ // PAR_BUFFER item dictionary layout:
210
+ // item.content — the paragraph body or table element
211
+ // item.nest_level — nesting depth (−1 for tables)
212
+ // item.kind "par", "heading", "table", or "continuation"
242
213
  context {
243
214
  let heading_buffer = none
244
- // Tables do not count as paragraphs for AFH 33-337 §2 numbering purposes
245
- let par_count = PAR_BUFFER.get().filter(item => not item.at(ITEM_IS_TABLE, default: false)).len()
215
+ // Only top-level paragraphs count for AFH 33-337 §2 numbering purposes
216
+ let par_count = PAR_BUFFER.get().filter(item => item.kind == "par").len()
246
217
  let items = PAR_BUFFER.get()
247
218
  let total_count = items.len()
219
+
220
+ // Track paragraph numbers per level manually to avoid nested-context
221
+ // counter propagation issues. Dictionary maps level index (as string)
222
+ // to the current counter value at that level.
223
+ let max-levels = paragraph-config.numbering-formats.len()
224
+ let level-counts = (:)
225
+ for lvl in range(max-levels) {
226
+ level-counts.insert(str(lvl), 1)
227
+ }
228
+
248
229
  let i = 0
249
230
  for item in items {
250
231
  i += 1
251
- let is_table = item.at(ITEM_IS_TABLE, default: false)
232
+ let kind = item.kind
233
+ let item_content = item.content
252
234
 
253
- // Render tables inline without paragraph numbering
254
- if is_table {
255
- blank-line()
256
- render-memo-table(item.at(0))
235
+ // Buffer headings for prepend to the next rendered element
236
+ if kind == "heading" {
237
+ heading_buffer = item_content
257
238
  continue
258
239
  }
259
240
 
260
- let par_content = item.at(0)
261
- let nest_level = item.at(1)
262
- let is_heading = item.at(2)
263
-
264
- // Prepend heading as bolded sentence
241
+ // Prepend buffered heading to the next non-heading element
265
242
  if heading_buffer != none {
266
- par_content = [#strong[#heading_buffer.] #par_content]
267
- }
268
- if is_heading {
269
- heading_buffer = par_content
270
- continue
243
+ if kind == "table" {
244
+ // Tables cannot have inline text prepended; emit heading as
245
+ // a standalone bold line above the table
246
+ blank-line()
247
+ strong[#heading_buffer.]
248
+ heading_buffer = none
249
+ } else {
250
+ item_content = [#strong[#heading_buffer.] #item_content]
251
+ heading_buffer = none
252
+ }
271
253
  }
272
254
 
255
+ // Format based on element kind
256
+ let nest_level = item.nest_level
273
257
  let final_par = {
274
- if auto-numbering {
258
+ if kind == "table" {
259
+ render-memo-table(item_content)
260
+ } else if kind == "continuation" {
261
+ // Continuation block within a multi-block list item:
262
+ // indent to align with preceding numbered paragraph's text, no new number.
263
+ // level-counts still holds the value of the preceding numbered paragraph.
264
+ if auto-numbering {
265
+ format-continuation-par(item_content, nest_level, level-counts)
266
+ } else if nest_level > 0 {
267
+ format-continuation-par(item_content, nest_level - 1, level-counts)
268
+ } else {
269
+ item_content
270
+ }
271
+ } else if auto-numbering {
275
272
  if par_count > 1 {
276
273
  // Apply paragraph numbering per AFH 33-337 §2
277
- SET_PAR_LEVEL(nest_level)
278
- let paragraph = memo-par(par_content)
279
- paragraph
274
+ let par = format-numbered-par(item_content, nest_level, level-counts)
275
+ // Advance counter for this level and reset child levels
276
+ level-counts.insert(str(nest_level), level-counts.at(str(nest_level)) + 1)
277
+ for child in range(nest_level + 1, max-levels) {
278
+ level-counts.insert(str(child), 1)
279
+ }
280
+ par
280
281
  } else {
281
282
  // AFH 33-337 §2: "A single paragraph is not numbered"
282
- // Return body content wrapped in block (like numbered case, but without numbering)
283
- par_content
283
+ item_content
284
284
  }
285
285
  } else {
286
286
  // Unnumbered mode: only explicitly nested items (enum/list) get numbered
287
287
  if nest_level > 0 {
288
- SET_PAR_LEVEL(nest_level - 1)
289
- let paragraph = memo-par(par_content)
290
- paragraph
288
+ let effective_level = nest_level - 1
289
+ let par = format-numbered-par(item_content, effective_level, level-counts)
290
+ level-counts.insert(str(effective_level), level-counts.at(str(effective_level)) + 1)
291
+ for child in range(effective_level + 1, max-levels) {
292
+ level-counts.insert(str(child), 1)
293
+ }
294
+ par
291
295
  } else {
292
296
  // Base-level paragraphs are flush left with no numbering
293
- par_content
297
+ // Reset all child level counters so subsequent list items restart at 1
298
+ for child in range(max-levels) {
299
+ level-counts.insert(str(child), 1)
300
+ }
301
+ item_content
294
302
  }
295
303
  }
296
304
  }
297
305
 
298
- //If this is the final paragraph, apply AFH 33-337 §11 rule:
306
+ // If this is the final item, apply AFH 33-337 §11 rule:
299
307
  // "Avoid dividing a paragraph of less than four lines between two pages"
300
308
  blank-line()
301
309
  if i == total_count {
@@ -303,16 +311,16 @@
303
311
 
304
312
  // Use configured spacing for line height calculation
305
313
  let line_height = measure(line(length: spacing.line + spacing.line-height)).width
306
- // Calculate last par's height
314
+ // Calculate last item's height
307
315
  let par_height = measure(final_par, width: available_width).height
308
316
 
309
317
  let estimated_lines = calc.ceil(par_height / line_height)
310
318
 
311
319
  if estimated_lines < 4 {
312
- // Short paragraph (< 4 lines): make sticky to keep with signature
320
+ // Short content (< 4 lines): make sticky to keep with signature
313
321
  block(sticky: true)[#final_par]
314
322
  } else {
315
- // Longer paragraph (≥ 4 lines): use default breaking behavior
323
+ // Longer content (≥ 4 lines): use default breaking behavior
316
324
  block(breakable: true)[#final_par]
317
325
  }
318
326
  } else {
@@ -322,4 +330,3 @@
322
330
  }
323
331
  }
324
332
 
325
-
@@ -172,7 +172,10 @@
172
172
  } else {
173
173
  [Disapprove]
174
174
  }
175
- [#approve-text / #disapprove-text]
175
+ // Keep the action line with the following content (body or signature block)
176
+ // using the same sticky-block pattern that body.typ applies to the last
177
+ // paragraph, per AFH 33-337 §11 orphan-prevention rules.
178
+ block(sticky: true)[#approve-text / #disapprove-text]
176
179
  }
177
180
 
178
181
  // =============================================================================