punctilio 4.3.0 → 5.0.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 (65) hide show
  1. package/README.md +36 -22
  2. package/dist/constants.d.ts +0 -34
  3. package/dist/constants.d.ts.map +1 -1
  4. package/dist/constants.js +0 -49
  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 +1170 -176
  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 +52 -1
  21. package/dist/prose-view.d.ts.map +1 -1
  22. package/dist/prose-view.js +179 -17
  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 -96
  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 +6 -2
  53. package/src/constants.ts +0 -56
  54. package/src/dashes.ts +1217 -208
  55. package/src/index.ts +71 -291
  56. package/src/markdown.ts +1 -1
  57. package/src/nbsp.ts +255 -78
  58. package/src/prose-view.ts +250 -25
  59. package/src/quote-classifier.ts +618 -97
  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
@@ -247,6 +234,16 @@ function matchContainsBoundary(match: RegExpExecArray, view: ProseView): boolean
247
234
  return false
248
235
  }
249
236
 
237
+ /** Count of node boundaries that fall at exactly `offset` (empty nodes stack). */
238
+ export function boundaryCountAt(view: ProseView, offset: number): number {
239
+ let count = 0
240
+ for (const boundary of view.boundaries) {
241
+ if (boundary === offset) count++
242
+ else if (boundary > offset) break
243
+ }
244
+ return count
245
+ }
246
+
250
247
  export function replaceAllInView(
251
248
  view: ProseView,
252
249
  regex: RegExp,
@@ -281,15 +278,243 @@ export function replaceAllInView(
281
278
  }
282
279
  }
283
280
 
284
- export function runLegacyPass(
285
- view: ProseView,
286
- passFn: (markedText: string) => string,
287
- separator: string = DEFAULT_SEPARATOR,
288
- ): void {
289
- const impl = view as ProseViewImpl
290
- if (impl.hasQueuedEdits()) {
291
- throw new Error("runLegacyPass() cannot run while the view has uncommitted queued edits.")
281
+ /**
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.
284
+ */
285
+ export function withProseView(text: string, run: (view: ProseView) => void): string {
286
+ const node: ProseNode = { value: text }
287
+ const view = buildProseView([node])
288
+ run(view)
289
+ view.commit()
290
+ return node.value
291
+ }
292
+
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)
292
517
  }
293
- transformTextNodes(impl.getNodes(), passFn, separator)
294
- impl.refresh()
518
+ run(input)
519
+ input.commit()
295
520
  }