specra 0.2.58 → 0.2.60

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.
@@ -5,9 +5,19 @@
5
5
  code: string;
6
6
  language: string;
7
7
  filename?: string;
8
+ showLineNumbers?: boolean;
8
9
  }
9
10
 
10
- let { code, language, filename }: Props = $props();
11
+ let { code, language, filename, showLineNumbers = false }: Props = $props();
12
+
13
+ // Languages whose syntax the JS/TS-oriented tokenizer below does NOT model.
14
+ // Highlighting these would mis-colour prose markup (e.g. a markdown list `-`
15
+ // becomes a red "operator", `# heading` becomes a grey "comment"), so they are
16
+ // rendered as plain, uncoloured text instead.
17
+ const PLAIN_LANGUAGES = new Set([
18
+ 'markdown', 'md', 'mdx', 'text', 'txt', 'plaintext', 'plain', '',
19
+ ]);
20
+ let isPlain = $derived(PLAIN_LANGUAGES.has((language || '').toLowerCase()));
11
21
 
12
22
  let copied = $state(false);
13
23
  let copyTimeout: ReturnType<typeof setTimeout> | undefined;
@@ -115,7 +125,7 @@
115
125
 
116
126
  <!-- Code content -->
117
127
  <div class="bg-muted/30 dark:bg-muted/20 rounded-b-xl overflow-x-auto border border-border/50">
