slidev-theme-kotlin 0.13.0 → 0.13.2
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/CHANGELOG.md +26 -0
- package/README.md +26 -2
- package/assets/code-window/arrow.svg +6 -0
- package/assets/code-window/ktor.svg +11 -0
- package/components/DrawnAnnotation.vue +23 -6
- package/components/InlineCompilerError.vue +45 -31
- package/components/README.md +66 -11
- package/components/TypeHint.vue +50 -11
- package/components/code-highlight.ts +38 -6
- package/components/code-text-match.ts +91 -11
- package/package.json +1 -1
- package/setup/code-imports.ts +47 -9
- package/setup/code-window-icon.ts +4 -0
- package/setup/kotlin-snippets.ts +17 -6
- package/setup/magic-move-between.ts +1 -1
- package/setup/static-content.ts +12 -5
- package/styles/layout.css +22 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,12 +2,38 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- An `arrow` modifier on a Kotlin fence, `` ```kotlin arrow ``, marks the code window with the [Arrow](https://arrow-kt.io) brand icon and outlines it in Arrow blue (`--code-window-arrow-color`, light `#2455cf`, dark `#497bf7`), next to the existing `gradle`, `jdbc`, and `r2dbc` modifiers. It is for slides whose Kotlin is really about `Raise`, `Resource`, `SuspendApp`, or another Arrow library; plain Kotlin keeps the Kotlin mark.
|
|
8
|
+
- A `ktor` modifier, `` ```kotlin ktor ``, marks the code window with the Ktor icon and outlines it in Ktor purple (`--code-window-ktor-color`, light `#7f52ff`, dark `#a578ff`), for Ktor routing, plugin, and client code.
|
|
9
|
+
|
|
10
|
+
### Changed
|
|
11
|
+
|
|
12
|
+
- `TypeHint` draws its `this: …` and `context(…)` pill as an inlay attached to the matched token even when the `{` ends its line, instead of a floating message. A floating message hides while Magic Move runs; the inlay travels with its token and stays visible through the transition. The pill still pushes trailing code to the right as it did.
|
|
13
|
+
|
|
14
|
+
### Fixed
|
|
15
|
+
|
|
16
|
+
- Explicit `:connect="true"` creates an editable connector from matched text even with no label, target, or saved geometry. Empty labels are supported too.
|
|
17
|
+
|
|
18
|
+
- `DrawnAnnotation` renders and edits an explicit `geometry.connector` without a `label` or `target`, including curved lines and arrowheads.
|
|
19
|
+
|
|
20
|
+
## 0.13.1
|
|
21
|
+
|
|
22
|
+
### Added
|
|
23
|
+
|
|
24
|
+
- `InlineCompilerError` accepts a line range, `line="3-6"`, to underline a multi-line declaration. The indentation after a `\n` in `text` is now optional for every component that matches code text (`InlineCompilerError`, `DrawnAnnotation`, `SmartCast`, `Warning`, `TypeHint`, `InlineValue`). A multi-line diagnostic squiggles each line from its code instead of from column 0, and its message sits after the last line of the match instead of the first.
|
|
25
|
+
- `TypeHint` takes `type` for IntelliJ's other inlay, the inferred type of a declaration: `<TypeHint :line="2" text="customer" type="Column<String>">` draws the grey `: Column<String>` pill right after `customer`, inside the line, so `by column { … }` moves right as it does in the IDE. It is for `val x by …` and other declarations whose type the code does not spell out. The pill is `::after` content of the matched token (a new `inlay` option on `code-highlight.ts` sets `data-code-inlay` on the match's last element), so it follows Magic Move with the token and never enters the text other components search. The handout renders it as `customer: Column<String>`.
|
|
26
|
+
|
|
27
|
+
- A `// Solution` marker for exercise slides, next to `// Example`. Everything above it folds off the slide the same way and the Kotlin snippet CLI compiles it with the code below, so the assertions an exercise slide shows are checked against a real answer. Unlike an `// Example` prefix, the solution never reaches the handout or `llms-full.txt`: `parseFenceBlock` prints the fence from the marker down, keeping only the solution's `import` lines so the symbols still resolve for a reader. `withoutSolution` in `code-imports.ts` does the cut, and `SplitImports.solution` reports which marker folded.
|
|
28
|
+
|
|
5
29
|
### Removed
|
|
6
30
|
|
|
7
31
|
- The three Kotlin logo files under `public/`: nothing in the theme referenced them, and the README no longer lists them as a feature.
|
|
8
32
|
|
|
9
33
|
### Fixed
|
|
10
34
|
|
|
35
|
+
- Java snippet filenames recognize indented public top-level types while ignoring nested declarations, comments, and string literals.
|
|
36
|
+
- The Kotlin snippet CLI wraps a positional `val [a, b] = pair` in `main()` like it does `val (a, b) = pair`: the square-bracket form of `-Xname-based-destructuring` is just as illegal at top level, but the generator kept it there and the deck's build failed with "Destructuring declarations are only allowed for local variables/values".
|
|
11
37
|
- The text on `cover` and `intro` slides no longer runs under the featured Kodee: the layouts keep it in a column of `--cover-text-width` (48% of the content box by default, clear of the mascot's arm) and wrap it there. The showcase's own cover subtitle, intro bullets, and closing title had been partly hidden behind the mascot since the deck was written. A deck widens the column from its stylesheet, or sets `100%` on the class of a slide that hides the mascot.
|
|
12
38
|
- `SmartCast` and `Warning` follow the colour scheme: in dark mode the token background is Darcula's smart-cast green (`#294436`) and warning olive (`#52503a`) instead of the IntelliJ Light tints, which left Darcula's grey code nearly unreadable on a pastel bar, and the messages brighten to `#7bc47f` and `#d4b44a`. The same variables (`--smart-cast-color`, `--warning-color`, and the `-message-color` pair) still override both schemes, as `TypeHint` already did.
|
|
13
39
|
|
package/README.md
CHANGED
|
@@ -117,7 +117,11 @@ SELECT * FROM presentations;
|
|
|
117
117
|
````
|
|
118
118
|
|
|
119
119
|
`kotlin`, `kt`, and `kts` use the Kotlin icon; each with a `gradle`, `jdbc`,
|
|
120
|
-
or `r2dbc` modifier uses the Gradle, JDBC, or R2DBC identity respectively
|
|
120
|
+
or `r2dbc` modifier uses the Gradle, JDBC, or R2DBC identity respectively, and
|
|
121
|
+
with an `arrow` modifier the [Arrow](https://arrow-kt.io) icon, for Kotlin
|
|
122
|
+
code that is really about `Raise`, `Resource`, or another Arrow library.
|
|
123
|
+
Use `kotlin ktor` (also `kt ktor` or `kts ktor`) for the Ktor icon and purple
|
|
124
|
+
border on Ktor code. These modifiers also work with Magic Move.
|
|
121
125
|
`yaml toolchain`, `java`, `bash`, `xml maven`, and `sql` use the Amper, Java,
|
|
122
126
|
Terminal, Maven, and PostgreSQL icons respectively. All other fences remain plain
|
|
123
127
|
code surfaces.
|
|
@@ -153,6 +157,8 @@ value:
|
|
|
153
157
|
--code-window-kotlin-color: #834df0;
|
|
154
158
|
--code-window-gradle-color: #6c707e;
|
|
155
159
|
--code-window-amper-color: #087cfa;
|
|
160
|
+
--code-window-arrow-color: #2455cf;
|
|
161
|
+
--code-window-ktor-color: #7f52ff;
|
|
156
162
|
--code-window-java-color: #e66d17;
|
|
157
163
|
--code-window-terminal-color: #6c707e;
|
|
158
164
|
--code-window-maven-color: #3574f0;
|
|
@@ -214,13 +220,31 @@ The window shows `fun doubled()…`. The comment is ordinary Kotlin, so the
|
|
|
214
220
|
fence still compiles as written (see the snippet checker below), and the
|
|
215
221
|
handout keeps it. A description after the colon is optional.
|
|
216
222
|
|
|
223
|
+
An exercise slide shows the assertions and hides the answer. Put the answer
|
|
224
|
+
above a `// Solution` comment instead: it folds exactly like `// Example`,
|
|
225
|
+
the snippet checker compiles the assertions against it, but the handout and
|
|
226
|
+
`llms-full.txt` print the fence without it (only its `import` lines survive,
|
|
227
|
+
so a reader can still resolve the symbols), and nothing on the slide can
|
|
228
|
+
reveal it:
|
|
229
|
+
|
|
230
|
+
````md
|
|
231
|
+
```kotlin
|
|
232
|
+
fun Int.isEven(): Boolean = this % 2 == 0
|
|
233
|
+
|
|
234
|
+
// Solution
|
|
235
|
+
check(4.isEven())
|
|
236
|
+
check(!7.isEven())
|
|
237
|
+
```
|
|
238
|
+
````
|
|
239
|
+
|
|
217
240
|
Details:
|
|
218
241
|
|
|
219
242
|
- Kotlin, Java, and Scala fences (`kotlin`, `kt`, `kts`, `java`, `scala`)
|
|
220
243
|
fold. Without a marker, only a block of `import` lines at the top of the
|
|
221
244
|
fence counts, and a `package` line or `@file:` annotation before it stays
|
|
222
245
|
visible. With a marker, everything above it folds. Only the first
|
|
223
|
-
`// Example` line in a fence counts, and only when there
|
|
246
|
+
`// Example` or `// Solution` line in a fence counts, and only when there
|
|
247
|
+
is code above it.
|
|
224
248
|
- Line numbers, `{1|2-3}` highlight ranges, and annotation targets all
|
|
225
249
|
count from the first line of real code, so folding never shifts them.
|
|
226
250
|
- Cross-slide Magic Move chains and classic ````md magic-move```` blocks fold
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
<svg width="40" height="41" viewBox="0 0 40 41" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
2
|
+
<path d="M0 41L20 0V30L0 41Z" fill="#2455CF"/>
|
|
3
|
+
<path d="M40 41L20 0V30L40 41Z" fill="#497BF7"/>
|
|
4
|
+
<path d="M20 37L13.9766 33.3086L20 30L26 33.2969L20 37Z" fill="#E88D15"/>
|
|
5
|
+
<path d="M26 33.2969L20 37V30L26 33.2969Z" fill="#FAA73A"/>
|
|
6
|
+
</svg>
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
2
|
+
<path d="M47.9995 31.9981L31.9975 48.0001L0 15.996L16.002 0L47.9995 31.9981Z" fill="url(#paint0_linear_1328_4995)"/>
|
|
3
|
+
<path d="M32.0103 15.9961H16.0083V31.9981H32.0103V15.9961Z" fill="black"/>
|
|
4
|
+
<defs>
|
|
5
|
+
<linearGradient id="paint0_linear_1328_4995" x1="5.51834" y1="21.4563" x2="26.1201" y2="42.1597" gradientUnits="userSpaceOnUse">
|
|
6
|
+
<stop stop-color="#7F52FF"/>
|
|
7
|
+
<stop offset="0.506725" stop-color="#C711E1"/>
|
|
8
|
+
<stop offset="1" stop-color="#FF6D3F"/>
|
|
9
|
+
</linearGradient>
|
|
10
|
+
</defs>
|
|
11
|
+
</svg>
|
|
@@ -390,7 +390,7 @@ const transitionInkIdentity = computed(() => JSON.stringify([
|
|
|
390
390
|
]))
|
|
391
391
|
// A source-only mark exposes no visual-editor operation. It remains valid:
|
|
392
392
|
// only labels and connectors have geometry that an editor can manipulate.
|
|
393
|
-
const editorRelevant = computed(() => hasLabel.value || (connect.value && hasTarget.value))
|
|
393
|
+
const editorRelevant = computed(() => hasLabel.value || (connect.value && (hasTarget.value || props.connect === true || !!manualConnector())))
|
|
394
394
|
|
|
395
395
|
if (props.on !== undefined && (props.at !== undefined || props.until !== undefined))
|
|
396
396
|
warn('`on` is `at` and `until` in one, so the separate `at` / `until` given here are ignored. Use either `on` alone, or `at` and `until`.')
|
|
@@ -592,7 +592,7 @@ const LABEL_HEAD_START = 0.1
|
|
|
592
592
|
const LABEL_FADE_DURATION = 250
|
|
593
593
|
|
|
594
594
|
// Whether a leader line is drawn at all: it needs somewhere to go.
|
|
595
|
-
const connectsLine = computed(() => connect.value && (hasTarget.value || hasLabel.value))
|
|
595
|
+
const connectsLine = computed(() => connect.value && (hasTarget.value || hasLabel.value || props.connect === true || !!manualConnector()))
|
|
596
596
|
const hasTargetStage = computed(() => hasTarget.value && targetMarkType.value !== 'none')
|
|
597
597
|
|
|
598
598
|
// A label that waits for a click of its own starts writing straight away. One
|
|
@@ -1289,7 +1289,10 @@ function measureGeometry(root: HTMLElement, overlayBox: DOMRect, width: number,
|
|
|
1289
1289
|
? Array.from(match.range.getClientRects()).filter(rect => rect.width > 0.5 && rect.height > 0.5)
|
|
1290
1290
|
: source
|
|
1291
1291
|
? [source.getBoundingClientRect()]
|
|
1292
|
-
|
|
1292
|
+
// A geometry-only line needs a canvas, but no source element to mark.
|
|
1293
|
+
: sourceMarkType.value === 'none' && !props.text && props.selector === '[data-annotate]' && manualConnector()
|
|
1294
|
+
? [root.getBoundingClientRect()]
|
|
1295
|
+
: []
|
|
1293
1296
|
|
|
1294
1297
|
if (!rects.length) {
|
|
1295
1298
|
// The text is not on the slide right now — a Magic Move step that does not
|
|
@@ -1541,10 +1544,22 @@ function paintDestinationInto(
|
|
|
1541
1544
|
placedLabelRegistry.delete(placementToken)
|
|
1542
1545
|
}
|
|
1543
1546
|
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
+
// Explicitly enabling a connector also works without a label or target.
|
|
1548
|
+
// Seed a short route from the mark; dragging materializes its endpoints in
|
|
1549
|
+
// geometry.connector through the same editor path as labelled connectors.
|
|
1547
1550
|
const savedConnector = manualConnector()
|
|
1551
|
+
if (!destination && !savedConnector && props.connect === true) {
|
|
1552
|
+
const slide = slideRoot()
|
|
1553
|
+
const bounds = toLocal(slide ? slideRect(slide) : root.getBoundingClientRect())
|
|
1554
|
+
const distance = Math.max(28, props.gap * 2)
|
|
1555
|
+
const x = clamp(markBox.cx + (markBox.cx < bounds.cx ? distance : -distance), bounds.left + 8, bounds.right - 8)
|
|
1556
|
+
const y = markBox.cy < bounds.cy
|
|
1557
|
+
? Math.min(markBox.bottom + distance, bounds.bottom - 8)
|
|
1558
|
+
: Math.max(markBox.top - distance, bounds.top + 8)
|
|
1559
|
+
destination = makeBox(x, y, x, y)
|
|
1560
|
+
}
|
|
1561
|
+
if (!connect.value || (!destination && !savedConnector))
|
|
1562
|
+
return
|
|
1548
1563
|
let start: Point
|
|
1549
1564
|
let end: Point
|
|
1550
1565
|
let c2x: number
|
|
@@ -1575,6 +1590,8 @@ function paintDestinationInto(
|
|
|
1575
1590
|
}
|
|
1576
1591
|
}
|
|
1577
1592
|
else {
|
|
1593
|
+
if (!destination)
|
|
1594
|
+
return
|
|
1578
1595
|
const route = routeLeader(markBox, destination, endPoint)
|
|
1579
1596
|
if (!route)
|
|
1580
1597
|
return
|
|
@@ -4,7 +4,7 @@ import type { TextMatch } from './code-text-match'
|
|
|
4
4
|
import { computed, nextTick, onBeforeUnmount, onMounted, ref, shallowRef, useId, useTemplateRef, watch } from 'vue'
|
|
5
5
|
import { useIsSlideActive, useNav, useSlideContext } from '@slidev/client'
|
|
6
6
|
import { useMutationObserver, useResizeObserver } from '@vueuse/core'
|
|
7
|
-
import { codeTextSegments, findTextInSegments, mergeVisualLines, trimmedLineRange } from './code-text-match'
|
|
7
|
+
import { codeTextSegments, findTextInSegments, lineSpanMatch, mergeVisualLines, trimmedLineRange } from './code-text-match'
|
|
8
8
|
import { normalizeAnnotationLineBreaks } from './annotation-line-breaks'
|
|
9
9
|
import { annotationLabelText, renderAnnotationLabel } from './annotation-label-markdown'
|
|
10
10
|
import { rememberTransitionInk, takeTransitionInk } from './transition-ink'
|
|
@@ -19,15 +19,16 @@ const isDevelopment = import.meta.env.MODE === 'development'
|
|
|
19
19
|
const props = defineProps<{
|
|
20
20
|
/**
|
|
21
21
|
* One-based source line number inside the single fenced code block this
|
|
22
|
-
* component wraps
|
|
23
|
-
*
|
|
24
|
-
*
|
|
22
|
+
* component wraps, or an inclusive range such as `"3-6"`. Alone, it
|
|
23
|
+
* underlines the code of that line or those lines. With `text`, it narrows
|
|
24
|
+
* the search to the lines starting at the first one.
|
|
25
25
|
*/
|
|
26
|
-
line?: number
|
|
26
|
+
line?: number | string
|
|
27
27
|
/**
|
|
28
28
|
* Exact rendered code text to underline. `\\n` (or a literal newline in a
|
|
29
|
-
* bound string) continues the match on the next source line
|
|
30
|
-
* match covers gets its own
|
|
29
|
+
* bound string) continues the match on the next source line, whose
|
|
30
|
+
* indentation may be left out; every line the match covers gets its own
|
|
31
|
+
* squiggle.
|
|
31
32
|
*/
|
|
32
33
|
text?: string
|
|
33
34
|
/** Which occurrence of `text` to underline, 1-based. */
|
|
@@ -55,6 +56,19 @@ const props = defineProps<{
|
|
|
55
56
|
const messageHtml = computed(() => renderAnnotationLabel(props.message))
|
|
56
57
|
const messageText = computed(() => annotationLabelText(props.message))
|
|
57
58
|
const text = computed(() => props.text === undefined ? undefined : normalizeAnnotationLineBreaks(props.text))
|
|
59
|
+
// `line` is one line (`:line="3"`, `line="3"`) or an inclusive range
|
|
60
|
+
// (`line="3-6"`). Null marks a value that is neither.
|
|
61
|
+
const lineSpan = computed<{ from: number, to: number } | null | undefined>(() => {
|
|
62
|
+
const line = props.line
|
|
63
|
+
if (line === undefined)
|
|
64
|
+
return undefined
|
|
65
|
+
const match = /^\s*(\d+)\s*(?:[-–]\s*(\d+)\s*)?$/.exec(String(line))
|
|
66
|
+
if (!match)
|
|
67
|
+
return null
|
|
68
|
+
const from = Number(match[1])
|
|
69
|
+
const to = match[2] === undefined ? from : Number(match[2])
|
|
70
|
+
return from >= 1 && to >= from ? { from, to } : null
|
|
71
|
+
})
|
|
58
72
|
|
|
59
73
|
const slideContext = useSlideContext()
|
|
60
74
|
const { $clicksContext, $clicks, $scale, $zoom } = slideContext
|
|
@@ -276,7 +290,7 @@ function requestedOccurrence() {
|
|
|
276
290
|
* container — is never trusted either way.
|
|
277
291
|
*/
|
|
278
292
|
interface ResolvedTarget extends TextMatch {
|
|
279
|
-
/** The code of the line the match
|
|
293
|
+
/** The code of the line the match ends on: the message sits after it. */
|
|
280
294
|
lineRange: Range | null
|
|
281
295
|
startNode?: Node
|
|
282
296
|
endNode?: Node
|
|
@@ -310,26 +324,27 @@ function resolveTarget(host: HTMLElement): ResolvedTarget {
|
|
|
310
324
|
}
|
|
311
325
|
|
|
312
326
|
function searchTarget(code: HTMLElement): ResolvedTarget {
|
|
313
|
-
const
|
|
314
|
-
if (
|
|
327
|
+
const span = lineSpan.value
|
|
328
|
+
if (span === null)
|
|
315
329
|
return NO_TARGET
|
|
316
330
|
const needle = text.value
|
|
317
|
-
if (needle === '' || (needle === undefined &&
|
|
331
|
+
if (needle === '' || (needle === undefined && span === undefined))
|
|
318
332
|
return NO_TARGET
|
|
319
333
|
|
|
320
334
|
const segments = codeTextSegments(code, isMagicMove)
|
|
321
335
|
const match: TextMatch = needle === undefined
|
|
322
|
-
?
|
|
323
|
-
: findTextInSegments(segments, needle, requestedOccurrence(),
|
|
336
|
+
? lineSpanMatch(segments, span!.from, span!.to)
|
|
337
|
+
: findTextInSegments(segments, needle, requestedOccurrence(), span?.from)
|
|
324
338
|
if (!match.range)
|
|
325
339
|
return { ...match, lineRange: null }
|
|
326
340
|
|
|
327
341
|
// The squiggle follows only the exact offending text, but placing the
|
|
328
342
|
// diagnostic immediately after that text would cover the remainder of its
|
|
329
343
|
// source line. The message is anchored after the line's final
|
|
330
|
-
// non-whitespace token instead, on the line the match
|
|
331
|
-
|
|
332
|
-
const
|
|
344
|
+
// non-whitespace token instead, on the line the match ends: after a
|
|
345
|
+
// multi-line declaration, not in the middle of it.
|
|
346
|
+
const endLine = match.endLine ?? match.line ?? span?.to
|
|
347
|
+
const lineRange = endLine === undefined ? null : trimmedLineRange(segments, endLine).range
|
|
333
348
|
return { ...match, lineRange, startNode: match.range.startContainer, endNode: match.range.endContainer }
|
|
334
349
|
}
|
|
335
350
|
|
|
@@ -392,20 +407,19 @@ function sync() {
|
|
|
392
407
|
// rendered edges, including offsets inside the first and last token. Its
|
|
393
408
|
// client rects are merged per visual line because Shiki fragments one
|
|
394
409
|
// range by token, and a match can continue on the next line.
|
|
395
|
-
|
|
410
|
+
// Each source line is measured on its own, trimmed of its indentation, so
|
|
411
|
+
// a continuation line's squiggle starts at its code rather than column 0.
|
|
412
|
+
const lines = (nextTarget.lineRanges?.length ? nextTarget.lineRanges : [range])
|
|
413
|
+
.flatMap(lineRange => mergeVisualLines(lineRange.getClientRects()))
|
|
396
414
|
if (!lines.length)
|
|
397
415
|
return
|
|
398
416
|
const first = lines[0]
|
|
399
417
|
|
|
400
|
-
// The message goes after the end of the line the match
|
|
418
|
+
// The message goes after the end of the line the match ends on, like
|
|
401
419
|
// IntelliJ's inline diagnostics, so the rest of that line stays readable.
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
if (box.width)
|
|
406
|
-
messageRight = Math.max(messageRight, box.right)
|
|
407
|
-
}
|
|
408
|
-
}
|
|
420
|
+
const anchorLines = nextTarget.lineRange ? mergeVisualLines(nextTarget.lineRange.getClientRects()) : []
|
|
421
|
+
const anchor = anchorLines[anchorLines.length - 1] ?? lines[lines.length - 1]
|
|
422
|
+
const messageRight = Math.max(anchor.right, lines[lines.length - 1].right)
|
|
409
423
|
|
|
410
424
|
// The slide clips at its own edge; wrap the message within the remaining
|
|
411
425
|
// room instead of letting it run off the canvas.
|
|
@@ -421,7 +435,7 @@ function sync() {
|
|
|
421
435
|
const targetSize = targetElement ? getComputedStyle(targetElement).fontSize : '12px'
|
|
422
436
|
position.value = {
|
|
423
437
|
left: `${(messageRight - hostBox.left) / scale + 10}px`,
|
|
424
|
-
top: `${(
|
|
438
|
+
top: `${(anchor.top - hostBox.top + (anchor.bottom - anchor.top) / 2) / scale}px`,
|
|
425
439
|
maxWidth: `${Math.max(60, (slideBox.right - messageRight) / scale - 34)}px`,
|
|
426
440
|
'--inline-compiler-error-target-size': targetSize || '12px',
|
|
427
441
|
}
|
|
@@ -588,18 +602,18 @@ watch([visible, hasTarget, () => props.line, text, () => props.occurrence, textM
|
|
|
588
602
|
const scope = props.line === undefined ? '' : ` on line ${props.line}`
|
|
589
603
|
if (!needle)
|
|
590
604
|
warnMissingTarget(`\`text\` must be non-empty for "${props.message}" — the diagnostic is not shown.`)
|
|
591
|
-
else if (
|
|
592
|
-
warnMissingTarget(`\`line\` must be a positive integer for "${props.message}" — the diagnostic is not shown.`)
|
|
605
|
+
else if (lineSpan.value === null)
|
|
606
|
+
warnMissingTarget(`\`line\` must be a positive integer or a range like "3-6" for "${props.message}" — the diagnostic is not shown.`)
|
|
593
607
|
else if (textMatches.value > 0)
|
|
594
608
|
warnMissingTarget(`Text "${props.text}" only matched ${textMatches.value} time${textMatches.value === 1 ? '' : 's'}${scope}, but \`occurrence\` asks for match ${requestedOccurrence()} — the diagnostic is not shown.`)
|
|
595
609
|
else
|
|
596
|
-
warnMissingTarget(`Text "${props.text}" was not found${scope} for "${props.message}" — it must match the rendered code exactly${needle.includes('\n') ? ', line breaks
|
|
610
|
+
warnMissingTarget(`Text "${props.text}" was not found${scope} for "${props.message}" — it must match the rendered code exactly${needle.includes('\n') ? ', line breaks included (indentation after a line break is optional)' : ''}.`)
|
|
597
611
|
}
|
|
598
612
|
else if (props.line === undefined) {
|
|
599
613
|
warnMissingTarget(`Give either \`line\` or \`text\` for "${props.message}" — the diagnostic is not shown.`)
|
|
600
614
|
}
|
|
601
|
-
else if (
|
|
602
|
-
warnMissingTarget(`\`line\` must be a positive integer for "${props.message}" — the diagnostic is not shown.`)
|
|
615
|
+
else if (lineSpan.value === null) {
|
|
616
|
+
warnMissingTarget(`\`line\` must be a positive integer or a range like "3-6" for "${props.message}" — the diagnostic is not shown.`)
|
|
603
617
|
}
|
|
604
618
|
else {
|
|
605
619
|
warnMissingTarget(`No code tokens on line ${props.line} for "${props.message}" — the diagnostic is not shown. Empty lines cannot be annotated; check the \`line\` prop.`)
|
package/components/README.md
CHANGED
|
@@ -109,6 +109,37 @@ Mono stack instead of the label's sans face:
|
|
|
109
109
|
</DrawnAnnotation>
|
|
110
110
|
```
|
|
111
111
|
|
|
112
|
+
## Connectors without labels
|
|
113
|
+
|
|
114
|
+
To start a connector at matched text without a label, explicitly enable it:
|
|
115
|
+
|
|
116
|
+
```vue
|
|
117
|
+
<DrawnAnnotation text="body" :connect="true" />
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
Place the annotation before the content containing `body`, or wrap that
|
|
121
|
+
content in its default slot. An omitted or empty `label` works. The line starts
|
|
122
|
+
with a short automatic route from the matched text; drag its handles in the
|
|
123
|
+
visual editor to position it and save `geometry.connector`. Plain text marks
|
|
124
|
+
without an explicit `:connect="true"` keep their mark-only behavior.
|
|
125
|
+
|
|
126
|
+
Omit `label` and supply `geometry.connector` to draw a connector with fixed
|
|
127
|
+
endpoints, without a target element. Coordinates are fractions of the slide's
|
|
128
|
+
width and height. Use `type="none"` to hide the source mark and `:connect="true"`
|
|
129
|
+
to enable the line even when the deck disables connectors by default.
|
|
130
|
+
|
|
131
|
+
```vue
|
|
132
|
+
<DrawnAnnotation
|
|
133
|
+
type="none"
|
|
134
|
+
:connect="true"
|
|
135
|
+
:geometry="{ connector: { start: { x: 0.3, y: 0.4 }, end: { x: 0.7, y: 0.6 } } }"
|
|
136
|
+
/>
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
An optional `control: { x, y }` inside `connector` curves the line. These
|
|
140
|
+
connectors support the same click timing and visual-editor handles as labelled
|
|
141
|
+
connectors. To connect to an element instead, use `target` without `label`.
|
|
142
|
+
|
|
112
143
|
## Where the label goes
|
|
113
144
|
|
|
114
145
|
A label is placed out of the slide's normal flow. Automatic placement is
|
|
@@ -445,14 +476,16 @@ Give `text` to mark an exact rendered fragment, even when Shiki split it across
|
|
|
445
476
|
syntax-token spans. `occurrence` selects its one-based occurrence (default
|
|
446
477
|
`1`). Add `line` to limit the text search to that one-based source line.
|
|
447
478
|
|
|
448
|
-
A diagnostic can cover several lines
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
479
|
+
A diagnostic can cover several lines. The simplest way is a line range:
|
|
480
|
+
`line="3-6"` underlines the code of lines 3 to 6. Or write the line break
|
|
481
|
+
into `text` as `\n` (or a real newline in a bound string); the indentation
|
|
482
|
+
after a line break may be left out. Every line gets its own squiggle, starting
|
|
483
|
+
at its code rather than at its indentation, and the message sits after the
|
|
484
|
+
last line of the match.
|
|
453
485
|
|
|
454
486
|
````md
|
|
455
|
-
<InlineCompilerError
|
|
487
|
+
<InlineCompilerError line="3-6" message="Platform declaration clash">
|
|
488
|
+
<InlineCompilerError text="when (state) {\nis Loading -> spinner()\n}" message="'when' expression must be exhaustive">
|
|
456
489
|
````
|
|
457
490
|
|
|
458
491
|
Alternatively, give only `line` to underline the line's non-whitespace code.
|
|
@@ -463,8 +496,8 @@ time to settle.
|
|
|
463
496
|
| prop | required | meaning |
|
|
464
497
|
| --- | --- | --- |
|
|
465
498
|
| `message` | yes | diagnostic shown beside the code and announced to screen readers; the same Markdown as a `DrawnAnnotation` label, so `` `Int` `` renders inline code and `\n` breaks the line |
|
|
466
|
-
| `text` | one of `text` or `line` | exact rendered code text to underline; `\n` continues it on the next line |
|
|
467
|
-
| `line` | one of `text` or `line` | one-based line number
|
|
499
|
+
| `text` | one of `text` or `line` | exact rendered code text to underline; `\n` continues it on the next line, whose indentation is optional |
|
|
500
|
+
| `line` | one of `text` or `line` | one-based line number, or an inclusive range like `"3-6"`; also narrows a `text` search to the lines starting at that line |
|
|
468
501
|
| `occurrence` | no | one-based match of `text` to underline; defaults to `1` |
|
|
469
502
|
|
|
470
503
|
## Click timing
|
|
@@ -643,6 +676,27 @@ fun liveTalks(talks: List<Talk>): List<Talk> =
|
|
|
643
676
|
</TypeHint>
|
|
644
677
|
````
|
|
645
678
|
|
|
679
|
+
`type` draws IntelliJ's other inlay, the inferred type of a declaration, for
|
|
680
|
+
a `val x by …` or any name whose type the code leaves out. `text` names the
|
|
681
|
+
declaration (it has no default here) and the grey `: Column<String>` pill sits
|
|
682
|
+
right after it, inside the line: the code that follows moves right, as in the
|
|
683
|
+
IDE, instead of being covered.
|
|
684
|
+
|
|
685
|
+
````md
|
|
686
|
+
<TypeHint :line="2" text="customer" type="Column<String>">
|
|
687
|
+
<TypeHint :line="3" text="hours" type="Column<Number>">
|
|
688
|
+
|
|
689
|
+
```kotlin
|
|
690
|
+
generateExcel("invoices.xlsx", invoices) {
|
|
691
|
+
val customer by column { it.customer }
|
|
692
|
+
val hours by column { it.hours }
|
|
693
|
+
}
|
|
694
|
+
```
|
|
695
|
+
|
|
696
|
+
</TypeHint>
|
|
697
|
+
</TypeHint>
|
|
698
|
+
````
|
|
699
|
+
|
|
646
700
|
The hint is the message of `SmartCast` and `Warning` in another shape: the
|
|
647
701
|
same exact-text match across Shiki tokens, the same placement after the end
|
|
648
702
|
of the line the match starts on, the same wait for a Magic Move step to
|
|
@@ -659,10 +713,11 @@ line (`body { }`), the pill sits after that code instead of covering it.
|
|
|
659
713
|
|
|
660
714
|
| prop | required | meaning |
|
|
661
715
|
| --- | --- | --- |
|
|
662
|
-
| `receiver` | one of the
|
|
663
|
-
| `context` | one of the
|
|
716
|
+
| `receiver` | one of the three | the lambda's receiver type; rendered as `this: <receiver>` |
|
|
717
|
+
| `context` | one of the three | what a `context(...)` block supplies; rendered as `context(<context>)` |
|
|
718
|
+
| `type` | one of the three | a declaration's inferred type; rendered as `: <type>` inside the line, right after `text` |
|
|
664
719
|
| `line` | no | one-based line number that narrows the `text` search |
|
|
665
|
-
| `text` |
|
|
720
|
+
| `text` | with `type` | exact rendered code the pill follows; defaults to `{` for `receiver` and `context` |
|
|
666
721
|
| `occurrence` | no | one-based match of `text` on that line; defaults to `1` |
|
|
667
722
|
|
|
668
723
|
## Click timing
|
package/components/TypeHint.vue
CHANGED
|
@@ -7,36 +7,48 @@ import { useCodeHighlight } from './code-highlight'
|
|
|
7
7
|
* IntelliJ's lambda inlay hint: the grey `this: HTML` pill the IDE draws
|
|
8
8
|
* right after the `{` of a lambda with receiver, or `context(Clock)` after
|
|
9
9
|
* the block of a `context(...)` call, which brings a context into scope
|
|
10
|
-
* without touching `this`.
|
|
11
|
-
*
|
|
10
|
+
* without touching `this`. When code follows the `{` on its line, as in
|
|
11
|
+
* `formula { hours * rate }`, the pill sits inside the line between the two. Made for DSL slides, where what each block brings
|
|
12
|
+
* into scope is the whole point. With `type` it is instead the IDE's type
|
|
13
|
+
* inlay, `: Column<String>` right after the name in `text`, pushing the rest
|
|
14
|
+
* of the line to the right as IntelliJ does for a `val x by …`.
|
|
12
15
|
*/
|
|
13
16
|
const props = defineProps<Omit<CodeHighlightProps, 'message'> & {
|
|
14
17
|
/** The receiver type of the lambda, shown as `this: <receiver>`. */
|
|
15
18
|
receiver?: string
|
|
16
19
|
/** The context a `context(...)` block supplies, shown as `context(<context>)` — no `this:`, as IntelliJ renders it. */
|
|
17
20
|
context?: string
|
|
21
|
+
/** The inferred type of the declaration named by `text`, shown inline as `: <type>` right after it. */
|
|
22
|
+
type?: string
|
|
18
23
|
}>()
|
|
19
24
|
const container = useTemplateRef<HTMLElement>('container')
|
|
20
25
|
|
|
21
|
-
if (import.meta.env.MODE === 'development' && !props.receiver && !props.context)
|
|
22
|
-
console.warn('[TypeHint] Give `receiver` (rendered `this: …`)
|
|
26
|
+
if (import.meta.env.MODE === 'development' && !props.receiver && !props.context && !props.type)
|
|
27
|
+
console.warn('[TypeHint] Give `receiver` (rendered `this: …`), `context` (rendered `context(…)`), or `type` (rendered `: …` after `text`) — the hint is not shown.')
|
|
28
|
+
if (import.meta.env.MODE === 'development' && props.type && !props.text)
|
|
29
|
+
console.warn('[TypeHint] A `type` hint needs `text`: the name it follows, such as `text="customer"`.')
|
|
23
30
|
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
// its
|
|
31
|
+
// Use token-attached inlays even when the opening brace ends the line.
|
|
32
|
+
// Floating messages are hidden during Magic Move, while an inlay travels
|
|
33
|
+
// with its token and stays visible throughout the transition. When code
|
|
34
|
+
// follows the match, the inlay also pushes it right as it does in the IDE.
|
|
27
35
|
const highlightProps = reactive({
|
|
28
36
|
...toRefs(props),
|
|
29
|
-
text: computed(() => props.text ?? '{'),
|
|
30
|
-
message: computed(() => props.receiver ? `this: ${props.receiver}` : props.context ? `context(${props.context})` : undefined),
|
|
37
|
+
text: computed(() => props.text ?? (props.type ? undefined : '{')),
|
|
38
|
+
message: computed(() => props.type ? undefined : props.receiver ? `this: ${props.receiver}` : props.context ? `context(${props.context})` : undefined),
|
|
39
|
+
})
|
|
40
|
+
const { message, messageShown, messageStyle, inlaid } = useCodeHighlight(highlightProps, container, {
|
|
41
|
+
component: 'TypeHint',
|
|
42
|
+
highlightClass: props.type ? 'type-hint-inlay' : 'type-hint-lambda',
|
|
43
|
+
inlay: () => props.type ? `: ${props.type}` : highlightProps.message,
|
|
31
44
|
})
|
|
32
|
-
const { message, messageShown, messageStyle } = useCodeHighlight(highlightProps, container, { component: 'TypeHint' })
|
|
33
45
|
</script>
|
|
34
46
|
|
|
35
47
|
<template>
|
|
36
48
|
<div ref="container" class="type-hint-host">
|
|
37
49
|
<slot />
|
|
38
50
|
<Transition name="type-hint">
|
|
39
|
-
<span v-if="messageShown" class="type-hint" :style="messageStyle">{{ message }}</span>
|
|
51
|
+
<span v-if="messageShown && !inlaid" class="type-hint" :style="messageStyle">{{ message }}</span>
|
|
40
52
|
</Transition>
|
|
41
53
|
</div>
|
|
42
54
|
</template>
|
|
@@ -72,4 +84,31 @@ html.dark .type-hint {
|
|
|
72
84
|
color: var(--type-hint-color, #8c8c8c);
|
|
73
85
|
background: var(--type-hint-background, #3c3f41);
|
|
74
86
|
}
|
|
87
|
+
/* The `type` inlay lives inside the code line (see `inlay` in
|
|
88
|
+
code-highlight.ts), so it is unscoped and takes the pill's look while
|
|
89
|
+
staying in the flow: the code after it moves right. */
|
|
90
|
+
.type-hint-inlay[data-code-inlay]::after,
|
|
91
|
+
.type-hint-lambda[data-code-inlay]::after {
|
|
92
|
+
content: attr(data-code-inlay);
|
|
93
|
+
display: inline-block;
|
|
94
|
+
margin-left: 0.05em;
|
|
95
|
+
padding: 0 0.45em;
|
|
96
|
+
border-radius: 0.5em;
|
|
97
|
+
color: var(--type-hint-color, #6c707e);
|
|
98
|
+
background: var(--type-hint-background, #ebecf0);
|
|
99
|
+
font-size: var(--type-hint-size, 0.85em);
|
|
100
|
+
font-style: normal;
|
|
101
|
+
font-weight: 500;
|
|
102
|
+
line-height: 1.45;
|
|
103
|
+
vertical-align: 0.05em;
|
|
104
|
+
white-space: pre;
|
|
105
|
+
pointer-events: none;
|
|
106
|
+
user-select: none;
|
|
107
|
+
}
|
|
108
|
+
.type-hint-lambda[data-code-inlay]::after { margin-left: 0.4em; }
|
|
109
|
+
html.dark .type-hint-inlay[data-code-inlay]::after,
|
|
110
|
+
html.dark .type-hint-lambda[data-code-inlay]::after {
|
|
111
|
+
color: var(--type-hint-color, #8c8c8c);
|
|
112
|
+
background: var(--type-hint-background, #3c3f41);
|
|
113
|
+
}
|
|
75
114
|
</style>
|
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
* colour and name, so each component is a thin wrapper that hands its props,
|
|
6
6
|
* its host element, and its identity to `useCodeHighlight` and renders the
|
|
7
7
|
* message it returns. `TypeHint` uses the same match and message placement
|
|
8
|
-
* without painting anything: its inlay is the message.
|
|
8
|
+
* without painting anything: its inlay is the message. Its `type` hint is
|
|
9
|
+
* instead drawn inside the line, after the match (see `inlay`).
|
|
9
10
|
*/
|
|
10
11
|
import type { ClicksInfo } from '@slidev/types'
|
|
11
12
|
import type { Ref } from 'vue'
|
|
@@ -58,6 +59,16 @@ export interface CodeHighlightOptions {
|
|
|
58
59
|
* visible part is the message: the match is then located but left untouched.
|
|
59
60
|
*/
|
|
60
61
|
highlightClass?: string
|
|
62
|
+
/**
|
|
63
|
+
* Text of an inline inlay drawn right after the match, inside the code line,
|
|
64
|
+
* so the rest of the line moves right as it does in the IDE. It is set as
|
|
65
|
+
* `data-code-inlay` on the last painted element and rendered by the
|
|
66
|
+
* component's CSS as `::after` content, which keeps it out of the text every
|
|
67
|
+
* other component searches. Needs `highlightClass` to paint the match.
|
|
68
|
+
* `endsLine` tells whether the match is the last code on its line, for a
|
|
69
|
+
* component that only draws inside the line when code follows the match.
|
|
70
|
+
*/
|
|
71
|
+
inlay?: (match: { endsLine: boolean }) => string | undefined
|
|
61
72
|
}
|
|
62
73
|
|
|
63
74
|
// Classes Shiki Magic Move keeps on its elements only while a step transition
|
|
@@ -127,7 +138,7 @@ function unwrap(el: HTMLElement) {
|
|
|
127
138
|
* steps. `container` is the component's host element around the slot.
|
|
128
139
|
*/
|
|
129
140
|
export function useCodeHighlight(props: CodeHighlightProps, container: Ref<HTMLElement | null>, options: CodeHighlightOptions) {
|
|
130
|
-
const { component, highlightClass } = options
|
|
141
|
+
const { component, highlightClass, inlay } = options
|
|
131
142
|
|
|
132
143
|
// Markdown attributes cannot conveniently contain a literal line break, so
|
|
133
144
|
// accept both a real newline (from a bound Vue string) and the `\\n` escape.
|
|
@@ -156,6 +167,10 @@ export function useCodeHighlight(props: CodeHighlightProps, container: Ref<HTMLE
|
|
|
156
167
|
const hasTarget = computed(() => matched.value)
|
|
157
168
|
// True while Shiki Magic Move is transitioning between steps.
|
|
158
169
|
const animating = ref(false)
|
|
170
|
+
// Whether the current match is the last code on its line; see `inlay`.
|
|
171
|
+
const matchEndsLine = ref(true)
|
|
172
|
+
// Whether the inlay is drawn right now, in place of any message.
|
|
173
|
+
const inlaid = ref(false)
|
|
159
174
|
const messageStyle = ref<CodeHighlightMessageStyle>({ left: '0px', top: '0px', maxWidth: 'none', '--code-highlight-target-size': '12px' })
|
|
160
175
|
|
|
161
176
|
if (isDevelopment && props.on !== undefined && (props.at !== undefined || props.until !== undefined))
|
|
@@ -217,8 +232,10 @@ export function useCodeHighlight(props: CodeHighlightProps, container: Ref<HTMLE
|
|
|
217
232
|
matches: number
|
|
218
233
|
/** The code of the line the match starts on: the message sits after it. */
|
|
219
234
|
lineRange: Range | null
|
|
235
|
+
/** Whether the match ends where the code of its first line ends. */
|
|
236
|
+
endsLine: boolean
|
|
220
237
|
}
|
|
221
|
-
const NO_TARGET: Target = { range: null, matches: 0, lineRange: null }
|
|
238
|
+
const NO_TARGET: Target = { range: null, matches: 0, lineRange: null, endsLine: true }
|
|
222
239
|
|
|
223
240
|
/** Finds the exact text even when Shiki split it across token spans or lines. */
|
|
224
241
|
function findTarget(host: HTMLElement): Target {
|
|
@@ -235,7 +252,8 @@ export function useCodeHighlight(props: CodeHighlightProps, container: Ref<HTMLE
|
|
|
235
252
|
// instead, on the line the match starts.
|
|
236
253
|
const startLine = match.line ?? props.line
|
|
237
254
|
const lineRange = match.range && startLine !== undefined ? trimmedLineRange(segments, startLine).range : null
|
|
238
|
-
|
|
255
|
+
const endsLine = !match.range || !lineRange || match.range.compareBoundaryPoints(Range.END_TO_END, lineRange) >= 0
|
|
256
|
+
return { range: match.range, matches: match.matches, lineRange, endsLine }
|
|
239
257
|
}
|
|
240
258
|
|
|
241
259
|
/** Places the message after the end of the match's first line, vertically centred on it. */
|
|
@@ -323,6 +341,19 @@ export function useCodeHighlight(props: CodeHighlightProps, container: Ref<HTMLE
|
|
|
323
341
|
if (!highlightClass)
|
|
324
342
|
return
|
|
325
343
|
const nextTargets = range ? paintRange(range) : []
|
|
344
|
+
// The inlay belongs after the match, so only its last element carries it.
|
|
345
|
+
// A data attribute is outside the class observer's filter, so writing it
|
|
346
|
+
// never retriggers a sync; it is written before the class below, whose
|
|
347
|
+
// mutation lets the other marks on this fence re-measure the moved line.
|
|
348
|
+
const inlayText = range ? inlay?.({ endsLine: matchEndsLine.value }) : undefined
|
|
349
|
+
inlaid.value = !!inlayText
|
|
350
|
+
const inlayTarget = inlayText ? nextTargets.at(-1) : undefined
|
|
351
|
+
for (const el of targets.value) {
|
|
352
|
+
if (el !== inlayTarget && el.hasAttribute('data-code-inlay'))
|
|
353
|
+
el.removeAttribute('data-code-inlay')
|
|
354
|
+
}
|
|
355
|
+
if (inlayTarget && inlayTarget.getAttribute('data-code-inlay') !== inlayText)
|
|
356
|
+
inlayTarget.setAttribute('data-code-inlay', inlayText!)
|
|
326
357
|
for (const el of targets.value) {
|
|
327
358
|
if (nextTargets.includes(el))
|
|
328
359
|
continue
|
|
@@ -358,6 +389,7 @@ export function useCodeHighlight(props: CodeHighlightProps, container: Ref<HTMLE
|
|
|
358
389
|
}
|
|
359
390
|
const target = findTarget(host)
|
|
360
391
|
textMatches.value = target.matches
|
|
392
|
+
matchEndsLine.value = target.endsLine
|
|
361
393
|
paint(target.range)
|
|
362
394
|
// Magic Move ends a transition by removing its animation classes, which
|
|
363
395
|
// the class observer below turns into one more sync: the settled edge.
|
|
@@ -398,7 +430,7 @@ export function useCodeHighlight(props: CodeHighlightProps, container: Ref<HTMLE
|
|
|
398
430
|
if (isDevelopment)
|
|
399
431
|
clearAnnotationAuthoringProblems(id)
|
|
400
432
|
})
|
|
401
|
-
watch([() => props.line, text, () => props.occurrence, message], sync)
|
|
433
|
+
watch([() => props.line, text, () => props.occurrence, message, () => inlay?.({ endsLine: matchEndsLine.value })], sync)
|
|
402
434
|
watch(visible, sync)
|
|
403
435
|
watch(isCurrentSlide, sync)
|
|
404
436
|
|
|
@@ -430,5 +462,5 @@ export function useCodeHighlight(props: CodeHighlightProps, container: Ref<HTMLE
|
|
|
430
462
|
}
|
|
431
463
|
}, { immediate: true })
|
|
432
464
|
|
|
433
|
-
return { message, messageHtml, messageShown, messageStyle }
|
|
465
|
+
return { message, messageHtml, messageShown, messageStyle, inlaid }
|
|
434
466
|
}
|
|
@@ -38,6 +38,14 @@ export interface TextMatch extends TextRange {
|
|
|
38
38
|
matches: number
|
|
39
39
|
/** Source line the match starts on, when the segments carry line numbers. */
|
|
40
40
|
line?: number
|
|
41
|
+
/** Source line the match ends on, when the segments carry line numbers. */
|
|
42
|
+
endLine?: number
|
|
43
|
+
/**
|
|
44
|
+
* The match cut into one range per source line it covers, each without the
|
|
45
|
+
* line's indentation or trailing whitespace: one squiggle per line, starting
|
|
46
|
+
* at the code rather than at column 0. Lines with nothing left are skipped.
|
|
47
|
+
*/
|
|
48
|
+
lineRanges?: Range[]
|
|
41
49
|
}
|
|
42
50
|
|
|
43
51
|
/** Tokens Magic Move is animating out; their text is on its way off the slide. */
|
|
@@ -165,31 +173,103 @@ export function rangeFromSegments(segments: TextSegment[], start: number, end: n
|
|
|
165
173
|
return { range, elements }
|
|
166
174
|
}
|
|
167
175
|
|
|
176
|
+
/**
|
|
177
|
+
* The needle as a pattern. A line after a `\n` in the needle may start with
|
|
178
|
+
* any indentation, whatever the needle itself writes there, so a multi-line
|
|
179
|
+
* target reads `fun f(\nname: String\n)` instead of repeating the code's
|
|
180
|
+
* exact leading spaces. Everything else matches literally.
|
|
181
|
+
*/
|
|
182
|
+
function needlePattern(needle: string) {
|
|
183
|
+
const escape = (part: string) => part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
184
|
+
const [first, ...rest] = needle.split('\n')
|
|
185
|
+
const continued = rest.map(part => part.trim() ? `[ \\t]*${escape(part.trimStart())}` : escape(part))
|
|
186
|
+
return new RegExp([escape(first), ...continued].join('\n'), 'g')
|
|
187
|
+
}
|
|
188
|
+
|
|
168
189
|
/**
|
|
169
190
|
* Finds the requested occurrence of `needle` in the flattened segments. With
|
|
170
191
|
* `line`, only that source line is searched — or, for a needle that spans
|
|
171
192
|
* several lines, the lines starting there — and occurrences are counted
|
|
172
|
-
* inside that scope.
|
|
193
|
+
* inside that scope. Indentation after a line break in the needle is optional
|
|
194
|
+
* (see `needlePattern`).
|
|
173
195
|
*/
|
|
174
196
|
export function findTextInSegments(segments: TextSegment[], needle: string, occurrence: number, line?: number): TextMatch {
|
|
175
197
|
const scoped = line === undefined ? segments : segmentsOnLines(segments, line, lineCount(needle))
|
|
176
198
|
const value = scoped.map(segment => segment.text).join('')
|
|
177
|
-
const
|
|
178
|
-
|
|
179
|
-
|
|
199
|
+
const found: { start: number, end: number }[] = []
|
|
200
|
+
const pattern = needlePattern(needle)
|
|
201
|
+
for (let match = pattern.exec(value); match; match = pattern.exec(value)) {
|
|
202
|
+
found.push({ start: match.index, end: match.index + match[0].length })
|
|
203
|
+
// Overlapping occurrences count, as they did with `indexOf`.
|
|
204
|
+
pattern.lastIndex = match.index + 1
|
|
205
|
+
}
|
|
180
206
|
|
|
181
|
-
const
|
|
182
|
-
if (
|
|
183
|
-
return { range: null, elements: [], matches:
|
|
207
|
+
const hit = found[Math.max(1, occurrence) - 1]
|
|
208
|
+
if (hit === undefined)
|
|
209
|
+
return { range: null, elements: [], matches: found.length }
|
|
184
210
|
|
|
185
|
-
const { range, elements } = rangeFromSegments(scoped, start,
|
|
211
|
+
const { range, elements } = rangeFromSegments(scoped, hit.start, hit.end)
|
|
212
|
+
if (!range)
|
|
213
|
+
return { range: null, elements: [], matches: found.length }
|
|
214
|
+
return {
|
|
215
|
+
range,
|
|
216
|
+
elements,
|
|
217
|
+
matches: found.length,
|
|
218
|
+
line: lineAt(scoped, hit.start),
|
|
219
|
+
endLine: lineAt(scoped, hit.end - 1),
|
|
220
|
+
lineRanges: trimmedLineRanges(scoped, hit.start, hit.end),
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* The trimmed code of source lines `from` … `to` as one match: it starts at
|
|
226
|
+
* the first line's code and ends after the last line's, and has one range per
|
|
227
|
+
* non-empty line. What a diagnostic on a whole statement or declaration
|
|
228
|
+
* underlines.
|
|
229
|
+
*/
|
|
230
|
+
export function lineSpanMatch(segments: TextSegment[], from: number, to: number): TextMatch {
|
|
231
|
+
const scoped = segmentsOnLines(segments, from, to - from + 1)
|
|
232
|
+
const value = scoped.map(segment => segment.text).join('')
|
|
233
|
+
const start = value.length - value.trimStart().length
|
|
234
|
+
const end = value.trimEnd().length
|
|
235
|
+
if (start >= end)
|
|
236
|
+
return { range: null, elements: [], matches: 0 }
|
|
237
|
+
const { range, elements } = rangeFromSegments(scoped, start, end)
|
|
186
238
|
if (!range)
|
|
187
|
-
return { range: null, elements: [], matches:
|
|
188
|
-
return {
|
|
239
|
+
return { range: null, elements: [], matches: 0 }
|
|
240
|
+
return {
|
|
241
|
+
range,
|
|
242
|
+
elements,
|
|
243
|
+
matches: 1,
|
|
244
|
+
line: lineAt(scoped, start),
|
|
245
|
+
endLine: lineAt(scoped, end - 1),
|
|
246
|
+
lineRanges: trimmedLineRanges(scoped, start, end),
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** Cuts `start` … `end` of the joined text at line breaks and trims each piece. */
|
|
251
|
+
function trimmedLineRanges(segments: TextSegment[], start: number, end: number): Range[] {
|
|
252
|
+
const value = segments.map(segment => segment.text).join('')
|
|
253
|
+
const ranges: Range[] = []
|
|
254
|
+
let lineStart = start
|
|
255
|
+
while (lineStart < end) {
|
|
256
|
+
const breakAt = value.indexOf('\n', lineStart)
|
|
257
|
+
const lineEnd = breakAt < 0 || breakAt > end ? end : breakAt
|
|
258
|
+
const piece = value.slice(lineStart, lineEnd)
|
|
259
|
+
const from = lineStart + piece.length - piece.trimStart().length
|
|
260
|
+
const to = lineStart + piece.trimEnd().length
|
|
261
|
+
if (from < to) {
|
|
262
|
+
const { range } = rangeFromSegments(segments, from, to)
|
|
263
|
+
if (range)
|
|
264
|
+
ranges.push(range)
|
|
265
|
+
}
|
|
266
|
+
lineStart = lineEnd + 1
|
|
267
|
+
}
|
|
268
|
+
return ranges
|
|
189
269
|
}
|
|
190
270
|
|
|
191
271
|
/** The source line of the segment containing `offset` of the joined text. */
|
|
192
|
-
function
|
|
272
|
+
function lineAt(segments: TextSegment[], offset: number): number | undefined {
|
|
193
273
|
let position = 0
|
|
194
274
|
for (const segment of segments) {
|
|
195
275
|
const next = position + segment.text.length
|
package/package.json
CHANGED
package/setup/code-imports.ts
CHANGED
|
@@ -21,6 +21,13 @@
|
|
|
21
21
|
* the marker line. The comment is valid Kotlin, so the fence still compiles
|
|
22
22
|
* as written; the handout prints it in full.
|
|
23
23
|
*
|
|
24
|
+
* A `// Solution` marker folds the same way but is for code the audience must
|
|
25
|
+
* not read: the solution of an exercise slide that shows only the assertions.
|
|
26
|
+
* The generated Kotlin sources keep it, so the assertions on the slide are
|
|
27
|
+
* compiled against it, while the handout and `llms-full.txt` print the fence
|
|
28
|
+
* without it (its import lines aside, so the symbols still resolve for a
|
|
29
|
+
* reader). Nothing on the slide can reveal it.
|
|
30
|
+
*
|
|
24
31
|
* Add `show-imports` to the fence info to keep the imports visible on a slide
|
|
25
32
|
* that is about them, or set `themeConfig.foldImports: false` to turn the
|
|
26
33
|
* feature off for a whole deck.
|
|
@@ -49,6 +56,12 @@ const IMPORT_LINE = /^import[ \t]+(\S.*?)[ \t]*;?[ \t]*$/
|
|
|
49
56
|
* counts, and only when there is code above it.
|
|
50
57
|
*/
|
|
51
58
|
export const EXAMPLE_MARKER = /^[ \t]*\/\/[ \t]*[Ee]xample(?:[ \t]*:.*)?[ \t]*$/
|
|
59
|
+
/**
|
|
60
|
+
* The comment that marks where an exercise starts and its solution ends:
|
|
61
|
+
* `// Solution`, with the same optional description. It folds like
|
|
62
|
+
* `// Example`, but the folded region is kept out of the handout.
|
|
63
|
+
*/
|
|
64
|
+
export const SOLUTION_MARKER = /^[ \t]*\/\/[ \t]*[Ss]olution(?:[ \t]*:.*)?[ \t]*$/
|
|
52
65
|
const PRELUDE_LINE = /^(?:package[ \t]+\S|@file:)/
|
|
53
66
|
const SHOW_IMPORTS = /(?:^|\s)show-imports(?=\s|$)/
|
|
54
67
|
const THEME_FLAGS = new RegExp(`(?:^|\\s)(?:${THEME_FENCE_FLAGS.join('|')})(?=\\s|$)`, 'g')
|
|
@@ -63,6 +76,8 @@ export interface SplitImports {
|
|
|
63
76
|
* folded region, imports included, without the marker line.
|
|
64
77
|
*/
|
|
65
78
|
prefix?: string
|
|
79
|
+
/** `true` when `prefix` sits above a `// Solution` marker: compiled, never printed. */
|
|
80
|
+
solution?: boolean
|
|
66
81
|
/** Zero-based index of the first folded line, when something folds. */
|
|
67
82
|
foldStartLine?: number
|
|
68
83
|
/** Zero-based index of the first line kept after the folded region. */
|
|
@@ -70,8 +85,9 @@ export interface SplitImports {
|
|
|
70
85
|
}
|
|
71
86
|
|
|
72
87
|
/**
|
|
73
|
-
* Split the folded region off a snippet: everything above a `// Example`
|
|
74
|
-
* marker when the snippet has one
|
|
88
|
+
* Split the folded region off a snippet: everything above a `// Example` or
|
|
89
|
+
* `// Solution` marker when the snippet has one (the first of either kind
|
|
90
|
+
* counts), otherwise its leading import block.
|
|
75
91
|
*
|
|
76
92
|
* Without a marker, a `package` line or `@file:` annotation before the
|
|
77
93
|
* imports stays in the body. Blank lines are allowed between imports; the
|
|
@@ -80,14 +96,29 @@ export interface SplitImports {
|
|
|
80
96
|
*/
|
|
81
97
|
export function splitLeadingImports(code: string): SplitImports {
|
|
82
98
|
const lines = code.split(/\r?\n/)
|
|
83
|
-
return
|
|
99
|
+
return splitAtMarker(lines) ?? splitImportBlock(code, lines)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The fence as the handout and `llms-full.txt` print it: a fence with a
|
|
104
|
+
* `// Solution` marker loses the solution and the marker, keeping only the
|
|
105
|
+
* solution's import lines above the code. Any other fence is returned as is,
|
|
106
|
+
* `// Example` prefix included.
|
|
107
|
+
*/
|
|
108
|
+
export function withoutSolution(code: string): string {
|
|
109
|
+
const split = splitAtMarker(code.split(/\r?\n/))
|
|
110
|
+
if (!split?.solution)
|
|
111
|
+
return code
|
|
112
|
+
const imports = split.imports.map(target => `import ${target}`)
|
|
113
|
+
return imports.length ? `${imports.join('\n')}\n\n${split.body}` : split.body
|
|
84
114
|
}
|
|
85
115
|
|
|
86
116
|
/** The marker split, or `undefined` when there is no marker or nothing above it. */
|
|
87
|
-
function
|
|
88
|
-
const marker = lines.findIndex(line => EXAMPLE_MARKER.test(line))
|
|
117
|
+
function splitAtMarker(lines: string[]): SplitImports | undefined {
|
|
118
|
+
const marker = lines.findIndex(line => EXAMPLE_MARKER.test(line) || SOLUTION_MARKER.test(line))
|
|
89
119
|
if (marker < 0)
|
|
90
120
|
return undefined
|
|
121
|
+
const solution = SOLUTION_MARKER.test(lines[marker])
|
|
91
122
|
const above = lines.slice(0, marker)
|
|
92
123
|
const first = above.findIndex(line => line.trim() !== '')
|
|
93
124
|
// A marker with nothing above it is an ordinary comment.
|
|
@@ -104,10 +135,17 @@ function splitAtExampleMarker(lines: string[]): SplitImports | undefined {
|
|
|
104
135
|
const match = line.match(IMPORT_LINE)
|
|
105
136
|
return match ? [match[1]] : []
|
|
106
137
|
})
|
|
107
|
-
return {
|
|
138
|
+
return {
|
|
139
|
+
imports,
|
|
140
|
+
body: lines.slice(end).join('\n'),
|
|
141
|
+
prefix: prefix.join('\n'),
|
|
142
|
+
...(solution && { solution }),
|
|
143
|
+
foldStartLine: 0,
|
|
144
|
+
bodyStartLine: end,
|
|
145
|
+
}
|
|
108
146
|
}
|
|
109
147
|
|
|
110
|
-
/** Split only the leading import block off a snippet, ignoring any `// Example` marker. */
|
|
148
|
+
/** Split only the leading import block off a snippet, ignoring any `// Example` or `// Solution` marker. */
|
|
111
149
|
export function splitImportBlock(code: string, lines: string[] = code.split(/\r?\n/)): SplitImports {
|
|
112
150
|
let start = 0
|
|
113
151
|
while (start < lines.length && (lines[start].trim() === '' || PRELUDE_LINE.test(lines[start])))
|
|
@@ -233,7 +271,7 @@ function foldFence(lines: Line[], open: number, close: number, info: string, edi
|
|
|
233
271
|
|
|
234
272
|
export interface FoldOptions {
|
|
235
273
|
/**
|
|
236
|
-
* Fold the imports (or the `// Example` prefix) of each fence. `false`
|
|
274
|
+
* Fold the imports (or the `// Example` / `// Solution` prefix) of each fence. `false`
|
|
237
275
|
* (`themeConfig.foldImports: false`) leaves the code as written; the
|
|
238
276
|
* theme's flags still leave the info line, since Slidev must never see them.
|
|
239
277
|
*/
|
|
@@ -242,7 +280,7 @@ export interface FoldOptions {
|
|
|
242
280
|
|
|
243
281
|
/**
|
|
244
282
|
* Compute the edits that strip the theme's flags from, and fold the imports
|
|
245
|
-
* (or the `// Example` prefix) of, every code fence in a slide's Markdown:
|
|
283
|
+
* (or the `// Example` / `// Solution` prefix) of, every code fence in a slide's Markdown:
|
|
246
284
|
* top-level three-backtick fences and the steps of a classic
|
|
247
285
|
* ````md magic-move```` block. Fences nested in any other four-plus-backtick
|
|
248
286
|
* or tilde block are left alone.
|
|
@@ -11,6 +11,10 @@ export function codeWindowIcon(language: string, meta = ''): string | undefined
|
|
|
11
11
|
return 'amper'
|
|
12
12
|
if (language === 'xml' && modifier === 'maven')
|
|
13
13
|
return 'maven'
|
|
14
|
+
if (['kotlin', 'kt', 'kts'].includes(language) && modifier === 'ktor')
|
|
15
|
+
return 'ktor'
|
|
16
|
+
if (['kotlin', 'kt', 'kts'].includes(language) && modifier === 'arrow')
|
|
17
|
+
return 'arrow'
|
|
14
18
|
if (['kotlin', 'kt', 'kts'].includes(language))
|
|
15
19
|
return 'kotlin'
|
|
16
20
|
if (language === 'java')
|
package/setup/kotlin-snippets.ts
CHANGED
|
@@ -13,7 +13,9 @@
|
|
|
13
13
|
* fence and a Kotlin fence on the same slide share that package, allowing
|
|
14
14
|
* them to demonstrate Java ↔ Kotlin interop;
|
|
15
15
|
* - the imports written in the fence are kept, as is anything folded above
|
|
16
|
-
* a `// Example`
|
|
16
|
+
* a `// Example` or `// Solution` marker (an exercise slide's assertions
|
|
17
|
+
* compile against the solution the handout never prints), and the deck
|
|
18
|
+
* can add shared imports
|
|
17
19
|
* (`themeConfig.snippets.imports`) for tables and types the slides use
|
|
18
20
|
* without defining, kept in a hand-written support package;
|
|
19
21
|
* - Kotlin top-level declarations stay top-level and bare statements are
|
|
@@ -100,6 +102,11 @@ type Mode = 'code' | 'block' | 'string' | 'raw'
|
|
|
100
102
|
* char literals, so a caller can tell where top-level constructs begin.
|
|
101
103
|
*/
|
|
102
104
|
export function scanKotlin(code: string): ScannedLine[] {
|
|
105
|
+
return scanSource(code, true)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Java shares the bracket/string scan, but has no string templates or nested block comments. */
|
|
109
|
+
function scanSource(code: string, kotlin: boolean): ScannedLine[] {
|
|
103
110
|
const result: ScannedLine[] = []
|
|
104
111
|
let depth = 0
|
|
105
112
|
let mode: Mode = 'code'
|
|
@@ -156,7 +163,7 @@ export function scanKotlin(code: string): ScannedLine[] {
|
|
|
156
163
|
continue
|
|
157
164
|
}
|
|
158
165
|
if (mode === 'block') {
|
|
159
|
-
if (two === '/*') {
|
|
166
|
+
if (kotlin && two === '/*') {
|
|
160
167
|
blockDepth++
|
|
161
168
|
masked += ' '
|
|
162
169
|
i += 2
|
|
@@ -174,7 +181,7 @@ export function scanKotlin(code: string): ScannedLine[] {
|
|
|
174
181
|
}
|
|
175
182
|
continue
|
|
176
183
|
}
|
|
177
|
-
if (two === '${') {
|
|
184
|
+
if (kotlin && two === '${') {
|
|
178
185
|
templates.push({ mode, depth })
|
|
179
186
|
depth++
|
|
180
187
|
mode = 'code'
|
|
@@ -226,9 +233,10 @@ export interface TopLevelSplit {
|
|
|
226
233
|
const MODIFIERS = 'public|private|protected|internal|expect|actual|final|open|abstract|sealed|const|external|override|lateinit|tailrec|vararg|suspend|inner|enum|annotation|companion|inline|value|infix|operator|data|noinline|crossinline'
|
|
227
234
|
const DECLARATION = new RegExp(`^(?:@[\\w.]+(?:\\([^)]*\\))?\\s+)*(?:(?:${MODIFIERS})\\s+)*(?:fun|val|var|object|class|interface|typealias)\\b`)
|
|
228
235
|
const DECLARATION_PREFIX = /^(?:@[\w.]|context\s*\()/
|
|
229
|
-
// `val _ = f()` discards a result
|
|
236
|
+
// `val _ = f()` discards a result, `val (a, b) = pair` destructures one, and
|
|
237
|
+
// so does the positional `val [a, b] = pair` of `-Xname-based-destructuring`;
|
|
230
238
|
// underscore names and destructuring are only legal for locals.
|
|
231
|
-
const LOCAL_ONLY = /^(?:val|var)\s+(?:_+\s*(?::|=)
|
|
239
|
+
const LOCAL_ONLY = /^(?:val|var)\s+(?:_+\s*(?::|=)|[([])/
|
|
232
240
|
// A non-indented line that can only continue the construct above it.
|
|
233
241
|
const CONTINUATION = /^(?:[.)\]}]|\?[.:]|&&|\|\||->|else\b|catch\b|finally\b)/
|
|
234
242
|
// A line after which the construct is still open.
|
|
@@ -314,7 +322,10 @@ function identifier(slug: string): string {
|
|
|
314
322
|
|
|
315
323
|
/** Java requires a public top-level type to have the same name as its source file. */
|
|
316
324
|
function javaCompilationUnitName(code: string): string | undefined {
|
|
317
|
-
|
|
325
|
+
const topLevel = scanSource(code, false)
|
|
326
|
+
.map(line => line.depthAtStart === 0 ? line.masked : '')
|
|
327
|
+
.join('\n')
|
|
328
|
+
return topLevel.match(/^\s*public\s+(?:(?:final|abstract|sealed|non-sealed|static|strictfp)\s+)*(?:class|interface|enum|record|@interface)\s+(\w+)/m)?.[1]
|
|
318
329
|
}
|
|
319
330
|
|
|
320
331
|
// Attribute lists may hold `>` inside quotes (`message="a > b"`), so a tag
|
|
@@ -40,7 +40,7 @@ export interface ParsedFence {
|
|
|
40
40
|
code: string
|
|
41
41
|
/** Import targets folded off the top of `code` (see `code-imports.ts`). */
|
|
42
42
|
imports: string[]
|
|
43
|
-
/** The code folded above a `// Example` marker, imports included, if the fence has one. */
|
|
43
|
+
/** The code folded above a `// Example` or `// Solution` marker, imports included, if the fence has one. */
|
|
44
44
|
prefix?: string
|
|
45
45
|
}
|
|
46
46
|
|
package/setup/static-content.ts
CHANGED
|
@@ -24,7 +24,8 @@
|
|
|
24
24
|
* Theme-specific markup is translated rather than dropped: a `DrawnAnnotation`
|
|
25
25
|
* label becomes a quoted note about the code it points at, an
|
|
26
26
|
* `InlineCompilerError` becomes its diagnostic, and imports folded on the
|
|
27
|
-
* slides (see `code-imports.ts`) stay in the handout's fences, expanded.
|
|
27
|
+
* slides (see `code-imports.ts`) stay in the handout's fences, expanded. A
|
|
28
|
+
* `// Solution` region does not: it is compiled but never printed.
|
|
28
29
|
*/
|
|
29
30
|
import type { ResolvedSlidevOptions, SlideInfo } from '@slidev/types'
|
|
30
31
|
import type { HtmlTagDescriptor, Plugin } from 'vite'
|
|
@@ -35,6 +36,7 @@ import { basename, dirname, extname, relative, resolve } from 'node:path'
|
|
|
35
36
|
import MarkdownIt from 'markdown-it'
|
|
36
37
|
import { goatcounterTag, resolveAnalyticsSettings } from './analytics'
|
|
37
38
|
import { codeWindowIcon } from './code-window-icon'
|
|
39
|
+
import { withoutSolution } from './code-imports'
|
|
38
40
|
import { parseFenceInfo } from './magic-move-between'
|
|
39
41
|
import type { ParsedFence } from './magic-move-between'
|
|
40
42
|
|
|
@@ -294,10 +296,10 @@ function highlightNote(title: string, raw: string): string {
|
|
|
294
296
|
return `\n\n> **${title}**${target}: ${note}\n\n`
|
|
295
297
|
}
|
|
296
298
|
|
|
297
|
-
/** `TypeHint` renders `this: HTML` after a brace,
|
|
299
|
+
/** `TypeHint` renders `this: HTML` after a brace, `context(Clock)` for a context block, or `customer: Column<String>` for a `type`. */
|
|
298
300
|
function typeHintNote(raw: string): string {
|
|
299
|
-
const { receiver, context } = parseAttributes(raw)
|
|
300
|
-
const hint = context ? `context(${context})` : receiver ? `this: ${receiver}` : ''
|
|
301
|
+
const { receiver, context, type, text } = parseAttributes(raw)
|
|
302
|
+
const hint = type ? `${text ?? ''}: ${type}` : context ? `context(${context})` : receiver ? `this: ${receiver}` : ''
|
|
301
303
|
return hint ? `\n\n> **Type hint** ${code(hint)}\n\n` : ''
|
|
302
304
|
}
|
|
303
305
|
|
|
@@ -402,10 +404,15 @@ export function fenceCaption(fence: ParsedFence): string {
|
|
|
402
404
|
return [identity, fence.title].filter(Boolean).join(' · ')
|
|
403
405
|
}
|
|
404
406
|
|
|
407
|
+
/**
|
|
408
|
+
* Parse a fence block for the handout: imports and an `// Example` prefix
|
|
409
|
+
* stay in the code, a `// Solution` prefix leaves it (see `withoutSolution`).
|
|
410
|
+
*/
|
|
405
411
|
export function parseFenceBlock(block: string): ParsedFence {
|
|
406
412
|
const lines = block.split(/\r?\n/)
|
|
407
413
|
const info = lines[0].replace(/^ {0,3}(`{3,}|~{3,})/, '').trim()
|
|
408
|
-
|
|
414
|
+
const fence = parseFenceInfo(info, lines.slice(1, -1).join('\n'), { foldImports: false })
|
|
415
|
+
return { ...fence, code: withoutSolution(fence.code) }
|
|
409
416
|
}
|
|
410
417
|
|
|
411
418
|
/**
|
package/styles/layout.css
CHANGED
|
@@ -12,6 +12,8 @@
|
|
|
12
12
|
--code-window-r2dbc-color: #06b6d4;
|
|
13
13
|
--code-window-gradle-color: #6c707e;
|
|
14
14
|
--code-window-amper-color: #087cfa;
|
|
15
|
+
--code-window-arrow-color: #2455cf;
|
|
16
|
+
--code-window-ktor-color: #7f52ff;
|
|
15
17
|
--code-window-java-color: #e66d17;
|
|
16
18
|
--code-window-terminal-color: #6c707e;
|
|
17
19
|
--code-window-maven-color: #3574f0;
|
|
@@ -57,6 +59,8 @@ html.dark {
|
|
|
57
59
|
--code-window-r2dbc-color: #06b6d4;
|
|
58
60
|
--code-window-gradle-color: #ced0d6;
|
|
59
61
|
--code-window-amper-color: #087cfa;
|
|
62
|
+
--code-window-arrow-color: #497bf7;
|
|
63
|
+
--code-window-ktor-color: #a578ff;
|
|
60
64
|
--code-window-java-color: #c77d55;
|
|
61
65
|
--code-window-terminal-color: #ced0d6;
|
|
62
66
|
--code-window-maven-color: #548af7;
|
|
@@ -143,6 +147,20 @@ html.dark {
|
|
|
143
147
|
--code-window-border: var(--code-window-amper-color);
|
|
144
148
|
}
|
|
145
149
|
|
|
150
|
+
/* `kotlin arrow`: Kotlin code that is really about an Arrow library. The mark
|
|
151
|
+
is Arrow's brand icon and the outline its blue; the same icon serves both
|
|
152
|
+
colour schemes. */
|
|
153
|
+
.slidev-code-wrapper:is(.code-window-icon--arrow, :has(code.code-window-icon--arrow)) {
|
|
154
|
+
--code-window-icon: url('../assets/code-window/arrow.svg');
|
|
155
|
+
--code-window-border: var(--code-window-arrow-color);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/* `kotlin ktor`: Ktor's primary icon and purple identity border. */
|
|
159
|
+
.slidev-code-wrapper:is(.code-window-icon--ktor, :has(code.code-window-icon--ktor)) {
|
|
160
|
+
--code-window-icon: url('../assets/code-window/ktor.svg');
|
|
161
|
+
--code-window-border: var(--code-window-ktor-color);
|
|
162
|
+
}
|
|
163
|
+
|
|
146
164
|
.slidev-code-wrapper:is(.code-window-icon--java, :has(code.code-window-icon--java)) {
|
|
147
165
|
--code-window-icon: url('../assets/code-window/java.svg');
|
|
148
166
|
--code-window-border: var(--code-window-java-color);
|
|
@@ -233,6 +251,10 @@ html.dark .slidev-code-wrapper:is(.code-window-icon--amper, :has(code.code-windo
|
|
|
233
251
|
--code-window-border: var(--code-window-amper-color);
|
|
234
252
|
}
|
|
235
253
|
|
|
254
|
+
html.dark .slidev-code-wrapper:is(.code-window-icon--arrow, :has(code.code-window-icon--arrow)) {
|
|
255
|
+
--code-window-border: var(--code-window-arrow-color);
|
|
256
|
+
}
|
|
257
|
+
|
|
236
258
|
html.dark .slidev-code-wrapper:is(.code-window-icon--java, :has(code.code-window-icon--java)) {
|
|
237
259
|
--code-window-icon: url('../assets/code-window/java_dark.svg');
|
|
238
260
|
--code-window-border: var(--code-window-java-color);
|