punctilio 4.3.1 → 5.0.1

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 (65) hide show
  1. package/README.md +37 -23
  2. package/dist/constants.d.ts +0 -27
  3. package/dist/constants.d.ts.map +1 -1
  4. package/dist/constants.js +0 -40
  5. package/dist/constants.js.map +1 -1
  6. package/dist/dashes.d.ts +11 -7
  7. package/dist/dashes.d.ts.map +1 -1
  8. package/dist/dashes.js +423 -284
  9. package/dist/dashes.js.map +1 -1
  10. package/dist/index.d.ts +14 -118
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +65 -116
  13. package/dist/index.js.map +1 -1
  14. package/dist/markdown.js +1 -1
  15. package/dist/markdown.js.map +1 -1
  16. package/dist/nbsp.d.ts +19 -13
  17. package/dist/nbsp.d.ts.map +1 -1
  18. package/dist/nbsp.js +214 -69
  19. package/dist/nbsp.js.map +1 -1
  20. package/dist/prose-view.d.ts +48 -7
  21. package/dist/prose-view.d.ts.map +1 -1
  22. package/dist/prose-view.js +163 -27
  23. package/dist/prose-view.js.map +1 -1
  24. package/dist/quote-classifier.d.ts +5 -4
  25. package/dist/quote-classifier.d.ts.map +1 -1
  26. package/dist/quote-classifier.js +601 -88
  27. package/dist/quote-classifier.js.map +1 -1
  28. package/dist/quotes.d.ts +12 -4
  29. package/dist/quotes.d.ts.map +1 -1
  30. package/dist/quotes.js +24 -27
  31. package/dist/quotes.js.map +1 -1
  32. package/dist/rehype.d.ts +53 -8
  33. package/dist/rehype.d.ts.map +1 -1
  34. package/dist/rehype.js +105 -62
  35. package/dist/rehype.js.map +1 -1
  36. package/dist/remark.d.ts +1 -1
  37. package/dist/remark.d.ts.map +1 -1
  38. package/dist/remark.js +6 -9
  39. package/dist/remark.js.map +1 -1
  40. package/dist/symbols.d.ts +25 -14
  41. package/dist/symbols.d.ts.map +1 -1
  42. package/dist/symbols.js +812 -220
  43. package/dist/symbols.js.map +1 -1
  44. package/dist/transform-options.d.ts +97 -0
  45. package/dist/transform-options.d.ts.map +1 -0
  46. package/dist/transform-options.js +34 -0
  47. package/dist/transform-options.js.map +1 -0
  48. package/dist/utils.d.ts +0 -16
  49. package/dist/utils.d.ts.map +1 -1
  50. package/dist/utils.js +0 -56
  51. package/dist/utils.js.map +1 -1
  52. package/package.json +8 -3
  53. package/src/constants.ts +0 -46
  54. package/src/dashes.ts +450 -269
  55. package/src/index.ts +70 -291
  56. package/src/markdown.ts +1 -1
  57. package/src/nbsp.ts +255 -78
  58. package/src/prose-view.ts +234 -39
  59. package/src/quote-classifier.ts +618 -88
  60. package/src/quotes.ts +36 -19
  61. package/src/rehype.ts +169 -88
  62. package/src/remark.ts +6 -11
  63. package/src/symbols.ts +814 -251
  64. package/src/transform-options.ts +143 -0
  65. package/src/utils.ts +0 -82
package/src/prose-view.ts CHANGED
@@ -1,6 +1,3 @@
1
- import { DEFAULT_SEPARATOR } from "./constants.js"
2
- import { transformTextNodes } from "./utils.js"
3
-
4
1
  export interface ProseNode {
5
2
  value: string
6
3
  }
@@ -54,8 +51,8 @@ class ProseViewImpl implements ProseView {
54
51
  return this.cachedBoundaries
55
52
  }
56
53
 
