slidev-theme-kotlin 0.13.0 → 0.13.1

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 CHANGED
@@ -1,6 +1,13 @@
1
1
  # Changelog
2
2
 
3
- ## Unreleased
3
+ ## 0.13.1
4
+
5
+ ### Added
6
+
7
+ - `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.
8
+ - `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>`.
9
+
10
+ - 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.
4
11
 
5
12
  ### Removed
6
13
 
@@ -8,6 +15,8 @@
8
15
 
9
16
  ### Fixed
10
17
 
18
+ - Java snippet filenames recognize indented public top-level types while ignoring nested declarations, comments, and string literals.
19
+ - 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
20
  - 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
21
  - `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
22
 
package/README.md CHANGED
@@ -214,13 +214,31 @@ The window shows `fun doubled()…`. The comment is ordinary Kotlin, so the
214
214
  fence still compiles as written (see the snippet checker below), and the
215
215
  handout keeps it. A description after the colon is optional.
216
216
 
217
+ An exercise slide shows the assertions and hides the answer. Put the answer
218
+ above a `// Solution` comment instead: it folds exactly like `// Example`,
219
+ the snippet checker compiles the assertions against it, but the handout and
220
+ `llms-full.txt` print the fence without it (only its `import` lines survive,
221
+ so a reader can still resolve the symbols), and nothing on the slide can
222
+ reveal it:
223
+
224
+ ````md
225
+ ```kotlin
226
+ fun Int.isEven(): Boolean = this % 2 == 0
227
+
228
+ // Solution
229
+ check(4.isEven())
230
+ check(!7.isEven())
231
+ ```
232
+ ````
233
+
217
234
  Details:
218
235
 
219
236
  - Kotlin, Java, and Scala fences (`kotlin`, `kt`, `kts`, `java`, `scala`)
220
237
  fold. Without a marker, only a block of `import` lines at the top of the
221
238
  fence counts, and a `package` line or `@file:` annotation before it stays
222
239
  visible. With a marker, everything above it folds. Only the first
223
- `// Example` line in a fence counts, and only when there is code above it.
240
+ `// Example` or `// Solution` line in a fence counts, and only when there
241
+ is code above it.
224
242
  - Line numbers, `{1|2-3}` highlight ranges, and annotation targets all
225
243
  count from the first line of real code, so folding never shifts them.
226
244
  - Cross-slide Magic Move chains and classic ````md magic-move```` blocks fold