118
- <pre class="p-2 text-[13px] font-mono leading-relaxed text-gray-800 dark:text-gray-200"><code class="table w-full">{#each lines as line, i}{@const isDeletion = language === 'diff' && line.startsWith('-') && !line.startsWith('--')}{@const isAddition = language === 'diff' && line.startsWith('+') && !line.startsWith('++')}{@const isDiff = isDeletion || isAddition}{@const diffBgClass = isDeletion ? 'bg-red-500/5 dark:bg-red-500/10' : isAddition ? 'bg-green-500/5 dark:bg-green-500/10' : ''}{@const diffMarkerClass = isDeletion ? 'text-red-600 dark:text-red-400' : isAddition ? 'text-green-600 dark:text-green-400' : ''}{@const tokens = tokenizeLine(line)}<div class="table-row {diffBgClass}"><span class="table-cell pr-4 text-right select-none text-muted-foreground/40 w-8 align-top">{i + 1}</span><span class="table-cell align-top">{#if tokens.length === 0}&nbsp;{:else}{#each tokens as token, j}{#if j === 0 && isDiff && token.value.length > 0 && (token.value[0] === '+' || token.value[0] === '-')}<span class="{diffMarkerClass} font-bold">{token.value[0]}</span>{#if token.value.length > 1}<span class="token-{token.type}">{token.value.slice(1)}</span>{/if}{:else}<span class="token-{token.type}">{token.value}</span>{/if}{/each}{/if}</span></div>{/each}</code></pre>
128
+ <pre class="p-2 text-[13px] font-mono leading-relaxed text-gray-800 dark:text-gray-200"><code class="table w-full">{#each lines as line, i}{@const isDeletion = language === 'diff' && line.startsWith('-') && !line.startsWith('--')}{@const isAddition = language === 'diff' && line.startsWith('+') && !line.startsWith('++')}{@const isDiff = isDeletion || isAddition}{@const diffBgClass = isDeletion ? 'bg-red-500/5 dark:bg-red-500/10' : isAddition ? 'bg-green-500/5 dark:bg-green-500/10' : ''}{@const diffMarkerClass = isDeletion ? 'text-red-600 dark:text-red-400' : isAddition ? 'text-green-600 dark:text-green-400' : ''}{@const tokens = isPlain ? [] : tokenizeLine(line)}<div class="table-row {diffBgClass}">{#if showLineNumbers}<span class="table-cell pr-4 text-right select-none text-muted-foreground/40 w-8 align-top">{i + 1}</span>{/if}<span class="table-cell align-top">{#if isPlain}{#if line}{line}{:else}&nbsp;{/if}{:else}{#if tokens.length === 0}&nbsp;{:else}{#each tokens as token, j}{#if j === 0 && isDiff && token.value.length > 0 && (token.value[0] === '+' || token.value[0] === '-')}<span class="{diffMarkerClass} font-bold">{token.value[0]}</span>{#if token.value.length > 1}<span class="token-{token.type}">{token.value.slice(1)}</span>{/if}{:else}<span class="token-{token.type}">{token.value}</span>{/if}{/each}{/if}{/if}</span></div>{/each}</code></pre>
119
129
  </div>
120
130
  </div>
121
131
 
@@ -2,6 +2,7 @@ interface Props {
2
2
  code: string;
3
3
  language: string;
4
4
  filename?: string;
5
+ showLineNumbers?: boolean;
5
6
  }
6
7
  declare const CodeBlock: import("svelte").Component<Props, {}, "">;
7
8
  type CodeBlock = ReturnType<typeof CodeBlock>;
@@ -23,8 +23,19 @@ export declare function validatePathWithinDirectory(filePath: string, allowedDir
23
23
  */
24
24
  export declare function scanMDXForDangerousPatterns(content: string): string[];
25
25
  /**
26
- * Sanitize MDX content by removing/escaping dangerous patterns
27
- * This is a defensive measure - ideally content should be rejected if dangerous
26
+ * Sanitize MDX content by neutralizing dangerous patterns in executable prose,
27
+ * while leaving fenced code blocks and inline code completely untouched.
28
+ *
29
+ * Code spans are not executable — the code renderer displays their contents as
30
+ * escaped text — so dangerous-looking examples inside them (e.g. a documented
31
+ * `<script>` tag) must be preserved verbatim, never stripped. We mask code spans
32
+ * with placeholder tokens, sanitize only the surrounding prose, then restore
33
+ * them unchanged.
34
+ *
35
+ * In prose, script tags are *escaped* (turned into inert, visible literal text)
36
+ * rather than deleted, so content never silently vanishes. Event-handler
37
+ * attributes and `javascript:` URLs are invisible attack vectors on otherwise
38
+ * valid HTML, so those are removed (neutralizing them deletes no visible text).
28
39
  */
29
40
  export declare function sanitizeMDXContent(content: string, strict?: boolean): string;
30
41
  /**
Binary file
package/dist/mdx.js CHANGED
@@ -3,6 +3,7 @@ import path from "path";
3
3
  import matter from "gray-matter";
4
4
  import yaml from "js-yaml";
5
5
  import { rehypeBasePath } from "./rehype-base-path.js";
6
+ import { remarkCodeMeta } from "./remark-code-meta.js";
6
7
  import { unified } from "unified";
7
8
  import remarkParse from "remark-parse";
8
9
  import remarkGfm from "remark-gfm";
@@ -518,6 +519,7 @@ async function processMarkdownToHtml(markdown) {
518
519
  .use(remarkParse)
519
520
  .use(remarkGfm)
520
521
  .use(remarkMath)
522
+ .use(remarkCodeMeta)
521
523
  .use(remarkRehype, { allowDangerousHtml: true })
522
524
  .use(rehypeRaw)
523
525
  .use(rehypeSlug)
@@ -575,9 +577,20 @@ function extractCodeBlockProps(node) {
575
577
  const language = langClass ? langClass.replace('language-', '') : 'txt';
576
578
  // Extract text content from the code element
577
579
  const code = extractTextContent(codeChild).replace(/\n$/, '');
578
- // Check for filename in data attributes (e.g. from remark-code-meta)
579
- const filename = node.properties?.['data-filename'] || codeChild.properties?.['data-filename'];
580
- return { code, language, ...(filename ? { filename } : {}) };
580
+ // Read attributes set by remark-code-meta from the fence meta. rehypeRaw
581
+ // normalises `data-foo` to the camel-cased `dataFoo` hast property, so accept
582
+ // both spellings, on either the <pre> or the <code>.
583
+ const readDataAttr = (camel, kebab) => node.properties?.[camel] ?? node.properties?.[kebab] ??
584
+ codeChild.properties?.[camel] ?? codeChild.properties?.[kebab];
585
+ const filename = readDataAttr('dataFilename', 'data-filename');
586
+ const lineNumbers = readDataAttr('dataLineNumbers', 'data-line-numbers');
587
+ const showLineNumbers = lineNumbers === 'true' || lineNumbers === true || lineNumbers === '';
588
+ return {
589
+ code,
590
+ language,
591
+ ...(filename ? { filename } : {}),
592
+ ...(showLineNumbers ? { showLineNumbers: true } : {}),
593
+ };
581
594
  }
582
595
  /**
583
596
  * Detect GitHub-style alert blockquotes: > [!WARNING] content
@@ -1176,6 +1189,7 @@ async function processMarkdownToMdxNodes(markdown) {
1176
1189
  .use(remarkParse)
1177
1190
  .use(remarkGfm)
1178
1191
  .use(remarkMath)
1192
+ .use(remarkCodeMeta)
1179
1193
  .use(remarkRehype, { allowDangerousHtml: true })
1180
1194
  .use(rehypeRaw)
1181
1195
  .use(rehypeSlug)
@@ -1,6 +1,14 @@
1
1
  /**
2
- * Remark plugin to extract code block meta strings and pass them as props
3
- * Converts: ```js filename.js
4
- * Into props: { language: 'js', meta: 'filename.js' }
2
+ * Remark plugin to read a code block's fence meta string and expose the
3
+ * recognised options as `data-*` attributes on the rendered <code>.
4
+ *
5
+ * Supported syntax:
6
+ * ```js title="app.js" -> data-filename="app.js"
7
+ * ```ts title='src/index.ts' -> data-filename="src/index.ts"
8
+ * ```js showLineNumbers -> data-line-numbers="true"
9
+ * ```js showLineNumbers=true -> data-line-numbers="true"
10
+ *
11
+ * Line numbers are opt-in: only an explicit `showLineNumbers` (or
12
+ * `showLineNumbers=true`) turns them on; absence (or `=false`) leaves them off.
5
13
  */
6
14
  export declare function remarkCodeMeta(): (tree: any) => void;
@@ -1,16 +1,38 @@
1
1
  /**
2
- * Remark plugin to extract code block meta strings and pass them as props
3
- * Converts: ```js filename.js
4
- * Into props: { language: 'js', meta: 'filename.js' }
2
+ * Remark plugin to read a code block's fence meta string and expose the
3
+ * recognised options as `data-*` attributes on the rendered <code>.
4
+ *
5
+ * Supported syntax:
6
+ * ```js title="app.js" -> data-filename="app.js"
7
+ * ```ts title='src/index.ts' -> data-filename="src/index.ts"
8
+ * ```js showLineNumbers -> data-line-numbers="true"
9
+ * ```js showLineNumbers=true -> data-line-numbers="true"
10
+ *
11
+ * Line numbers are opt-in: only an explicit `showLineNumbers` (or
12
+ * `showLineNumbers=true`) turns them on; absence (or `=false`) leaves them off.
5
13
  */
6
14
  export function remarkCodeMeta() {
7
15
  return (tree) => {
8
16
  const visit = (node) => {
9
17
  if (node.type === 'code' && node.meta) {
10
- // Store the meta string in the node's data
11
- node.data = node.data || {};
12
- node.data.hProperties = node.data.hProperties || {};
13
- node.data.hProperties.meta = node.meta;
18
+ const hProperties = {};
19
+ // title="..." / title='...' -> filename shown in the code block header.
20
+ const titleMatch = node.meta.match(/\btitle\s*=\s*"([^"]*)"/i) ||
21
+ node.meta.match(/\btitle\s*=\s*'([^']*)'/i);
22
+ if (titleMatch) {
23
+ hProperties['data-filename'] = titleMatch[1];
24
+ }
25
+ // showLineNumbers (opt-in). Bare flag or `=true` enables; `=false` keeps off.
26
+ const lineNumbersMatch = node.meta.match(/\bshowLineNumbers(?:\s*=\s*(true|false))?\b/i);
27
+ if (lineNumbersMatch &&
28
+ (lineNumbersMatch[1] === undefined ||
29
+ lineNumbersMatch[1].toLowerCase() === 'true')) {
30
+ hProperties['data-line-numbers'] = 'true';
31
+ }
32
+ if (Object.keys(hProperties).length > 0) {
33
+ node.data = node.data || {};
34
+ node.data.hProperties = { ...(node.data.hProperties || {}), ...hProperties };
35
+ }
14
36
  }
15
37
  if (node.children) {
16
38
  node.children.forEach(visit);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "specra",
3
- "version": "0.2.58",
3
+ "version": "0.2.60",
4
4
  "description": "A modern documentation library for SvelteKit with built-in versioning, API reference generation, full-text search, and MDX support",
5
5
  "svelte": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",