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.
- package/README.md +36 -22
- package/dist/constants.d.ts +0 -34
- package/dist/constants.d.ts.map +1 -1
- package/dist/constants.js +0 -49
- package/dist/constants.js.map +1 -1
- package/dist/dashes.d.ts +11 -7
- package/dist/dashes.d.ts.map +1 -1
- package/dist/dashes.js +1170 -176
- package/dist/dashes.js.map +1 -1
- package/dist/index.d.ts +14 -118
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +65 -116
- package/dist/index.js.map +1 -1
- package/dist/markdown.js +1 -1
- package/dist/markdown.js.map +1 -1
- package/dist/nbsp.d.ts +19 -13
- package/dist/nbsp.d.ts.map +1 -1
- package/dist/nbsp.js +214 -69
- package/dist/nbsp.js.map +1 -1
- package/dist/prose-view.d.ts +52 -1
- package/dist/prose-view.d.ts.map +1 -1
- package/dist/prose-view.js +179 -17
- package/dist/prose-view.js.map +1 -1
- package/dist/quote-classifier.d.ts +5 -4
- package/dist/quote-classifier.d.ts.map +1 -1
- package/dist/quote-classifier.js +601 -96
- package/dist/quote-classifier.js.map +1 -1
- package/dist/quotes.d.ts +12 -4
- package/dist/quotes.d.ts.map +1 -1
- package/dist/quotes.js +24 -27
- package/dist/quotes.js.map +1 -1
- package/dist/rehype.d.ts +53 -8
- package/dist/rehype.d.ts.map +1 -1
- package/dist/rehype.js +105 -62
- package/dist/rehype.js.map +1 -1
- package/dist/remark.d.ts +1 -1
- package/dist/remark.d.ts.map +1 -1
- package/dist/remark.js +6 -9
- package/dist/remark.js.map +1 -1
- package/dist/symbols.d.ts +25 -14
- package/dist/symbols.d.ts.map +1 -1
- package/dist/symbols.js +812 -220
- package/dist/symbols.js.map +1 -1
- package/dist/transform-options.d.ts +97 -0
- package/dist/transform-options.d.ts.map +1 -0
- package/dist/transform-options.js +34 -0
- package/dist/transform-options.js.map +1 -0
- package/dist/utils.d.ts +0 -16
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +0 -56
- package/dist/utils.js.map +1 -1
- package/package.json +6 -2
- package/src/constants.ts +0 -56
- package/src/dashes.ts +1217 -208
- package/src/index.ts +71 -291
- package/src/markdown.ts +1 -1
- package/src/nbsp.ts +255 -78
- package/src/prose-view.ts +250 -25
- package/src/quote-classifier.ts +618 -97
- package/src/quotes.ts +36 -19
- package/src/rehype.ts +169 -88
- package/src/remark.ts +6 -11
- package/src/symbols.ts +814 -251
- package/src/transform-options.ts +143 -0
- 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.
|
|
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
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
const
|
|
290
|
-
|
|
291
|
-
|
|
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
|
-
|
|
294
|
-
|
|
518
|
+
run(input)
|
|
519
|
+
input.commit()
|
|
295
520
|
}
|