Skip to content

Commit d515dee

Browse files
authored
feat(demo-manifest): add stripComments + stripWrappers options (#8)
Two new opt-in options on `demoManifestPlugin` for keeping consumer positioning chrome out of the published code panel without forcing the demo file itself to forgo it: - `stripComments?: string[]` — substring markers whose enclosing comment (`/* … */` block or `<!-- … -->` template) is removed before highlighting. Lets consumers leave maintainer notes inside demo files explaining *why* the file carries some positioning chrome, without leaking that prose into the snippet readers see. - `stripWrappers?: string[]` — class tokens whose wrapping element is unwrapped: opening + closing tags vanish, children stay in place as siblings. Also strips the matching CSS rule from the demo's `<style>` block so the published source doesn't carry an orphan selector pointing at nothing. Both default to `[]` (feature off). Consumers opt in per-project, e.g. svelte-motion would pass: ```ts demoManifestPlugin({ stripComments: ['HUMANSPEAK'], stripWrappers: ['humanspeak-demo-shell'] }) ``` Implementation: - `stripMarkedComments()` — regex over both comment forms - `unwrapMarkedElements()` — depth-balanced tag matching by tag name handles nested same-tag children. Multi-line attribute layouts tolerated via `[^<>]*?` against the attrs body. Self- closing matches log a warning instead of failing (the marker class is almost certainly authored on a wrapping element). - `stripOrphanCSSRules()` — strips rule blocks targeting only the marker class. Compound selectors (`.marker, .other`) survive intact; @-rules aren't recursed into. Documented limitations. Pipeline order (matters): comments → wrappers → orphan CSS → existing docs-kit chrome strip → prettier format. Verified by hand against a fixture with nested same-name divs and both comment forms; all six acceptance checks green. No test infra in this repo yet — the existing `stripDocsKitChrome` heuristic ships without tests too, and these helpers follow the same shape.
1 parent 006da92 commit d515dee

1 file changed

Lines changed: 250 additions & 6 deletions

File tree

src/lib/vite/demo-manifest.ts

Lines changed: 250 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,27 @@ export interface DemoManifestOptions {
6565
* (`svelte`, `typescript`, `javascript`, `html`). Use for demos that
6666
* reach for `css`, `bash`, `json`, etc. */
6767
extraLangs?: string[]
68+
/** Marker substrings whose enclosing comment should be stripped before
69+
* highlighting. Catches both block comments inside
70+
* `<script>` / `<style>` (`/* HUMANSPEAK ... *​/`) and template
71+
* comments in markup (`<!-- HUMANSPEAK ... -->`). Useful for
72+
* maintainer notes that explain *why* a demo file carries some
73+
* positioning shell, without leaking that explanation into the
74+
* published code panel. Match is plain `String.includes` —
75+
* case-sensitive, no regex. Default `[]` (feature off). */
76+
stripComments?: string[]
77+
/** Class names whose wrapping element should be unwrapped — the
78+
* opening + closing tags vanish, the element's children stay in
79+
* place as siblings. Pairs with stripping the matching CSS rule
80+
* from the demo's `<style>` block so the published source doesn't
81+
* carry an orphan selector that targets nothing.
82+
*
83+
* Match is plain class-token equality (`class="foo bar"` matches
84+
* both `foo` and `bar`); no regex, no `.` prefix. Self-closing
85+
* elements aren't unwrapped (they have no children to preserve);
86+
* the plugin leaves them in place and logs a warning at build
87+
* time. Default `[]` (feature off). */
88+
stripWrappers?: string[]
6889
}
6990

7091
interface ResolvedOptions {
@@ -73,6 +94,8 @@ interface ResolvedOptions {
7394
output: string
7495
themes: [string, string]
7596
langs: string[]
97+
stripComments: string[]
98+
stripWrappers: string[]
7699
}
77100

78101
const DEFAULT_LANGS = ['svelte', 'typescript', 'javascript', 'html']
@@ -84,10 +107,216 @@ function resolveOptions(opts: DemoManifestOptions): ResolvedOptions {
84107
examplesDir: opts.examplesDir ?? 'src/lib/examples',
85108
output: opts.output ?? 'src/lib/demo-manifest.json',
86109
themes: opts.themes ?? ['github-light', 'one-dark-pro'],
87-
langs: Array.from(new Set([...DEFAULT_LANGS, ...(opts.extraLangs ?? [])]))
110+
langs: Array.from(new Set([...DEFAULT_LANGS, ...(opts.extraLangs ?? [])])),
111+
stripComments: opts.stripComments ?? [],
112+
stripWrappers: opts.stripWrappers ?? []
88113
}
89114
}
90115

116+
/**
117+
* Strip block (`/* … *​/`) and template (`<!-- … -->`) comments whose body
118+
* contains any of the marker substrings. Lets consumers leave maintainer
119+
* notes inside demo files (explaining positioning chrome, internal
120+
* intent, etc.) without leaking that prose into the published code panel.
121+
*
122+
* Both comment forms are matched with non-greedy bodies so adjacent
123+
* comments don't accidentally fuse into a single regex match. Match is
124+
* `String.includes` — case-sensitive, no regex syntax to escape.
125+
*
126+
* Heuristic: doesn't try to tell apart comments inside strings/templates
127+
* from real comments. Demo files we ship don't embed comment delimiters
128+
* inside string literals, so we accept the simpler implementation.
129+
*/
130+
function stripMarkedComments(source: string, markers: string[]): string {
131+
if (markers.length === 0) return source
132+
133+
// Block comments: /* … */, possibly multi-line.
134+
const blockCommentRe = /\/\*[\s\S]*?\*\//g
135+
// Template comments: <!-- … -->, possibly multi-line.
136+
const templateCommentRe = /<!--[\s\S]*?-->/g
137+
138+
const shouldStrip = (body: string): boolean => markers.some((m) => body.includes(m))
139+
140+
return source
141+
.replace(blockCommentRe, (match) => (shouldStrip(match) ? '' : match))
142+
.replace(templateCommentRe, (match) => (shouldStrip(match) ? '' : match))
143+
}
144+
145+
/**
146+
* Unwrap every element whose `class` attribute contains any of the
147+
* given class tokens. The element's opening + closing tags vanish; the
148+
* children stay where they were as siblings of the original parent.
149+
*
150+
* Tag matching is depth-balanced by tag name so nested same-tag children
151+
* (e.g. a `<div>` inside our marker `<div>`) don't fool the close-tag
152+
* search. Multi-line attribute layouts are tolerated — we look at the
153+
* full text between `<` and `>` regardless of newlines.
154+
*
155+
* Self-closing matches (`<MarkerDiv ... />`) have no body to preserve;
156+
* we leave them in place and log a single warning per build (callers
157+
* presumably authored the marker class on a wrapping element, not a
158+
* leaf — silently dropping a leaf would be surprising).
159+
*
160+
* Heuristic, not an HTML parser: works for the closed set of patterns
161+
* a demo file's positioning shell uses. Won't correctly handle:
162+
* - Tags inside string literals or template-literal expressions
163+
* - Mismatched/unclosed tags (Svelte's compiler would have errored already)
164+
* - Attribute-name collisions like `data-class="..."` (matches `class=`
165+
* anchored, so this is fine in practice)
166+
*/
167+
function unwrapMarkedElements(source: string, classTokens: string[]): string {
168+
if (classTokens.length === 0) return source
169+
170+
// Match opening tags + capture tag name and attrs body.
171+
// `[^<]*?` for the attrs body keeps the regex from consuming a `<`
172+
// belonging to a sibling/child element; the outer `[\s\S]` flag is
173+
// implicit via the character-class shape.
174+
const openTagRe = /<(\w[\w-]*)([^<>]*?)>/g
175+
176+
// Within a captured attrs body, look for `class="…"` or `class='…'`
177+
// and split the value on whitespace. Tolerates spread-style
178+
// `class={…}` only loosely — we treat the literal text inside `{}`
179+
// as a class string, which is good enough for typical demo files
180+
// and degrades safely (no match → no strip) for dynamic classes.
181+
const classValueRe = /\bclass\s*=\s*(?:["']([^"']*)["']|\{([^}]*)\})/
182+
183+
const hasMarkerClass = (attrs: string): boolean => {
184+
const m = classValueRe.exec(attrs)
185+
if (!m) return false
186+
const raw = m[1] ?? m[2] ?? ''
187+
const tokens = raw.replace(/['"]/g, '').split(/\s+/).filter(Boolean)
188+
return tokens.some((t) => classTokens.includes(t))
189+
}
190+
191+
/** Find the index of the matching closing tag for `<tagName>` starting
192+
* scanning at `from`. Depth-balanced by `tagName` so nested same-tag
193+
* children don't fool us. Returns `-1` if no balanced close is found. */
194+
function findMatchingClose(tagName: string, from: number): number {
195+
const sameTagOpenRe = new RegExp(`<${tagName}\\b[^<>]*?>`, 'g')
196+
const sameTagSelfCloseRe = new RegExp(`<${tagName}\\b[^<>]*?/>`, 'g')
197+
const sameTagCloseRe = new RegExp(`</${tagName}\\s*>`, 'g')
198+
sameTagOpenRe.lastIndex = from
199+
sameTagSelfCloseRe.lastIndex = from
200+
sameTagCloseRe.lastIndex = from
201+
202+
let depth = 1
203+
let cursor = from
204+
while (depth > 0) {
205+
sameTagOpenRe.lastIndex = cursor
206+
sameTagSelfCloseRe.lastIndex = cursor
207+
sameTagCloseRe.lastIndex = cursor
208+
const opens = sameTagOpenRe.exec(source)
209+
const selfs = sameTagSelfCloseRe.exec(source)
210+
const closes = sameTagCloseRe.exec(source)
211+
if (!closes) return -1
212+
213+
// Pick whichever event happens first.
214+
const events = [opens, closes].filter(Boolean) as RegExpExecArray[]
215+
events.sort((a, b) => a.index - b.index)
216+
const next = events[0]
217+
if (!next) return -1
218+
219+
// Self-closing same-tag elements aren't real depth changes; we
220+
// detect them by checking whether the open match ends with `/>`.
221+
const isSelfClose = next === opens && /\/>\s*$/.test(next[0])
222+
if (next === opens && !isSelfClose) depth++
223+
else if (next === closes) depth--
224+
225+
cursor = next.index + next[0].length
226+
}
227+
return cursor - `</${tagName}>`.length
228+
}
229+
230+
// Collect ranges to delete; apply them right-to-left so earlier
231+
// offsets stay valid as the string shrinks.
232+
type Range = { start: number; end: number }
233+
const deletions: Range[] = []
234+
let warned = false
235+
236+
// We can't reuse `openTagRe.exec` mid-loop because we mutate the
237+
// string, so collect matches first and resolve close indices once.
238+
const matches: { tag: string; openStart: number; openEnd: number; isSelfClose: boolean }[] = []
239+
let m: RegExpExecArray | null
240+
while ((m = openTagRe.exec(source))) {
241+
const [full, tag, attrs] = m
242+
if (!hasMarkerClass(attrs)) continue
243+
const isSelfClose = /\/>\s*$/.test(full)
244+
matches.push({
245+
tag,
246+
openStart: m.index,
247+
openEnd: m.index + full.length,
248+
isSelfClose
249+
})
250+
}
251+
252+
for (const { tag, openStart, openEnd, isSelfClose } of matches) {
253+
if (isSelfClose) {
254+
if (!warned) {
255+
// eslint-disable-next-line no-console
256+
console.warn(
257+
`[docs-kit:demo-manifest] stripWrappers: self-closing <${tag}> with marker class is unsupported — leaving in place.`
258+
)
259+
warned = true
260+
}
261+
continue
262+
}
263+
const closeStart = findMatchingClose(tag, openEnd)
264+
if (closeStart === -1) {
265+
// No balanced close — leave the source untouched rather than
266+
// produce a half-stripped result.
267+
continue
268+
}
269+
const closeEnd = closeStart + `</${tag}>`.length
270+
deletions.push({ start: openStart, end: openEnd })
271+
deletions.push({ start: closeStart, end: closeEnd })
272+
}
273+
274+
// Apply right-to-left.
275+
deletions.sort((a, b) => b.start - a.start)
276+
let out = source
277+
for (const { start, end } of deletions) {
278+
out = out.slice(0, start) + out.slice(end)
279+
}
280+
return out
281+
}
282+
283+
/**
284+
* Strip rules from `<style>` blocks that target ONLY the given class
285+
* tokens. Pairs with `unwrapMarkedElements` so the published code
286+
* doesn't carry an orphan `.humanspeak-demo-shell { … }` rule pointing
287+
* at an element that no longer exists.
288+
*
289+
* Limitations:
290+
* - Doesn't try to handle compound selectors. `.marker, .other`
291+
* survives intact (it still has a useful arm). To strip the
292+
* `.marker` arm specifically we'd need a CSS parser; deferred until
293+
* we have a real reason.
294+
* - At-rules (`@media`, `@supports`, …) aren't recursed into. A demo
295+
* that hides its positioning shell behind `@media` will leave an
296+
* orphan rule; rare enough that we accept it.
297+
* - Doesn't strip the `<style>` element itself when emptied — Svelte
298+
* is happy with an empty style block and Shiki renders it as a
299+
* single trailing line, which is visually fine.
300+
*/
301+
function stripOrphanCSSRules(source: string, classTokens: string[]): string {
302+
if (classTokens.length === 0) return source
303+
304+
const escaped = classTokens.map((t) => t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|')
305+
// Selector that targets ONLY one of our marker tokens (no commas,
306+
// no descendant combinators). Allows for trailing pseudo-classes
307+
// or :global() wrapping so `.marker:hover { … }` and
308+
// `:global(.marker) { … }` both strip.
309+
const orphanRuleRe = new RegExp(
310+
`(?:^|\\n)[ \\t]*(?::global\\s*\\(\\s*)?\\.(?:${escaped})\\b[\\w:()\\-]*\\s*\\)?\\s*\\{[^}]*\\}`,
311+
'g'
312+
)
313+
314+
return source.replace(/<style([^>]*)>([\s\S]*?)<\/style>/g, (_full, attrs, body) => {
315+
const cleaned = body.replace(orphanRuleRe, '').replace(/\n{3,}/g, '\n\n')
316+
return `<style${attrs}>${cleaned}</style>`
317+
})
318+
}
319+
91320
/**
92321
* Strip docs-kit chrome from a demo source so the displayed snippet is
93322
* copy-pasteable. The raw file on disk stays as-is (it imports
@@ -260,11 +489,26 @@ async function buildManifestJson(
260489
for (const file of files) {
261490
const rel = relative(examplesRoot, file).split(sep).join('/')
262491
const raw = await readFile(file, 'utf8')
263-
// Display copy: docs-kit imports + their tags stripped, snippet
264-
// wrappers unwrapped, then prettier-formatted so the orphan
265-
// indentation left behind by the strip pass gets normalised. The
266-
// raw file on disk stays as-is.
267-
const stripped = stripDocsKitChrome(raw)
492+
// Display copy pipeline (order matters):
493+
// 1. Drop consumer-marked comments (`stripComments`) — must
494+
// run before structural strips so a comment that lives
495+
// inside an about-to-be-unwrapped element still gets
496+
// removed even if the rest of the strip succeeds.
497+
// 2. Unwrap consumer-marked wrapper elements
498+
// (`stripWrappers`). Children stay in place.
499+
// 3. Strip orphan CSS rules targeting those marker classes
500+
// from the demo's `<style>` block so the displayed source
501+
// doesn't carry selectors pointing at nothing.
502+
// 4. Strip docs-kit chrome (imports + tags + snippet
503+
// wrappers) the consumer didn't author themselves.
504+
// 5. Prettier-format the result so the orphan indentation
505+
// left behind by every strip pass gets normalised.
506+
// The raw file on disk stays as-is for all of this — only the
507+
// emitted manifest sees the cleaned form.
508+
const noComments = stripMarkedComments(raw, options.stripComments)
509+
const unwrapped = unwrapMarkedElements(noComments, options.stripWrappers)
510+
const noOrphanCSS = stripOrphanCSSRules(unwrapped, options.stripWrappers)
511+
const stripped = stripDocsKitChrome(noOrphanCSS)
268512
const code = await formatter(stripped)
269513
manifest[rel] = {
270514
code,

0 commit comments

Comments
 (0)