@@ -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
7091interface 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
78101const 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 = / \b c l a s s \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 ( / < s t y l e ( [ ^ > ] * ) > ( [ \s \S ] * ?) < \/ s t y l e > / 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