Skip to content

Commit 1412afb

Browse files
feature/try-it-code-block-button
1 parent cb379fb commit 1412afb

File tree

3 files changed

+158
-3
lines changed

3 files changed

+158
-3
lines changed

components/blocks/code.js

Lines changed: 84 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React, { useEffect, useRef } from "react";
1+
import React, { useEffect, useRef, useState } from "react";
22
import classNames from "classnames";
33
import Prism from "prismjs";
44
import "prismjs/plugins/line-numbers/prism-line-numbers";
@@ -12,6 +12,87 @@ import Image from "./image";
1212

1313
import styles from "./code.module.css";
1414

15+
// Compress code with gzip and encode as base64 for Streamlit Playground
16+
async function compressCodeForPlayground(code) {
17+
const encoder = new TextEncoder();
18+
const data = encoder.encode(code);
19+
20+
const cs = new CompressionStream("gzip");
21+
const writer = cs.writable.getWriter();
22+
writer.write(data);
23+
writer.close();
24+
25+
const compressedChunks = [];
26+
const reader = cs.readable.getReader();
27+
28+
while (true) {
29+
const { done, value } = await reader.read();
30+
if (done) break;
31+
compressedChunks.push(value);
32+
}
33+
34+
// Combine all chunks into a single Uint8Array
35+
const totalLength = compressedChunks.reduce(
36+
(acc, chunk) => acc + chunk.length,
37+
0,
38+
);
39+
const compressed = new Uint8Array(totalLength);
40+
let offset = 0;
41+
for (const chunk of compressedChunks) {
42+
compressed.set(chunk, offset);
43+
offset += chunk.length;
44+
}
45+
46+
// Convert to URL-safe base64 (uses - and _ instead of + and /)
47+
let binary = "";
48+
for (let i = 0; i < compressed.length; i++) {
49+
binary += String.fromCharCode(compressed[i]);
50+
}
51+
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_");
52+
}
53+
54+
// TryMeButton component
55+
const TryMeButton = ({ code }) => {
56+
const [playgroundUrl, setPlaygroundUrl] = useState(null);
57+
58+
useEffect(() => {
59+
async function generateUrl() {
60+
if (code) {
61+
const encoded = await compressCodeForPlayground(code.trim());
62+
setPlaygroundUrl(
63+
`https://streamlit.io/playground?example=blank&code=${encoded}`,
64+
);
65+
}
66+
}
67+
generateUrl();
68+
}, [code]);
69+
70+
if (!playgroundUrl) return null;
71+
72+
return (
73+
<a
74+
href={playgroundUrl}
75+
target="_blank"
76+
rel="noopener noreferrer"
77+
className={styles.TryMeButton}
78+
title="Try this code in Streamlit Playground"
79+
>
80+
<span className={styles.TryMeLabel}>Try it!</span>
81+
<svg
82+
className={styles.TryMeIcon}
83+
viewBox="0 0 24 24"
84+
fill="none"
85+
stroke="currentColor"
86+
strokeWidth="2"
87+
strokeLinecap="round"
88+
strokeLinejoin="round"
89+
>
90+
<polygon points="5 3 19 12 5 21 5 3" />
91+
</svg>
92+
</a>
93+
);
94+
};
95+
1596
// Initialize the cache for imported languages.
1697
const languageImports = new Map();
1798

@@ -62,6 +143,7 @@ const Code = ({
62143
hideCopyButton = false,
63144
filename,
64145
filenameOnly = true,
146+
try: tryIt = false,
65147
}) => {
66148
// Create a ref for the code element.
67149
const codeRef = useRef(null);
@@ -135,6 +217,7 @@ const Code = ({
135217
<span className={styles.Language}>{displayLanguage}</span>
136218
)}
137219
{filename && <span className={styles.Filename}>{filename}</span>}
220+
{tryIt && <TryMeButton code={customCode} />}
138221
</div>
139222
);
140223

components/blocks/code.module.css

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,3 +76,22 @@
7676
}
7777

