@lovett/ui 0.0.11 → 0.2.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/dist/chunk-RBYWGBQ2.js +2752 -0
- package/dist/chunk-RBYWGBQ2.js.map +1 -0
- package/dist/index.d.ts +5574 -57
- package/dist/index.js +21650 -11206
- package/dist/index.js.map +1 -1
- package/dist/rich-composer-impl-5NO443A6.js +1859 -0
- package/dist/rich-composer-impl-5NO443A6.js.map +1 -0
- package/dist/styles.css +1570 -0
- package/dist/tokens.css +112 -0
- package/package.json +8 -1
- package/src/__tests__/avatar.test.tsx +272 -0
- package/src/__tests__/bar-chart.test.tsx +809 -0
- package/src/__tests__/board.test.tsx +420 -0
- package/src/__tests__/chart-math.test.ts +922 -0
- package/src/__tests__/chart-series.test.ts +339 -0
- package/src/__tests__/code-block.test.tsx +134 -0
- package/src/__tests__/display-popover.test.tsx +195 -0
- package/src/__tests__/display-store.test.tsx +307 -0
- package/src/__tests__/donut-chart.test.tsx +397 -0
- package/src/__tests__/dropdown-menu.test.tsx +156 -2
- package/src/__tests__/filter-menu.test.tsx +175 -0
- package/src/__tests__/gauge-ring.test.tsx +233 -0
- package/src/__tests__/line-chart.test.tsx +612 -0
- package/src/__tests__/ranked-bars.test.tsx +343 -0
- package/src/__tests__/remark-underline.test.ts +194 -0
- package/src/__tests__/sortable.test.tsx +394 -0
- package/src/__tests__/sparkline.test.tsx +368 -0
- package/src/__tests__/stat-layer.test.tsx +271 -0
- package/src/__tests__/stat-strip.test.tsx +175 -0
- package/src/__tests__/status.test.tsx +293 -0
- package/src/__tests__/tabs.test.tsx +303 -0
- package/src/__tests__/token-shape.test.ts +132 -2
- package/src/avatar.tsx +352 -0
- package/src/bar-chart.tsx +1214 -0
- package/src/board.tsx +658 -0
- package/src/chart-frame.tsx +960 -0
- package/src/chart-legend.tsx +304 -0
- package/src/chart-tooltip.tsx +267 -0
- package/src/code-block.tsx +62 -8
- package/src/delta-chip.tsx +263 -0
- package/src/detail/__tests__/activity-pane.test.tsx +369 -0
- package/src/detail/__tests__/detail-chrome.test.tsx +134 -0
- package/src/detail/__tests__/detail-surface.test.tsx +529 -0
- package/src/detail/__tests__/field-row.test.tsx +357 -0
- package/src/detail/activity-pane.tsx +611 -0
- package/src/detail/calendar.tsx +355 -0
- package/src/detail/detail-divider.tsx +261 -0
- package/src/detail/detail-header.tsx +287 -0
- package/src/detail/detail-menu.tsx +254 -0
- package/src/detail/detail-surface.tsx +1110 -0
- package/src/detail/field-list.tsx +196 -0
- package/src/detail/field-row.tsx +1131 -0
- package/src/detail/index.ts +58 -0
- package/src/detail/segmented-choice.tsx +94 -0
- package/src/detail/types.ts +129 -0
- package/src/display-popover.tsx +487 -0
- package/src/display-store.tsx +301 -0
- package/src/donut-chart.tsx +988 -0
- package/src/dropdown-menu.tsx +290 -19
- package/src/filter-core/EXPORTS.md +85 -0
- package/src/filter-core/__tests__/columns.test.ts +159 -0
- package/src/filter-core/__tests__/faceting.test.ts +193 -0
- package/src/filter-core/__tests__/filter-fns.test.ts +519 -0
- package/src/filter-core/__tests__/operators.test.ts +235 -0
- package/src/filter-core/__tests__/state.test.ts +268 -0
- package/src/filter-core/__tests__/url.test.ts +350 -0
- package/src/filter-core/columns.ts +134 -0
- package/src/filter-core/date-utils.ts +38 -0
- package/src/filter-core/examples/task-filter-columns.ts +121 -0
- package/src/filter-core/faceting.ts +120 -0
- package/src/filter-core/filter-fns.ts +335 -0
- package/src/filter-core/index.ts +105 -0
- package/src/filter-core/operators.ts +433 -0
- package/src/filter-core/state.ts +280 -0
- package/src/filter-core/types.ts +247 -0
- package/src/filter-core/url.ts +261 -0
- package/src/filter-dropdown.tsx +12 -0
- package/src/filter-menu.tsx +649 -0
- package/src/floating-drawer.tsx +19 -1
- package/src/gauge-ring.tsx +435 -0
- package/src/hue.ts +52 -0
- package/src/index.ts +303 -0
- package/src/kbd.tsx +27 -4
- package/src/lib/chart.ts +866 -0
- package/src/lib/focus.ts +43 -1
- package/src/lib/layer-stack.ts +30 -3
- package/src/lib/remark-underline.ts +443 -0
- package/src/lib/series.ts +169 -0
- package/src/line-chart.tsx +1176 -0
- package/src/markdown.tsx +26 -7
- package/src/modal.tsx +42 -18
- package/src/progress-ledger.tsx +304 -0
- package/src/ranked-bars.tsx +386 -0
- package/src/segmented-pill.tsx +32 -9
- package/src/sortable.tsx +520 -1
- package/src/sparkline.tsx +416 -0
- package/src/stat-card.tsx +376 -0
- package/src/stat-strip.tsx +327 -0
- package/src/status.tsx +215 -0
- package/src/styles.css +1570 -0
- package/src/tabs.tsx +206 -25
- package/src/task-card.tsx +610 -0
- package/src/thread/__tests__/comment-body-hostile.test.tsx +331 -0
- package/src/thread/__tests__/comment-tree.test.ts +151 -0
- package/src/thread/__tests__/emoji.test.ts +187 -0
- package/src/thread/__tests__/fixtures/thread-fixture.ts +252 -0
- package/src/thread/__tests__/link-preview-source.test.ts +120 -0
- package/src/thread/__tests__/link-preview.test.tsx +600 -0
- package/src/thread/__tests__/markdown-format.test.ts +82 -0
- package/src/thread/__tests__/markdown-spec.test.ts +469 -0
- package/src/thread/__tests__/relative-time.test.ts +71 -0
- package/src/thread/__tests__/rich-composer.test.tsx +799 -0
- package/src/thread/__tests__/scroll-caret.test.ts +58 -0
- package/src/thread/__tests__/suggestion-list.test.tsx +648 -0
- package/src/thread/__tests__/thread-scroll-ownership.test.tsx +88 -0
- package/src/thread/__tests__/thread.test.tsx +742 -0
- package/src/thread/__tests__/use-attachments.test.tsx +679 -0
- package/src/thread/actions.tsx +196 -0
- package/src/thread/attachments.tsx +1071 -0
- package/src/thread/comment-body.tsx +148 -0
- package/src/thread/comment-tree.ts +182 -0
- package/src/thread/comment.tsx +967 -0
- package/src/thread/composer-footer.tsx +125 -0
- package/src/thread/composer.tsx +319 -0
- package/src/thread/emoji.ts +283 -0
- package/src/thread/index.ts +153 -0
- package/src/thread/link-preview.tsx +341 -0
- package/src/thread/markdown-format.ts +155 -0
- package/src/thread/markdown-spec.ts +754 -0
- package/src/thread/rail.tsx +372 -0
- package/src/thread/reactions.tsx +415 -0
- package/src/thread/relative-time.tsx +94 -0
- package/src/thread/rich-composer-impl.tsx +1601 -0
- package/src/thread/rich-composer.tsx +195 -0
- package/src/thread/scroll-caret.ts +37 -0
- package/src/thread/suggestion-list.tsx +182 -0
- package/src/thread/thread.tsx +718 -0
- package/src/thread/types.ts +232 -0
- package/src/thread/use-attachments.ts +598 -0
- package/src/thread/use-now.ts +73 -0
- package/src/thread/use-thread.ts +316 -0
- package/src/tokens.css +112 -0
package/src/lib/focus.ts
CHANGED
|
@@ -27,6 +27,48 @@ export function tabbablesWithin(root: HTMLElement): HTMLElement[] {
|
|
|
27
27
|
el.tabIndex >= 0 &&
|
|
28
28
|
!el.hasAttribute('inert') &&
|
|
29
29
|
el.getAttribute('aria-hidden') !== 'true' &&
|
|
30
|
-
!el.closest('[hidden]')
|
|
30
|
+
!el.closest('[hidden]') &&
|
|
31
|
+
// `inert` is INHERITED by the subtree in a browser, so a control inside
|
|
32
|
+
// an inert ancestor cannot take focus — a trap that hands it focus
|
|
33
|
+
// anyway simply stops moving. The detail surface collapses a pane by
|
|
34
|
+
// making it inert rather than unmounting it (its scroll and its drafts
|
|
35
|
+
// have to survive), which is the case that made this ancestor check
|
|
36
|
+
// load-bearing rather than tidy.
|
|
37
|
+
!el.closest('[inert]'),
|
|
31
38
|
)
|
|
32
39
|
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Keep Tab inside `container`.
|
|
43
|
+
*
|
|
44
|
+
* `Popover` is non-modal by design: it moves focus in and hands it back, but
|
|
45
|
+
* Tab walks straight out of it into the page behind. For a surface that is a
|
|
46
|
+
* self-contained panel of controls — the Display popover, the filter picker —
|
|
47
|
+
* that reads as the panel losing focus mid-use, so those surfaces wrap the
|
|
48
|
+
* cycle themselves. Call from a `keydown` handler on the container.
|
|
49
|
+
*
|
|
50
|
+
* Does nothing unless the key is Tab, and nothing when the container holds
|
|
51
|
+
* fewer than two tabbables (a single control has nowhere to cycle to and
|
|
52
|
+
* preventing default there would trap the user on it).
|
|
53
|
+
*/
|
|
54
|
+
export function handleTabTrap(
|
|
55
|
+
event: { key: string; shiftKey: boolean; preventDefault: () => void },
|
|
56
|
+
container: HTMLElement | null,
|
|
57
|
+
): void {
|
|
58
|
+
if (event.key !== 'Tab' || !container) return
|
|
59
|
+
const tabbables = tabbablesWithin(container)
|
|
60
|
+
if (tabbables.length < 2) return
|
|
61
|
+
const first = tabbables[0]
|
|
62
|
+
const last = tabbables[tabbables.length - 1]
|
|
63
|
+
if (!first || !last) return
|
|
64
|
+
const active = document.activeElement
|
|
65
|
+
if (event.shiftKey && (active === first || !container.contains(active))) {
|
|
66
|
+
event.preventDefault()
|
|
67
|
+
last.focus()
|
|
68
|
+
return
|
|
69
|
+
}
|
|
70
|
+
if (!event.shiftKey && active === last) {
|
|
71
|
+
event.preventDefault()
|
|
72
|
+
first.focus()
|
|
73
|
+
}
|
|
74
|
+
}
|
package/src/lib/layer-stack.ts
CHANGED
|
@@ -58,7 +58,23 @@ export interface LayerHandle {
|
|
|
58
58
|
}
|
|
59
59
|
|
|
60
60
|
interface LayerRecord {
|
|
61
|
-
|
|
61
|
+
/**
|
|
62
|
+
* MUTABLE, and the one field that is.
|
|
63
|
+
*
|
|
64
|
+
* A surface whose kind CHANGES while it is open must not lose its place in
|
|
65
|
+
* the stack over it. The item detail is the case that found this: its three
|
|
66
|
+
* modes are one element tree, and `panel` is non-modal while `full` and
|
|
67
|
+
* `modal` are, so switching mode flips this from 'modal' to 'popover'. When
|
|
68
|
+
* `kind` was an effect dependency, that flip popped and re-pushed the
|
|
69
|
+
* layer — landing the detail ABOVE a dropdown menu that was already open,
|
|
70
|
+
* so the next Escape closed the whole detail instead of the menu inside it.
|
|
71
|
+
* (Switching mode is done FROM that menu, so it was the common path, not an
|
|
72
|
+
* edge case.)
|
|
73
|
+
*
|
|
74
|
+
* Updating in place keeps push order meaning what it says: the order things
|
|
75
|
+
* OPENED, not the order they last changed shape.
|
|
76
|
+
*/
|
|
77
|
+
kind: LayerKind
|
|
62
78
|
readonly element: () => HTMLElement | null
|
|
63
79
|
readonly onEscape: (event: KeyboardEvent) => void
|
|
64
80
|
}
|
|
@@ -119,12 +135,17 @@ export function useLayer(options: UseLayerOptions): LayerHandle {
|
|
|
119
135
|
onEscapeRef.current = onEscape
|
|
120
136
|
})
|
|
121
137
|
|
|
138
|
+
// Read through a ref for the same reason `onEscape` is: so that changing it
|
|
139
|
+
// updates the layer rather than reordering the stack. See `LayerRecord.kind`.
|
|
140
|
+
const kindRef = useRef(kind)
|
|
141
|
+
kindRef.current = kind
|
|
142
|
+
|
|
122
143
|
const recordRef = useRef<LayerRecord | null>(null)
|
|
123
144
|
|
|
124
145
|
useEffect(() => {
|
|
125
146
|
if (!enabled) return
|
|
126
147
|
const record: LayerRecord = {
|
|
127
|
-
kind,
|
|
148
|
+
kind: kindRef.current,
|
|
128
149
|
element: () => elementRef?.current ?? null,
|
|
129
150
|
onEscape: (event) => onEscapeRef.current(event),
|
|
130
151
|
}
|
|
@@ -134,7 +155,13 @@ export function useLayer(options: UseLayerOptions): LayerHandle {
|
|
|
134
155
|
pop()
|
|
135
156
|
if (recordRef.current === record) recordRef.current = null
|
|
136
157
|
}
|
|
137
|
-
}, [enabled,
|
|
158
|
+
}, [enabled, elementRef])
|
|
159
|
+
|
|
160
|
+
// A kind change updates the record IN PLACE, keeping its position.
|
|
161
|
+
useEffect(() => {
|
|
162
|
+
const record = recordRef.current
|
|
163
|
+
if (record !== null) record.kind = kind
|
|
164
|
+
}, [kind])
|
|
138
165
|
|
|
139
166
|
return useMemo<LayerHandle>(
|
|
140
167
|
() => ({
|
|
@@ -0,0 +1,443 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* remark-underline — `++text++` renders as `<u>` (ADR-148 D13).
|
|
3
|
+
*
|
|
4
|
+
* Markdown has no underline. The rich composer serialises TipTap's
|
|
5
|
+
* `underline` mark as `++text++` — a dialect several Markdown flavours use
|
|
6
|
+
* for insert/underline, and one CommonMark cannot misread because `+` has no
|
|
7
|
+
* inline meaning. This is the RENDERER half: a micromark text construct that
|
|
8
|
+
* tokenizes `++` runs, a resolver that pairs openers with closers, and an
|
|
9
|
+
* mdast handler that emits an `underline` node carrying `data.hName = 'u'`,
|
|
10
|
+
* which `remark-rehype` turns into a `<u>` element for the component map in
|
|
11
|
+
* `markdown.tsx` to style.
|
|
12
|
+
*
|
|
13
|
+
* ZERO DEPENDENCIES, ON PURPOSE. `remark-gfm` already pulls every utility this
|
|
14
|
+
* would want into the lockfile (`micromark-util-classify-character`,
|
|
15
|
+
* `micromark-util-resolve-all`, `micromark-util-chunked`,
|
|
16
|
+
* `mdast-util-from-markdown`, `micromark-util-types`) — but pnpm's strict
|
|
17
|
+
* layout keeps transitives out of `packages/ui/node_modules`, so none of them
|
|
18
|
+
* is importable here without becoming a direct dependency, which D13 forbids.
|
|
19
|
+
* The three helpers are re-derived below (each is under fifteen lines in the
|
|
20
|
+
* original) and the types are local structural mirrors of
|
|
21
|
+
* `micromark-util-types` / `mdast-util-from-markdown`. The runtime contract is
|
|
22
|
+
* plain objects and functions that micromark calls by shape, so a mirror is
|
|
23
|
+
* exactly as safe as the import would be. The construct is modelled line for
|
|
24
|
+
* line on `micromark-extension-gfm-strikethrough` (`~~` → `<del>`), its
|
|
25
|
+
* closest cousin.
|
|
26
|
+
*
|
|
27
|
+
* THE RULES — agreed with the composer's serializer (ADR-148 D13):
|
|
28
|
+
*
|
|
29
|
+
* • A `++` run may OPEN / CLOSE by exactly GFM strikethrough's flanking
|
|
30
|
+
* rule (`micromark-extension-gfm-strikethrough`, which is CommonMark's
|
|
31
|
+
* `*` rule without the underscore exception): classify the character on
|
|
32
|
+
* each side as whitespace, punctuation, or other, then
|
|
33
|
+
*
|
|
34
|
+
* open = after is not whitespace and (after is not punctuation or before is whitespace/punctuation)
|
|
35
|
+
* close = before is not whitespace and (before is not punctuation or after is whitespace/punctuation)
|
|
36
|
+
*
|
|
37
|
+
* So `++text++` behaves the way `~~text~~` and `**text**` already do:
|
|
38
|
+
* `i++; j++;` and `C++` stay literal (a run before punctuation after a
|
|
39
|
+
* letter cannot open), `word++x++word` DOES underline (intraword, like
|
|
40
|
+
* `~~`), `see ++(note)++ here` underlines but `a++(note)++b` does not.
|
|
41
|
+
* Whitespace = end of input, line endings, tabs, spaces, Unicode
|
|
42
|
+
* whitespace; punctuation = Unicode P and S. micromark hands over UTF-16
|
|
43
|
+
* code units, so an astral character (an emoji) arrives as two surrogates
|
|
44
|
+
* and classifies as "other", exactly as it does for `~~`.
|
|
45
|
+
* • Exactly two markers. A lone `+`, a `+++` run, an unclosed `++text`, a
|
|
46
|
+
* closer with no opener, spaces just inside the markers — all stay
|
|
47
|
+
* literal text. Nothing is dropped and nothing throws (D3 rule 3).
|
|
48
|
+
* • Code spans and fences are untouched: micromark never runs text
|
|
49
|
+
* constructs inside them, so `` `++x++` `` shows the plus signs.
|
|
50
|
+
* • `\++` is an ordinary character escape and stays literal.
|
|
51
|
+
* • Registered as an `insideSpan` resolver AND an attention marker — the
|
|
52
|
+
* same two hooks strikethrough uses — so `**++x++**`, `++**x**++`,
|
|
53
|
+
* `~~++x++~~` and the intraword `a**++x++**b` all nest correctly.
|
|
54
|
+
* • Two pairs CAN nest structurally (`++a ++b++ c++` → `<u>a <u>b</u> c</u>`),
|
|
55
|
+
* exactly as `~~` does. The composer never emits that (a mark cannot
|
|
56
|
+
* nest in ProseMirror) and it renders as one underline, so it is pinned
|
|
57
|
+
* rather than prevented.
|
|
58
|
+
*/
|
|
59
|
+
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
// Local structural types — mirrors of `micromark-util-types` (see header).
|
|
62
|
+
// Only the members this construct touches are declared.
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* A character code. `null` is end of input; negative values are micromark's
|
|
67
|
+
* virtual characters (line endings, tab expansion); everything else is a
|
|
68
|
+
* Unicode code point.
|
|
69
|
+
*/
|
|
70
|
+
type Code = number | null
|
|
71
|
+
|
|
72
|
+
interface Point {
|
|
73
|
+
line: number
|
|
74
|
+
column: number
|
|
75
|
+
offset: number
|
|
76
|
+
_index: number
|
|
77
|
+
_bufferIndex: number
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
interface Token {
|
|
81
|
+
type: string
|
|
82
|
+
start: Point
|
|
83
|
+
end: Point
|
|
84
|
+
_open?: boolean
|
|
85
|
+
_close?: boolean
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
interface Resolvable {
|
|
89
|
+
readonly resolveAll?: Resolver | undefined
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
interface TokenizeContext {
|
|
93
|
+
readonly previous: Code
|
|
94
|
+
readonly events: Event[]
|
|
95
|
+
readonly parser: {
|
|
96
|
+
readonly constructs: {
|
|
97
|
+
readonly insideSpan: { readonly null?: readonly Resolvable[] | undefined }
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
type Event = ['enter' | 'exit', Token, TokenizeContext]
|
|
103
|
+
type State = (code: Code) => State | undefined
|
|
104
|
+
type Resolver = (events: Event[], context: TokenizeContext) => Event[]
|
|
105
|
+
type Tokenizer = (this: TokenizeContext, effects: Effects, ok: State, nok: State) => State
|
|
106
|
+
|
|
107
|
+
interface Effects {
|
|
108
|
+
enter(type: string): Token
|
|
109
|
+
exit(type: string): Token
|
|
110
|
+
consume(code: Code): undefined
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
interface Construct extends Resolvable {
|
|
114
|
+
readonly name: string
|
|
115
|
+
readonly tokenize: Tokenizer
|
|
116
|
+
readonly resolveAll: Resolver
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** The three hooks a micromark syntax extension may register for an inline construct. */
|
|
120
|
+
interface MicromarkExtension {
|
|
121
|
+
readonly text: Readonly<Record<number, Construct>>
|
|
122
|
+
readonly insideSpan: { readonly null: readonly Construct[] }
|
|
123
|
+
readonly attentionMarkers: { readonly null: readonly Code[] }
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Mirrors of `mdast-util-from-markdown` — the compile side.
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* The mdast node. `data.hName` is the documented `mdast-util-to-hast` hook:
|
|
130
|
+
* an unknown node type with an `hName` becomes an element of that name, so
|
|
131
|
+
* no rehype plugin of any kind is involved and the raw-HTML ban is untouched.
|
|
132
|
+
*/
|
|
133
|
+
interface UnderlineNode {
|
|
134
|
+
type: 'underline'
|
|
135
|
+
children: unknown[]
|
|
136
|
+
data: { hName: 'u' }
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
interface CompileContext {
|
|
140
|
+
enter(node: UnderlineNode, token: Token): undefined
|
|
141
|
+
exit(token: Token): undefined
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
type Handle = (this: CompileContext, token: Token) => undefined
|
|
145
|
+
|
|
146
|
+
interface FromMarkdownExtension {
|
|
147
|
+
readonly canContainEols: readonly string[]
|
|
148
|
+
readonly enter: Readonly<Record<string, Handle>>
|
|
149
|
+
readonly exit: Readonly<Record<string, Handle>>
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// The slice of a unified processor the plugin touches. `settings` is listed
|
|
153
|
+
// so the type is not "weak" (all-optional) and unified's `Data` — which has
|
|
154
|
+
// `settings` — is assignable to it whether or not remark-parse's augmentation
|
|
155
|
+
// (`micromarkExtensions` / `fromMarkdownExtensions`) is loaded in a consumer.
|
|
156
|
+
interface RemarkData {
|
|
157
|
+
settings?: unknown
|
|
158
|
+
micromarkExtensions?: unknown[]
|
|
159
|
+
fromMarkdownExtensions?: unknown[]
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
interface RemarkProcessorLike {
|
|
163
|
+
data(): RemarkData
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ---------------------------------------------------------------------------
|
|
167
|
+
// Constants
|
|
168
|
+
// ---------------------------------------------------------------------------
|
|
169
|
+
|
|
170
|
+
/** U+002B PLUS SIGN. */
|
|
171
|
+
const PLUS = 43
|
|
172
|
+
/** U+0020 SPACE. */
|
|
173
|
+
const SPACE = 32
|
|
174
|
+
|
|
175
|
+
const SEQUENCE_TEMPORARY = 'underlineSequenceTemporary'
|
|
176
|
+
const SEQUENCE = 'underlineSequence'
|
|
177
|
+
const UNDERLINE = 'underline'
|
|
178
|
+
const UNDERLINE_TEXT = 'underlineText'
|
|
179
|
+
/** micromark's own token types this construct reads. */
|
|
180
|
+
const CHARACTER_ESCAPE = 'characterEscape'
|
|
181
|
+
const DATA = 'data'
|
|
182
|
+
|
|
183
|
+
/** `Array#splice` spreads its items as arguments; past this many V8 overflows the stack. */
|
|
184
|
+
const SPLICE_CHUNK = 10_000
|
|
185
|
+
|
|
186
|
+
/** `micromark-util-character`'s `unicodeWhitespace` / `unicodePunctuation`. */
|
|
187
|
+
const UNICODE_WHITESPACE = /\s/
|
|
188
|
+
const UNICODE_PUNCTUATION = /\p{P}|\p{S}/u
|
|
189
|
+
|
|
190
|
+
/** `micromark-util-symbol`'s character groups. */
|
|
191
|
+
const GROUP_WHITESPACE = 1
|
|
192
|
+
const GROUP_PUNCTUATION = 2
|
|
193
|
+
type CharacterGroup = typeof GROUP_WHITESPACE | typeof GROUP_PUNCTUATION | undefined
|
|
194
|
+
|
|
195
|
+
// ---------------------------------------------------------------------------
|
|
196
|
+
// Re-derived helpers (see header for why these are not imports)
|
|
197
|
+
// ---------------------------------------------------------------------------
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* `micromark-util-classify-character`: whitespace (end of input, any virtual
|
|
201
|
+
* character — all line endings and tab parts are negative codes — the space,
|
|
202
|
+
* Unicode whitespace), punctuation (Unicode P and S), or other. Codes are
|
|
203
|
+
* UTF-16 units and are stringified with `fromCharCode` exactly as micromark's
|
|
204
|
+
* `regexCheck` does, so surrogate halves classify as "other" here too.
|
|
205
|
+
*/
|
|
206
|
+
function classifyCharacter(code: Code): CharacterGroup {
|
|
207
|
+
if (code === null || code < 0 || code === SPACE) return GROUP_WHITESPACE
|
|
208
|
+
const character = String.fromCharCode(code)
|
|
209
|
+
if (UNICODE_WHITESPACE.test(character)) return GROUP_WHITESPACE
|
|
210
|
+
if (UNICODE_PUNCTUATION.test(character)) return GROUP_PUNCTUATION
|
|
211
|
+
return undefined
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** `micromark-util-chunked`'s `splice`: `Array#splice` that survives huge item lists. */
|
|
215
|
+
function spliceChunked<T>(list: T[], start: number, remove: number, items: readonly T[]): void {
|
|
216
|
+
const end = list.length
|
|
217
|
+
let at = start < 0 ? (-start > end ? 0 : end + start) : start > end ? end : start
|
|
218
|
+
const count = remove > 0 ? remove : 0
|
|
219
|
+
|
|
220
|
+
if (items.length < SPLICE_CHUNK) {
|
|
221
|
+
list.splice(at, count, ...items)
|
|
222
|
+
return
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (count > 0) list.splice(at, count)
|
|
226
|
+
let from = 0
|
|
227
|
+
while (from < items.length) {
|
|
228
|
+
list.splice(at, 0, ...items.slice(from, from + SPLICE_CHUNK))
|
|
229
|
+
from += SPLICE_CHUNK
|
|
230
|
+
at += SPLICE_CHUNK
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** `micromark-util-resolve-all`: run each distinct `resolveAll` once, in order. */
|
|
235
|
+
function resolveAll(
|
|
236
|
+
constructs: readonly Resolvable[],
|
|
237
|
+
events: Event[],
|
|
238
|
+
context: TokenizeContext,
|
|
239
|
+
): Event[] {
|
|
240
|
+
const called: Resolver[] = []
|
|
241
|
+
let result = events
|
|
242
|
+
for (const construct of constructs) {
|
|
243
|
+
const resolve = construct.resolveAll
|
|
244
|
+
if (resolve !== undefined && !called.includes(resolve)) {
|
|
245
|
+
result = resolve(result, context)
|
|
246
|
+
called.push(resolve)
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
return result
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// ---------------------------------------------------------------------------
|
|
253
|
+
// The micromark construct
|
|
254
|
+
// ---------------------------------------------------------------------------
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Tokenize one `++` run. Runs whenever the text tokenizer meets a `+`.
|
|
258
|
+
* Emits a temporary sequence token flagged with whether it may open and/or
|
|
259
|
+
* close; the resolver pairs them afterwards.
|
|
260
|
+
*/
|
|
261
|
+
function tokenizeUnderline(
|
|
262
|
+
this: TokenizeContext,
|
|
263
|
+
effects: Effects,
|
|
264
|
+
ok: State,
|
|
265
|
+
nok: State,
|
|
266
|
+
): State {
|
|
267
|
+
const previous = this.previous
|
|
268
|
+
const events = this.events
|
|
269
|
+
let size = 0
|
|
270
|
+
|
|
271
|
+
return start
|
|
272
|
+
|
|
273
|
+
function start(code: Code): State | undefined {
|
|
274
|
+
// A `+` directly after another `+` that was not an escape means we are
|
|
275
|
+
// inside a run an earlier attempt already rejected (`+++`): stay literal.
|
|
276
|
+
if (previous === PLUS && events[events.length - 1]?.[1].type !== CHARACTER_ESCAPE) {
|
|
277
|
+
return nok(code)
|
|
278
|
+
}
|
|
279
|
+
effects.enter(SEQUENCE_TEMPORARY)
|
|
280
|
+
return more(code)
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function more(code: Code): State | undefined {
|
|
284
|
+
if (code === PLUS) {
|
|
285
|
+
// A third marker is not this syntax.
|
|
286
|
+
if (size > 1) return nok(code)
|
|
287
|
+
effects.consume(code)
|
|
288
|
+
size++
|
|
289
|
+
return more
|
|
290
|
+
}
|
|
291
|
+
if (size < 2) return nok(code)
|
|
292
|
+
|
|
293
|
+
const token = effects.exit(SEQUENCE_TEMPORARY)
|
|
294
|
+
// GFM strikethrough's flanking rule, verbatim (see header).
|
|
295
|
+
const before = classifyCharacter(previous)
|
|
296
|
+
const after = classifyCharacter(code)
|
|
297
|
+
token._open = after === undefined || (after === GROUP_PUNCTUATION && before !== undefined)
|
|
298
|
+
token._close = before === undefined || (before === GROUP_PUNCTUATION && after !== undefined)
|
|
299
|
+
return ok(code)
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Pair closers with the nearest earlier opener, wrapping the events between
|
|
305
|
+
* in `underline` / `underlineText`, and demote every sequence that did not
|
|
306
|
+
* pair to plain `data` so its characters render as typed.
|
|
307
|
+
*/
|
|
308
|
+
function resolveAllUnderline(events: Event[], context: TokenizeContext): Event[] {
|
|
309
|
+
let index = -1
|
|
310
|
+
|
|
311
|
+
while (++index < events.length) {
|
|
312
|
+
const closer = events[index]
|
|
313
|
+
if (
|
|
314
|
+
closer === undefined ||
|
|
315
|
+
closer[0] !== 'enter' ||
|
|
316
|
+
closer[1].type !== SEQUENCE_TEMPORARY ||
|
|
317
|
+
closer[1]._close !== true
|
|
318
|
+
) {
|
|
319
|
+
continue
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
let open = index
|
|
323
|
+
while (open--) {
|
|
324
|
+
const opener = events[open]
|
|
325
|
+
if (
|
|
326
|
+
opener === undefined ||
|
|
327
|
+
opener[0] !== 'exit' ||
|
|
328
|
+
opener[1].type !== SEQUENCE_TEMPORARY ||
|
|
329
|
+
opener[1]._open !== true ||
|
|
330
|
+
// Sizes must match — always two here, kept for parity with the model.
|
|
331
|
+
closer[1].end.offset - closer[1].start.offset !==
|
|
332
|
+
opener[1].end.offset - opener[1].start.offset
|
|
333
|
+
) {
|
|
334
|
+
continue
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
closer[1].type = SEQUENCE
|
|
338
|
+
opener[1].type = SEQUENCE
|
|
339
|
+
|
|
340
|
+
const underline: Token = {
|
|
341
|
+
type: UNDERLINE,
|
|
342
|
+
start: { ...opener[1].start },
|
|
343
|
+
end: { ...closer[1].end },
|
|
344
|
+
}
|
|
345
|
+
const text: Token = {
|
|
346
|
+
type: UNDERLINE_TEXT,
|
|
347
|
+
start: { ...opener[1].end },
|
|
348
|
+
end: { ...closer[1].start },
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// Opening.
|
|
352
|
+
const next: Event[] = [
|
|
353
|
+
['enter', underline, context],
|
|
354
|
+
['enter', opener[1], context],
|
|
355
|
+
['exit', opener[1], context],
|
|
356
|
+
['enter', text, context],
|
|
357
|
+
]
|
|
358
|
+
|
|
359
|
+
// Between — let every other span construct (emphasis, strong,
|
|
360
|
+
// strikethrough, this one again) resolve inside the pair.
|
|
361
|
+
const insideSpan = context.parser.constructs.insideSpan.null
|
|
362
|
+
if (insideSpan !== undefined) {
|
|
363
|
+
spliceChunked(
|
|
364
|
+
next,
|
|
365
|
+
next.length,
|
|
366
|
+
0,
|
|
367
|
+
resolveAll(insideSpan, events.slice(open + 1, index), context),
|
|
368
|
+
)
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// Closing.
|
|
372
|
+
spliceChunked(next, next.length, 0, [
|
|
373
|
+
['exit', text, context],
|
|
374
|
+
['enter', closer[1], context],
|
|
375
|
+
['exit', closer[1], context],
|
|
376
|
+
['exit', underline, context],
|
|
377
|
+
])
|
|
378
|
+
|
|
379
|
+
spliceChunked(events, open - 1, index - open + 3, next)
|
|
380
|
+
index = open + next.length - 2
|
|
381
|
+
break
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// Whatever did not pair is text. Never dropped, never thrown (D3 rule 3).
|
|
386
|
+
for (const event of events) {
|
|
387
|
+
if (event[1].type === SEQUENCE_TEMPORARY) event[1].type = DATA
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
return events
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function underlineSyntax(): MicromarkExtension {
|
|
394
|
+
const construct: Construct = {
|
|
395
|
+
name: 'underline',
|
|
396
|
+
tokenize: tokenizeUnderline,
|
|
397
|
+
resolveAll: resolveAllUnderline,
|
|
398
|
+
}
|
|
399
|
+
return {
|
|
400
|
+
text: { [PLUS]: construct },
|
|
401
|
+
insideSpan: { null: [construct] },
|
|
402
|
+
attentionMarkers: { null: [PLUS] },
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// ---------------------------------------------------------------------------
|
|
407
|
+
// The mdast compile extension
|
|
408
|
+
// ---------------------------------------------------------------------------
|
|
409
|
+
|
|
410
|
+
function enterUnderline(this: CompileContext, token: Token): undefined {
|
|
411
|
+
this.enter({ type: UNDERLINE, children: [], data: { hName: 'u' } }, token)
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function exitUnderline(this: CompileContext, token: Token): undefined {
|
|
415
|
+
this.exit(token)
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function underlineFromMarkdown(): FromMarkdownExtension {
|
|
419
|
+
return {
|
|
420
|
+
// A soft line break inside `++a\nb++` is kept as text, like `delete`.
|
|
421
|
+
canContainEols: [UNDERLINE],
|
|
422
|
+
enter: { [UNDERLINE]: enterUnderline },
|
|
423
|
+
exit: { [UNDERLINE]: exitUnderline },
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// ---------------------------------------------------------------------------
|
|
428
|
+
// The remark plugin
|
|
429
|
+
// ---------------------------------------------------------------------------
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* remark plugin: `++text++` → `<u>`. Register after `remarkGfm` in a
|
|
433
|
+
* `remarkPlugins` list. Same shape as `remark-gfm` itself — it appends one
|
|
434
|
+
* micromark syntax extension and one mdast compile extension to the
|
|
435
|
+
* processor's data and returns nothing.
|
|
436
|
+
*/
|
|
437
|
+
export function remarkUnderline(this: RemarkProcessorLike): undefined {
|
|
438
|
+
const data = this.data()
|
|
439
|
+
const micromark = data.micromarkExtensions ?? (data.micromarkExtensions = [])
|
|
440
|
+
const fromMarkdown = data.fromMarkdownExtensions ?? (data.fromMarkdownExtensions = [])
|
|
441
|
+
micromark.push(underlineSyntax())
|
|
442
|
+
fromMarkdown.push(underlineFromMarkdown())
|
|
443
|
+
}
|