punctilio 4.3.1 → 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 -27
- package/dist/constants.d.ts.map +1 -1
- package/dist/constants.js +0 -40
- 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 +423 -284
- 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 +48 -7
- package/dist/prose-view.d.ts.map +1 -1
- package/dist/prose-view.js +163 -27
- 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 -88
- 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 -46
- package/src/dashes.ts +450 -269
- package/src/index.ts +70 -291
- package/src/markdown.ts +1 -1
- package/src/nbsp.ts +255 -78
- package/src/prose-view.ts +234 -39
- package/src/quote-classifier.ts +618 -88
- 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
|
|
@@ -292,34 +279,242 @@ export function replaceAllInView(
|
|
|
292
279
|
}
|
|
293
280
|
|
|
294
281
|
/**
|
|
295
|
-
*
|
|
296
|
-
*
|
|
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
|
-
|
|
303
|
-
|
|
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
|
|
290
|
+
return node.value
|
|
312
291
|
}
|
|
313
292
|
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
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
|
-
|
|
324
|
-
|
|
518
|
+
run(input)
|
|
519
|
+
input.commit()
|
|
325
520
|
}
|