7878
/* Syntax highlighting is now handled globally via styles/syntax-highlighting.scss */
79+
80+
/* Try Me Button */
81+
.TryMeButton {
82+
@apply flex items-center gap-1 px-2 py-1 rounded text-xs font-medium transition-all duration-150;
83+
@apply text-red-70 hover:text-white hover:bg-red-70;
84+
text-decoration: none !important;
85+
}
86+
87+
:global(.dark) .TryMeButton {
88+
@apply text-red-50 hover:text-white hover:bg-red-60;
89+
}
90+
91+
.TryMeIcon {
92+
@apply w-3 h-3;
93+
}
94+
95+
.TryMeLabel {
96+
@apply hidden sm:inline;
97+
}

pages/[...slug].js

Lines changed: 55 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,29 @@ import { useRouter } from "next/router";
1414
import rehypeSlug from "rehype-slug";
1515
import rehypeAutolinkHeadings from "rehype-autolink-headings";
1616
import getConfig from "next/config";
17+
18+
// Custom remark plugin to pass code fence metadata through to the HTML
19+
// e.g., ```python try filename="app.py"``` adds data-meta attribute
20+
function remarkCodeMeta() {
21+
// Simple recursive tree walker (avoids ESM import issues with unist-util-visit)
22+
function walkTree(node, callback) {
23+
callback(node);
24+
if (node.children) {
25+
node.children.forEach((child) => walkTree(child, callback));
26+
}
27+
}
28+
29+
return (tree) => {
30+
walkTree(tree, (node) => {
31+
if (node.type === "code" && node.meta) {
32+
// Store meta in data.hProperties which remark-rehype passes to the element
33+
node.data = node.data || {};
34+
node.data.hProperties = node.data.hProperties || {};
35+
node.data.hProperties["data-meta"] = node.meta;
36+
}
37+
});
38+
};
39+
}
1740
const { serverRuntimeConfig, publicRuntimeConfig } = getConfig();
1841

1942
// Site Components
@@ -167,7 +190,37 @@ export default function Article({
167190
goToLatest={goToLatest}
168191
/>
169192
),
170-
pre: (props) => <Code {...props} />,
193+
pre: (props) => {
194+
// Extract metadata from code fence (e.g., ```python try filename="app.py" filenameOnly)
195+
// The metadata is passed via data-meta attribute from our remark plugin
196+
const codeElement = props.children;
197+
const metaString = codeElement?.props?.["data-meta"] || "";
198+
199+
// Parse metadata into props
200+
const codeProps = {};
201+
202+
if (metaString) {
203+
// Match boolean flags (standalone words) and key="value" pairs
204+
const booleanFlags = ["try", "filenameOnly", "hideCopyButton"];
205+
206+
// Extract key="value" pairs first
207+
const keyValueRegex = /(\w+)=["']([^"']+)["']/g;
208+
let match;
209+
while ((match = keyValueRegex.exec(metaString)) !== null) {
210+
codeProps[match[1]] = match[2];
211+
}
212+
213+
// Check for boolean flags (words not part of key=value)
214+
const cleanedMeta = metaString.replace(keyValueRegex, "");
215+
booleanFlags.forEach((flag) => {
216+
if (new RegExp(`\\b${flag}\\b`).test(cleanedMeta)) {
217+
codeProps[flag] = true;
218+
}
219+
});
220+
}
221+
222+
return <Code {...props} {...codeProps} />;
223+
},
171224
h1: H1,
172225
h2: H2,
173226
h3: H3,
@@ -410,7 +463,7 @@ export async function getStaticProps(context) {
410463
scope: data,
411464
mdxOptions: {
412465
rehypePlugins: [rehypeSlug, rehypeAutolinkHeadings],
413-
remarkPlugins: [remarkUnwrapImages, remarkGfm],
466+
remarkPlugins: [remarkUnwrapImages, remarkGfm, remarkCodeMeta],
414467
},
415468
});
416469

0 commit comments

Comments
 (0)