57
- /** Recompute `text` and `boundaries` from the current node values. @internal */
58
- refresh(): void {
54
+ /** Recompute `text` and `boundaries` from the current node values. */
55
+ private refresh(): void {
59
56
  let text = ""
60
57
  const boundaries: number[] = []
61
58
  this.nodes.forEach((node, index) => {
@@ -68,16 +65,6 @@ class ProseViewImpl implements ProseView {
68
65
  this.cachedBoundaries = boundaries
69
66
  }
70
67
 
71
- /** @internal */
72
- hasQueuedEdits(): boolean {
73
- return this.edits.length > 0
74
- }
75
-
76
- /** @internal */
77
- getNodes(): ProseNode[] {
78
- return this.nodes
79
- }
80
-
81
68
  hasBoundary(offset: number): boolean {
82
69
  const boundaries = this.cachedBoundaries
83
70
  let low = 0
@@ -292,34 +279,242 @@ export function replaceAllInView(
292
279
  }
293
280
 
294
281
  /**
295
- * Splits sentinel-marked text on the separator, builds a ProseView over the
296
- * fragments, runs `run` against the clean-text view, commits the queued edits,
297
- * and rejoins the fragments with the separator. The separator count is
298
- * preserved exactly: each fragment maps to one source node, and `commit()`
299
- * never creates or destroys node boundaries.
282
+ * String↔view bridge: builds a single-node ProseView over `text`, runs `run`
283
+ * against it, commits any queued edits, and returns the resulting text.
300
284
  */
301
- export function withProseView(
302
- markedText: string,
303
- separator: string,
304
- run: (view: ProseView) => void,
305
- ): string {
306
- const fragments = separator.length > 0 ? markedText.split(separator) : [markedText]
307
- const nodes: ProseNode[] = fragments.map((value) => ({ value }))
308
- const view = buildProseView(nodes)
285
+ export function withProseView(text: string, run: (view: ProseView) => void): string {
286
+ const node: ProseNode = { value: text }
287
+ const view = buildProseView([node])
309
288
  run(view)
310
289
  view.commit()
311
- return nodes.map((node) => node.value).join(separator)
290
+ return node.value
312
291
  }
313
292
 
314
- export function runLegacyPass(
315
- view: ProseView,
316
- passFn: (markedText: string) => string,
317
- separator: string = DEFAULT_SEPARATOR,
318
- ): void {
319
- const impl = view as ProseViewImpl
320
- if (impl.hasQueuedEdits()) {
321
- throw new Error("runLegacyPass() cannot run while the view has uncommitted queued edits.")
293
+ /**
294
+ * A pass with the same dual-input shape as the built-ins (`niceQuotes`,
295
+ * `hyphenReplace`, ...): string in, transformed string out; ProseView in,
296
+ * edits committed onto it in place.
297
+ */
298
+ export type ProsePass = {
299
+ (input: string): string
300
+ (input: ProseView): void
301
+ }
302
+
303
+ export interface DefinePassOptions {
304
+ /**
305
+ * How matches that contain an interior node boundary are handled:
306
+ *
307
+ * - `"skip"` (default, safe): such matches are left untouched, so a pass
308
+ * never rewrites text that spans two source nodes.
309
+ * - `"allow"`: such matches are always replaced; the replacement text lands
310
+ * in the node containing the match start, collapsing the boundary after it.
311
+ * - predicate: forwarded as `allowBoundaries` to {@link replaceAllInView};
312
+ * return `true` to allow that particular boundary-spanning match.
313
+ */
314
+ boundaries?: "skip" | "allow" | ((match: RegExpExecArray, view: ProseView) => boolean)
315
+ }
316
+
317
+ type ReplacementPart =
318
+ | { kind: "literal"; text: string }
319
+ | { kind: "whole" }
320
+ | { kind: "indexed"; index: number; fallback?: { index: number; literal: string } }
321
+ | { kind: "named"; name: string }
322
+
323
+ function isAsciiDigit(char: string | undefined): char is string {
324
+ return char !== undefined && char >= "0" && char <= "9"
325
+ }
326
+
327
+ /** Parses the `$`-token starting at `dollarIndex`; returns the part and the offset past it. */
328
+ function parseDollarToken(template: string, dollarIndex: number): { part: ReplacementPart; next: number } {
329
+ const unsupported = (): Error =>
330
+ new Error(
331
+ `Unsupported "$" form in replacement template ${JSON.stringify(template)}; ` +
332
+ `supported forms are $$, $&, $1-$99, and $<name>.`
333
+ )
334
+
335
+ const next = template[dollarIndex + 1]
336
+ if (next === "$") {
337
+ return { part: { kind: "literal", text: "$" }, next: dollarIndex + 2 }
338
+ }
339
+ if (next === "&") {
340
+ return { part: { kind: "whole" }, next: dollarIndex + 2 }
341
+ }
342
+ if (isAsciiDigit(next)) {
343
+ const digits = isAsciiDigit(template[dollarIndex + 2])
344
+ ? next + template[dollarIndex + 2]
345
+ : next
346
+ const index = parseInt(digits, 10)
347
+ // `$12` means group 12 when it exists, otherwise group 1 followed by a
348
+ // literal "2" — matching String.replace. `$0`/`$00` reference nothing.
349
+ const fallbackIndex = parseInt(digits[0], 10)
350
+ const fallback = digits.length === 2 && fallbackIndex > 0
351
+ ? { index: fallbackIndex, literal: digits[1] }
352
+ : undefined
353
+ if (index === 0 && fallback === undefined) {
354
+ throw new Error(
355
+ `Unsupported group reference $${digits} in replacement template ` +
356
+ `${JSON.stringify(template)}; numbered groups start at $1.`
357
+ )
358
+ }
359
+ return { part: { kind: "indexed", index, fallback }, next: dollarIndex + 1 + digits.length }
360
+ }
361
+ if (next === "<") {
362
+ const close = template.indexOf(">", dollarIndex + 2)
363
+ if (close === -1 || close === dollarIndex + 2) {
364
+ throw unsupported()
365
+ }
366
+ return {
367
+ part: { kind: "named", name: template.slice(dollarIndex + 2, close) },
368
+ next: close + 1,
369
+ }
370
+ }
371
+ throw unsupported()
372
+ }
373
+
374
+ /**
375
+ * Parses a `String.replace`-style replacement template into parts. Supported
376
+ * forms: `$$` (literal `$`), `$&` (whole match), `$1`–`$99` (numbered
377
+ * groups), and `$<name>` (named groups). Any other `$` form (`$0`, ``$` ``,
378
+ * `$'`, a bare or malformed `$`) throws rather than silently substituting.
379
+ */
380
+ function parseReplacementTemplate(template: string): ReplacementPart[] {
381
+ const parts: ReplacementPart[] = []
382
+ let literalStart = 0
383
+ let i = 0
384
+
385
+ while (i < template.length) {
386
+ if (template[i] !== "$") {
387
+ i++
388
+ continue
389
+ }
390
+ if (i > literalStart) {
391
+ parts.push({ kind: "literal", text: template.slice(literalStart, i) })
392
+ }
393
+ const { part, next } = parseDollarToken(template, i)
394
+ parts.push(part)
395
+ i = next
396
+ literalStart = next
397
+ }
398
+
399
+ if (template.length > literalStart) {
400
+ parts.push({ kind: "literal", text: template.slice(literalStart) })
401
+ }
402
+
403
+ return parts
404
+ }
405
+
406
+ /** Renders a numbered group reference, applying the `$12` → `$1` + "2" fallback. */
407
+ function renderIndexedPart(
408
+ part: { index: number; fallback?: { index: number; literal: string } },
409
+ match: RegExpExecArray,
410
+ ): string {
411
+ if (part.index < match.length) {
412
+ return match[part.index] ?? ""
413
+ }
414
+ if (part.fallback && part.fallback.index < match.length) {
415
+ return (match[part.fallback.index] ?? "") + part.fallback.literal
416
+ }
417
+ throw new Error(
418
+ `Replacement references group $${part.index} but the pattern only has ` +
419
+ `${match.length - 1} capture group(s).`
420
+ )
421
+ }
422
+
423
+ /**
424
+ * Compiles a replacement template into a replacer. Group references are
425
+ * resolved per match: a reference to a group the pattern does not define
426
+ * throws, while a defined group that simply did not participate in the match
427
+ * substitutes the empty string (as with String.replace).
428
+ */
429
+ function compileReplacementTemplate(template: string): (match: RegExpExecArray) => string {
430
+ const parts = parseReplacementTemplate(template)
431
+ return (match) =>
432
+ parts
433
+ .map((part) => {
434
+ switch (part.kind) {
435
+ case "literal":
436
+ return part.text
437
+ case "whole":
438
+ return match[0]
439
+ case "indexed":
440
+ return renderIndexedPart(part, match)
441
+ case "named": {
442
+ const groups = match.groups
443
+ if (!groups || !(part.name in groups)) {
444
+ throw new Error(
445
+ `Replacement references group $<${part.name}> but the pattern ` +
446
+ `has no capture group named "${part.name}".`
447
+ )
448
+ }
449
+ return groups[part.name] ?? ""
450
+ }
451
+ }
452
+ })
453
+ .join("")
454
+ }
455
+
456
+ /**
457
+ * Defines a boundary-aware pass with the same dual-input shape as the
458
+ * built-in passes, suitable for composing with them (e.g. via `applyPasses`
459
+ * from `punctilio/rehype`).
460
+ *
461
+ * `replacement` is either a template string (supporting `$$`, `$&`,
462
+ * `$1`–`$99`, and `$<name>`; any other `$` form throws at definition time)
463
+ * or a replacer `(match, view) => string | null`, where `null` leaves that
464
+ * match untouched.
465
+ *
466
+ * Patterns that can match the empty string (e.g. pure lookarounds) insert
467
+ * the replacement at every position they match; prefer patterns that
468
+ * consume at least one character.
469
+ */
470
+ export function definePass(
471
+ pattern: RegExp,
472
+ replacement: string | ((match: RegExpExecArray, view: ProseView) => string | null),
473
+ options: DefinePassOptions = {},
474
+ ): ProsePass {
475
+ if (!pattern.global) {
476
+ throw new Error("definePass() requires a pattern with the global (g) flag.")
477
+ }
478
+
479
+ const boundaries = options.boundaries ?? "skip"
480
+ let allowBoundaries: ReplaceAllOptions["allowBoundaries"]
481
+ if (boundaries === "allow") {
482
+ allowBoundaries = () => true
483
+ } else if (typeof boundaries === "function") {
484
+ allowBoundaries = boundaries
485
+ } else if (boundaries !== "skip") {
486
+ throw new Error(
487
+ `Invalid boundaries option: ${JSON.stringify(boundaries)}. ` +
488
+ `Must be "skip", "allow", or a predicate.`
489
+ )
490
+ }
491
+
492
+ const replacer = typeof replacement === "string"
493
+ ? compileReplacementTemplate(replacement)
494
+ : replacement
495
+
496
+ function pass(input: string): string
497
+ function pass(input: ProseView): void
498
+ function pass(input: string | ProseView): string | void {
499
+ return overInput(input, (view) => {
500
+ replaceAllInView(view, pattern, replacer, allowBoundaries ? { allowBoundaries } : undefined)
501
+ })
502
+ }
503
+ return pass
504
+ }
505
+
506
+ /**
507
+ * Dual-input driver shared by the public passes: a string runs through a
508
+ * single-node view and returns the transformed string; a ProseView has the
509
+ * pass's edits queued and committed onto it in place (returns nothing).
510
+ */
511
+ export function overInput(input: string, run: (view: ProseView) => void): string
512
+ export function overInput(input: ProseView, run: (view: ProseView) => void): void
513
+ export function overInput(input: string | ProseView, run: (view: ProseView) => void): string | void
514
+ export function overInput(input: string | ProseView, run: (view: ProseView) => void): string | void {
515
+ if (typeof input === "string") {
516
+ return withProseView(input, run)
322
517
  }
323
- transformTextNodes(impl.getNodes(), passFn, separator)
324
- impl.refresh()
518
+ run(input)
519
+ input.commit()
325
520
  }