@@ -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. Alone, it underlines that line's code. With `text`, it
23
- * narrows the search to that line — the first line of the match when `text`
24
- * spans several.
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; every line the
30
- * match covers gets its own squiggle.
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 starts on: the message sits after it. */
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 line = props.line
314
- if (line !== undefined && (!Number.isInteger(line) || line < 1))
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 && line === 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
- ? { ...trimmedLineRange(segments, line!), matches: 0, line }
323
- : findTextInSegments(segments, needle, requestedOccurrence(), line)
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 starts.
331
- const startLine = match.line ?? line
332
- const lineRange = startLine === undefined ? null : trimmedLineRange(segments, startLine).range
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
- const lines = mergeVisualLines(range.getClientRects())
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 starts on, like
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
- let messageRight = first.right
403
- if (nextTarget.lineRange) {
404
- for (const box of Array.from(nextTarget.lineRange.getClientRects())) {
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: `${(first.top - hostBox.top + (first.bottom - first.top) / 2) / scale}px`,
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 (props.line !== undefined && (!Number.isInteger(props.line) || props.line < 1))
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 and indentation included' : ''}.`)
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 (!Number.isInteger(props.line) || props.line < 1) {
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.`)
@@ -445,14 +445,16 @@ Give `text` to mark an exact rendered fragment, even when Shiki split it across
445
445
  syntax-token spans. `occurrence` selects its one-based occurrence (default
446
446
  `1`). Add `line` to limit the text search to that one-based source line.
447
447
 
448
- A diagnostic can cover several lines: write the line break into `text` as
449
- `\n` (or a real newline in a bound string), indentation included, exactly as
450
- the code renders. Every line the match covers gets its own squiggle, and the
451
- message sits after the line the match starts on, as IntelliJ places its
452
- inline diagnostics. With `line`, that is the first line of the match.
448
+ A diagnostic can cover several lines. The simplest way is a line range:
449
+ `line="3-6"` underlines the code of lines 3 to 6. Or write the line break
450
+ into `text` as `\n` (or a real newline in a bound string); the indentation
451
+ after a line break may be left out. Every line gets its own squiggle, starting
452
+ at its code rather than at its indentation, and the message sits after the
453
+ last line of the match.
453
454
 
454
455
  ````md
455
- <InlineCompilerError text="when (state) {\n is Loading -> spinner()\n}" message="'when' expression must be exhaustive">
456
+ <InlineCompilerError line="3-6" message="Platform declaration clash">
457
+ <InlineCompilerError text="when (state) {\nis Loading -> spinner()\n}" message="'when' expression must be exhaustive">
456
458
  ````
457
459
 
458
460
  Alternatively, give only `line` to underline the line's non-whitespace code.
@@ -463,8 +465,8 @@ time to settle.
463
465
  | prop | required | meaning |
464
466
  | --- | --- | --- |
465
467
  | `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; also narrows a `text` search to that line (the first line of a multi-line match) |
468
+ | `text` | one of `text` or `line` | exact rendered code text to underline; `\n` continues it on the next line, whose indentation is optional |
469
+ | `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
470
  | `occurrence` | no | one-based match of `text` to underline; defaults to `1` |
469
471
 
470
472
  ## Click timing
@@ -643,6 +645,27 @@ fun liveTalks(talks: List<Talk>): List<Talk> =
643
645
  </TypeHint>
644
646
  ````
645
647
 
648
+ `type` draws IntelliJ's other inlay, the inferred type of a declaration, for
649
+ a `val x by …` or any name whose type the code leaves out. `text` names the
650
+ declaration (it has no default here) and the grey `: Column<String>` pill sits
651
+ right after it, inside the line: the code that follows moves right, as in the
652
+ IDE, instead of being covered.
653
+
654
+ ````md
655
+ <TypeHint :line="2" text="customer" type="Column<String>">
656
+ <TypeHint :line="3" text="hours" type="Column<Number>">
657
+
658
+ ```kotlin
659
+ generateExcel("invoices.xlsx", invoices) {
660
+ val customer by column { it.customer }
661
+ val hours by column { it.hours }
662
+ }
663
+ ```
664
+
665
+ </TypeHint>
666
+ </TypeHint>
667
+ ````
668
+
646
669
  The hint is the message of `SmartCast` and `Warning` in another shape: the
647
670
  same exact-text match across Shiki tokens, the same placement after the end
648
671
  of the line the match starts on, the same wait for a Magic Move step to
@@ -659,10 +682,11 @@ line (`body { }`), the pill sits after that code instead of covering it.
659
682
 
660
683
  | prop | required | meaning |
661
684
  | --- | --- | --- |
662
- | `receiver` | one of the two | the lambda's receiver type; rendered as `this: <receiver>` |
663
- | `context` | one of the two | what a `context(...)` block supplies; rendered as `context(<context>)` |
685
+ | `receiver` | one of the three | the lambda's receiver type; rendered as `this: <receiver>` |
686
+ | `context` | one of the three | what a `context(...)` block supplies; rendered as `context(<context>)` |
687
+ | `type` | one of the three | a declaration's inferred type; rendered as `: <type>` inside the line, right after `text` |
664
688
  | `line` | no | one-based line number that narrows the `text` search |
665
- | `text` | no | exact rendered code the pill follows; defaults to `{` |
689
+ | `text` | with `type` | exact rendered code the pill follows; defaults to `{` for `receiver` and `context` |
666
690
  | `occurrence` | no | one-based match of `text` on that line; defaults to `1` |
667
691
 
668
692
  ## Click timing
@@ -7,36 +7,49 @@ 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`. Made for DSL slides, where what each block brings
11
- * into scope is the whole point.
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: …`) or `context` (rendered `context(…)`) — the hint is not shown.')
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
31
  // The same exact-text match and message placement as `SmartCast` and
25
32
  // `Warning`, with nothing painted: the hint sits after the `{` that ends
26
- // its line, or after the line when more code follows the brace.
33
+ // its line. When more code follows the brace, it is drawn inside the line
34
+ // instead, right after the match, as is a `type` hint (the `.type-hint-inlay`
35
+ // rule below), so the rest of the line moves right as it does in the IDE.
27
36
  const highlightProps = reactive({
28
37
  ...toRefs(props),
29
- text: computed(() => props.text ?? '{'),
30
- message: computed(() => props.receiver ? `this: ${props.receiver}` : props.context ? `context(${props.context})` : undefined),
38
+ text: computed(() => props.text ?? (props.type ? undefined : '{')),
39
+ message: computed(() => props.type ? undefined : props.receiver ? `this: ${props.receiver}` : props.context ? `context(${props.context})` : undefined),
40
+ })
41
+ const { message, messageShown, messageStyle, inlaid } = useCodeHighlight(highlightProps, container, {
42
+ component: 'TypeHint',
43
+ highlightClass: props.type ? 'type-hint-inlay' : 'type-hint-lambda',
44
+ inlay: ({ endsLine }) => props.type ? `: ${props.type}` : endsLine ? undefined : highlightProps.message,
31
45
  })
32
- const { message, messageShown, messageStyle } = useCodeHighlight(highlightProps, container, { component: 'TypeHint' })
33
46
  </script>
34
47
 
35
48
  <template>
36
49
  <div ref="container" class="type-hint-host">
37
50
  <slot />
38
51
  <Transition name="type-hint">
39
- <span v-if="messageShown" class="type-hint" :style="messageStyle">{{ message }}</span>
52
+ <span v-if="messageShown && !inlaid" class="type-hint" :style="messageStyle">{{ message }}</span>
40
53
  </Transition>
41
54
  </div>
42
55
  </template>
@@ -72,4 +85,31 @@ html.dark .type-hint {
72
85
  color: var(--type-hint-color, #8c8c8c);
73
86
  background: var(--type-hint-background, #3c3f41);
74
87
  }
88
+ /* The `type` inlay lives inside the code line (see `inlay` in
89
+ code-highlight.ts), so it is unscoped and takes the pill's look while
90
+ staying in the flow: the code after it moves right. */
91
+ .type-hint-inlay[data-code-inlay]::after,
92
+ .type-hint-lambda[data-code-inlay]::after {
93
+ content: attr(data-code-inlay);
94
+ display: inline-block;
95
+ margin-left: 0.05em;
96
+ padding: 0 0.45em;
97
+ border-radius: 0.5em;
98
+ color: var(--type-hint-color, #6c707e);
99
+ background: var(--type-hint-background, #ebecf0);
100
+ font-size: var(--type-hint-size, 0.85em);
101
+ font-style: normal;
102
+ font-weight: 500;
103
+ line-height: 1.45;
104
+ vertical-align: 0.05em;
105
+ white-space: pre;
106
+ pointer-events: none;
107
+ user-select: none;
108
+ }
109
+ .type-hint-lambda[data-code-inlay]::after { margin-left: 0.4em; }
110
+ html.dark .type-hint-inlay[data-code-inlay]::after,
111
+ html.dark .type-hint-lambda[data-code-inlay]::after {
112
+ color: var(--type-hint-color, #8c8c8c);
113
+ background: var(--type-hint-background, #3c3f41);
114
+ }
75
115
  </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
- return { range: match.range, matches: match.matches, lineRange }
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 starts: number[] = []
178
- for (let index = value.indexOf(needle); index >= 0; index = value.indexOf(needle, index + 1))
179
- starts.push(index)
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 start = starts[Math.max(1, occurrence) - 1]
182
- if (start === undefined)
183
- return { range: null, elements: [], matches: starts.length }
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, start + needle.length)
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: starts.length }
188
- return { range, elements, matches: starts.length, line: startLine(scoped, start) }
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 startLine(segments: TextSegment[], offset: number): number | undefined {
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "slidev-theme-kotlin",
3
- "version": "0.13.0",
3
+ "version": "0.13.1",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./annotation-editor": {
@@ -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, otherwise its leading import block.
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 splitAtExampleMarker(lines) ?? splitImportBlock(code, lines)
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 splitAtExampleMarker(lines: string[]): SplitImports | undefined {
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 { imports, body: lines.slice(end).join('\n'), prefix: prefix.join('\n'), foldStartLine: 0, bodyStartLine: end }
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.
@@ -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` marker, and the deck can add shared imports
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 and `val (a, b) = pair` destructures one;
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
- return code.match(/^public\s+(?:(?:final|abstract|sealed|non-sealed|static|strictfp)\s+)*(?:class|interface|enum|record|@interface)\s+(\w+)/m)?.[1]
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
 
@@ -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, or `context(Clock)` for a context block. */
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
- return parseFenceInfo(info, lines.slice(1, -1).join('\n'), { foldImports: false })
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
  /**