al_marimo 1.0.0

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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: d04f121dc6cb7e4d4d132f0ba0e7912df875b73707013bfa35a66a5f5be8586b
4
+ data.tar.gz: a44b52b29146749d4e7a754de13530670a0f0c48d718bfa6a40fd954e9e52a4e
5
+ SHA512:
6
+ metadata.gz: 93d3a014fa33632f6fdb78ed349388c94ebf282bf59f0c54e354759783589d97cc7ce2c8ff5c7880be8ec2be6080430c8a0dacacca891ff7a435b008b9e82fba
7
+ data.tar.gz: f177be12c37a379ee733af04c910f80e3bbd961cb8faa8e60ffa5aee52a2cc04a76d89decd3f353214a76184665ec4369c435097298938645a96871b71110c11
data/CHANGELOG.md ADDED
@@ -0,0 +1,11 @@
1
+ # Changelog
2
+
3
+ ## 1.0.0 — 2026-08-02
4
+
5
+ - Initial release of `al_marimo`, implementing the plugin proposed in
6
+ [alshedivat/al-folio#3541](https://github.com/alshedivat/al-folio/issues/3541) (source PR
7
+ [#3517](https://github.com/alshedivat/al-folio/pull/3517)).
8
+ - `{% al_marimo_embed %}` for hosted notebooks and `.al-marimo-inline` conversion for in-page Python snippets.
9
+ - The `marimo-snippets` runtime is **vendored and version-pinned** with recorded provenance, rather than loaded from a
10
+ CDN at an unpinned major version as the source PR did.
11
+ - Embedded notebooks are sandboxed **without** `allow-same-origin`.
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) al-folio maintainers
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,90 @@
1
+ # al_marimo
2
+
3
+ [marimo](https://marimo.io) interactive Python notebooks for [al-folio](https://github.com/alshedivat/al-folio) v1.x.
4
+ Implements the plugin proposed in [al-folio#3541](https://github.com/alshedivat/al-folio/issues/3541).
5
+
6
+ ## Install
7
+
8
+ ```ruby
9
+ gem "al_marimo", "= 1.0.0"
10
+ ```
11
+
12
+ and in `_config.yml` — **both lists, or the plugin is inert**:
13
+
14
+ ```yaml
15
+ plugins:
16
+ - al_marimo
17
+ ```
18
+
19
+ Then opt in **per page**, in front matter:
20
+
21
+ ```yaml
22
+ ---
23
+ layout: post
24
+ title: a notebook post
25
+ marimo: true
26
+ ---
27
+ ```
28
+
29
+ Nothing is loaded on pages without that flag, so bundling the plugin costs nothing site-wide.
30
+
31
+ ## Usage
32
+
33
+ ### Embedding a hosted notebook
34
+
35
+ ```liquid
36
+ {% al_marimo_embed src="https://marimo.app/l/abc123" height="700px" caption="Sampling demo" %}
37
+ ```
38
+
39
+ Accepts `src` (required), `height`, `width`, `mode` (`read` / `edit`), `embed`, `title`, `caption`, `class`. `src` may
40
+ be a literal or a Liquid variable.
41
+
42
+ ### Inline runnable snippets
43
+
44
+ Wrap fenced Python blocks in a container:
45
+
46
+ <pre>
47
+ &lt;div class="al-marimo-inline" markdown="1"&gt;
48
+
49
+ ```python
50
+ import marimo as mo
51
+ mo.md("Hello from the browser")
52
+ ```
53
+
54
+ &lt;/div&gt;
55
+ </pre>
56
+
57
+ The runtime moves those blocks into a `<marimo-iframe>` after load.
58
+
59
+ ## Differences from the source PR
60
+
61
+ The source PR was explicitly a work in progress. Two things changed:
62
+
63
+ **The runtime is vendored, not fetched from a CDN.** The PR loaded
64
+ `https://cdn.jsdelivr.net/npm/@marimo-team/marimo-snippets@1` — an unpinned major version, with no integrity hash,
65
+ executing in every visitor's page. That is the shape of the polyfill.io supply-chain attack. Pinning it with SRI is not
66
+ possible either: that URL serves a _dynamically minified_ build, and jsDelivr
67
+ [documents that such files must not be used with SRI](https://www.jsdelivr.com/using-sri-with-dynamic-files) because a
68
+ minifier upgrade changes the bytes and would break every site at once. So the stable unminified source is vendored at an
69
+ exact version, with its digest recorded in [`lib/vendor/provenance.json`](lib/vendor/provenance.json) and a test that
70
+ fails if the shipped file drifts from it.
71
+
72
+ **Embedded notebooks do not get `allow-same-origin`.** The PR's sandbox included it, which lets the embedded
73
+ third-party notebook reach the embedding page's storage and cookies — defeating the purpose of the sandbox.
74
+
75
+ ## Note on third-party contact
76
+
77
+ The notebook itself executes on `marimo.app` (or your own WASM host). Embedding one means your readers' browsers contact
78
+ that origin. The vendored runtime also references a marimo-hosted icon. Nothing is sent unless a page opts in.
79
+
80
+ ## Development
81
+
82
+ ```bash
83
+ bundle install
84
+ bundle exec rake test
85
+ npm ci && npm run lint:prettier
86
+ ```
87
+
88
+ ## License
89
+
90
+ MIT
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AlMarimo
4
+ VERSION = "1.0.0"
5
+ end
data/lib/al_marimo.rb ADDED
@@ -0,0 +1,196 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cgi"
4
+ require "json"
5
+ require "jekyll"
6
+ require "liquid"
7
+ require_relative "al_marimo/version"
8
+
9
+ # Embeds marimo notebooks, and turns Python code blocks into runnable snippets.
10
+ #
11
+ # Two-layer gating, per the al-folio contract: the gem must be loaded *and* the
12
+ # page must opt in with `marimo: true` in its front matter. Without the page
13
+ # opt-in every tag renders an empty string, so a site that bundles this plugin
14
+ # pays nothing on pages that do not use it.
15
+ module AlMarimo
16
+ PLUGIN_ROOT = File.expand_path("..", __dir__)
17
+ # Jekyll writes a StaticFile to <dest>/<dir>/<name>, where <dir> is relative
18
+ # to the base below. That base must be lib/, not the gem root, or assets land
19
+ # at /lib/assets/... while every tag in this file points at /assets/... .
20
+ LIB_ROOT = __dir__
21
+ ASSETS_ROOT = File.join(LIB_ROOT, "assets")
22
+ VENDOR_ROOT = File.join(PLUGIN_ROOT, "lib", "vendor")
23
+
24
+ # marimo runs the notebook itself; only these origins are ever contacted.
25
+ DEFAULT_PLAYGROUND = "https://marimo.app"
26
+
27
+ class PluginStaticFile < Jekyll::StaticFile; end
28
+
29
+ module_function
30
+
31
+ def config(site)
32
+ return {} unless site
33
+
34
+ site.config["al_marimo"] || {}
35
+ end
36
+
37
+ # Page-level opt-in. Anything truthy other than the string "false" counts, so
38
+ # `marimo: true` and `marimo: snippets` both work.
39
+ #
40
+ # Reads through `[]` rather than testing for Hash: at render time Jekyll hands
41
+ # `registers[:page]` a Drop (Jekyll::Drops::DocumentDrop), not a Hash, so an
42
+ # `is_a?(Hash)` guard silently discards the front matter and the feature never
43
+ # switches on for any real page.
44
+ def page_enabled?(page)
45
+ value = begin
46
+ page.respond_to?(:[]) ? page["marimo"] : nil
47
+ rescue StandardError
48
+ nil
49
+ end
50
+ return false if value.nil? || value == false
51
+ return false if value.respond_to?(:strip) && ["", "false"].include?(value.strip.downcase)
52
+
53
+ true
54
+ end
55
+
56
+ def provenance
57
+ @provenance ||= JSON.parse(File.read(File.join(VENDOR_ROOT, "provenance.json")))
58
+ end
59
+
60
+ def escape(value)
61
+ CGI.escapeHTML(value.to_s)
62
+ end
63
+
64
+ # Builds the notebook URL, folding in the query parameters marimo understands.
65
+ #
66
+ # Appends with the correct separator rather than assuming the caller passed a
67
+ # bare URL: `?embed=true` on a URL that already has a query string produces a
68
+ # link that silently loads the wrong notebook.
69
+ def build_url(src, embed: true, mode: "read")
70
+ url = src.to_s.strip
71
+ return nil if url.empty?
72
+
73
+ separator = url.include?("?") ? "&" : "?"
74
+ if embed
75
+ url = "#{url}#{separator}embed=true"
76
+ separator = "&"
77
+ end
78
+ "#{url}#{separator}mode=#{mode == "edit" ? "edit" : "read"}"
79
+ end
80
+
81
+ # [relative_dir, filename] for everything this gem publishes. Exposed so the
82
+ # destination path can be asserted without booting a full Jekyll site.
83
+ def asset_entries
84
+ Dir.glob(File.join(ASSETS_ROOT, "**", "*")).sort.reject { |p| File.directory?(p) }.map do |source_path|
85
+ [File.dirname(source_path).sub("#{LIB_ROOT}/", ""), File.basename(source_path)]
86
+ end
87
+ end
88
+
89
+ class AssetsGenerator < Jekyll::Generator
90
+ safe true
91
+ priority :low
92
+
93
+ def generate(site)
94
+ Dir.glob(File.join(ASSETS_ROOT, "**", "*")).sort.each do |source_path|
95
+ next if File.directory?(source_path)
96
+
97
+ relative_dir = File.dirname(source_path).sub("#{LIB_ROOT}/", "")
98
+ site.static_files << PluginStaticFile.new(site, LIB_ROOT, relative_dir, File.basename(source_path))
99
+ end
100
+ end
101
+ end
102
+
103
+ # {% al_marimo_scripts %} — emits the runtime, only on pages that opted in.
104
+ class ScriptsTag < Liquid::Tag
105
+ def render(context)
106
+ site = context.registers[:site]
107
+ page = context.registers[:page]
108
+ return "" unless site && AlMarimo.page_enabled?(page)
109
+
110
+ baseurl = site.config["baseurl"] || ""
111
+ # Vendored rather than pulled from a CDN. jsDelivr serves a dynamically
112
+ # minified build at the package root and documents that it must not be
113
+ # used with SRI; loading it unpinned would hand every al-folio site's
114
+ # execution context to whatever that URL returns tomorrow.
115
+ <<~HTML
116
+ <script defer src="#{baseurl}/assets/al_marimo/js/marimo-init.js"></script>
117
+ <script defer src="#{baseurl}/assets/al_marimo/js/marimo-snippets.js"></script>
118
+ HTML
119
+ end
120
+ end
121
+
122
+ class StylesTag < Liquid::Tag
123
+ def render(context)
124
+ site = context.registers[:site]
125
+ page = context.registers[:page]
126
+ return "" unless site && AlMarimo.page_enabled?(page)
127
+
128
+ baseurl = site.config["baseurl"] || ""
129
+ %(<link rel="stylesheet" href="#{baseurl}/assets/al_marimo/css/marimo.css">\n)
130
+ end
131
+ end
132
+
133
+ # {% al_marimo_embed src="https://marimo.app/l/abc" height="600px" caption="..." %}
134
+ class EmbedTag < Liquid::Tag
135
+ ATTRIBUTES = /(\w+)\s*=\s*("[^"]*"|'[^']*'|[^\s]+)/.freeze
136
+
137
+ def initialize(tag_name, markup, options)
138
+ super
139
+ @attributes = {}
140
+ markup.scan(ATTRIBUTES) do |key, value|
141
+ @attributes[key] = value.gsub(/\A["']|["']\z/, "")
142
+ end
143
+ end
144
+
145
+ def render(context)
146
+ site = context.registers[:site]
147
+ return "" unless site
148
+
149
+ src = resolve(@attributes["src"], context)
150
+ url = AlMarimo.build_url(
151
+ src,
152
+ embed: @attributes.fetch("embed", "true") != "false",
153
+ mode: @attributes["mode"] || "read"
154
+ )
155
+ # A missing or empty src is an authoring mistake. Emit nothing rather than
156
+ # an iframe pointed at the site itself, which would silently render the
157
+ # page inside its own post.
158
+ return "" if url.nil?
159
+
160
+ height = @attributes["height"] || "600px"
161
+ width = @attributes["width"] || "100%"
162
+ title = @attributes["title"] || "marimo notebook"
163
+ caption = @attributes["caption"]
164
+ extra_class = @attributes["class"]
165
+
166
+ figure = +%(<figure class="al-marimo">)
167
+ figure << %(<div class="al-marimo-embed">)
168
+ figure << %(<iframe src="#{AlMarimo.escape(url)}")
169
+ figure << %( class="#{AlMarimo.escape(extra_class)}") if extra_class
170
+ # Deliberately omits allow-same-origin: the notebook is third-party code,
171
+ # and granting it same-origin access to the embedding site would let it
172
+ # read the parent document's storage and cookies.
173
+ figure << %( sandbox="allow-scripts allow-downloads allow-popups allow-forms")
174
+ figure << %( width="#{AlMarimo.escape(width)}" height="#{AlMarimo.escape(height)}")
175
+ figure << %( title="#{AlMarimo.escape(title)}" loading="lazy" frameborder="0" allowfullscreen></iframe>)
176
+ figure << %(</div>)
177
+ figure << %(<figcaption class="caption">#{AlMarimo.escape(caption)}</figcaption>) if caption
178
+ figure << %(</figure>)
179
+ figure
180
+ end
181
+
182
+ private
183
+
184
+ # Allows both a literal (src="https://…") and a variable (src=page.notebook).
185
+ def resolve(value, context)
186
+ return nil if value.nil?
187
+
188
+ looked_up = context[value]
189
+ looked_up.nil? ? value : looked_up
190
+ end
191
+ end
192
+ end
193
+
194
+ Liquid::Template.register_tag("al_marimo_scripts", AlMarimo::ScriptsTag)
195
+ Liquid::Template.register_tag("al_marimo_styles", AlMarimo::StylesTag)
196
+ Liquid::Template.register_tag("al_marimo_embed", AlMarimo::EmbedTag)
@@ -0,0 +1,42 @@
1
+ /* Styling for marimo embeds. Plain CSS rather than Sass: _sass/ is gem-owned by
2
+ al_folio_core and a plugin must not add to the theme's build pipeline. */
3
+
4
+ .al-marimo {
5
+ margin: 1.5rem 0;
6
+ }
7
+
8
+ .al-marimo-embed {
9
+ position: relative;
10
+ width: 100%;
11
+ /* Notebooks are wide; let the frame scroll itself rather than the page. */
12
+ overflow-x: auto;
13
+ }
14
+
15
+ .al-marimo-embed iframe {
16
+ display: block;
17
+ width: 100%;
18
+ border: 1px solid var(--global-divider-color, rgba(0, 0, 0, 0.1));
19
+ border-radius: 6px;
20
+ background: var(--global-bg-color, transparent);
21
+ }
22
+
23
+ .al-marimo figcaption,
24
+ .al-marimo .caption {
25
+ margin-top: 0.5rem;
26
+ font-size: 0.875rem;
27
+ text-align: center;
28
+ color: var(--global-text-color-light, inherit);
29
+ }
30
+
31
+ /* Hidden until marimo-init.js has moved the code blocks into <marimo-iframe>,
32
+ so readers never see the raw source flash before it becomes a notebook.
33
+ Scripts are deferred, so this is re-shown on DOMContentLoaded at the latest. */
34
+ .al-marimo-inline {
35
+ visibility: hidden;
36
+ }
37
+
38
+ /* Without JS the block would stay hidden forever, which is worse than showing
39
+ the code. Reveal it and let it render as an ordinary listing. */
40
+ .no-js .al-marimo-inline {
41
+ visibility: visible;
42
+ }
@@ -0,0 +1,114 @@
1
+ // Prepares kramdown's code-block output for marimo-snippets.
2
+ //
3
+ // marimo-snippets reads <pre> children of a <marimo-iframe>. Kramdown wraps
4
+ // fenced blocks in a .highlighter-rouge div whose nested elements contribute
5
+ // whitespace-only text nodes; marimo-snippets reads the element via textContent
6
+ // and those nodes show up as blank leading and trailing lines in the notebook.
7
+ // So the <pre> is moved out of the wrapper rather than the wrapper moved in.
8
+ //
9
+ // Runs before marimo-snippets (which is loaded after it) and is idempotent, so
10
+ // a page that re-runs it does not double-convert.
11
+ (function () {
12
+ "use strict";
13
+
14
+ var LANGUAGE_PREFIX = "language-";
15
+
16
+ function languageOf(element) {
17
+ var found = "";
18
+ Array.prototype.forEach.call(element.classList, function (name) {
19
+ if (name.indexOf(LANGUAGE_PREFIX) === 0) {
20
+ found = name;
21
+ }
22
+ });
23
+ return found;
24
+ }
25
+
26
+ // Kramdown emits a trailing newline inside <code>; marimo renders it as an
27
+ // empty final cell line.
28
+ function trim(pre) {
29
+ Array.prototype.forEach.call(pre.querySelectorAll("code"), function (code) {
30
+ code.textContent = code.textContent.replace(/^\n+/, "").replace(/\s+$/, "");
31
+ });
32
+ }
33
+
34
+ function collectBlocks(container) {
35
+ var wrappers = container.querySelectorAll(".highlighter-rouge");
36
+ var blocks = [];
37
+
38
+ if (wrappers.length > 0) {
39
+ Array.prototype.forEach.call(wrappers, function (wrapper) {
40
+ var language = languageOf(wrapper);
41
+ var pre = wrapper.querySelector("pre");
42
+ if (!pre) {
43
+ return;
44
+ }
45
+ // marimo-snippets distinguishes Python cells from markdown cells by the
46
+ // language class, which kramdown puts on the wrapper, not the <pre>.
47
+ if (language) {
48
+ pre.classList.add(language);
49
+ }
50
+ trim(pre);
51
+ blocks.push(pre);
52
+ });
53
+ return blocks;
54
+ }
55
+
56
+ Array.prototype.forEach.call(container.querySelectorAll("pre"), function (pre) {
57
+ trim(pre);
58
+ blocks.push(pre);
59
+ });
60
+ return blocks;
61
+ }
62
+
63
+ // Height is a guess, but a bad guess is very visible: too short and the
64
+ // notebook scrolls inside a stub. Scale with both cell count and line count.
65
+ function estimateHeight(blocks) {
66
+ var lines = blocks.reduce(function (total, pre) {
67
+ return total + (pre.textContent || "").split("\n").filter(Boolean).length;
68
+ }, 0);
69
+ return Math.max(300, 150 + blocks.length * 80 + lines * 22);
70
+ }
71
+
72
+ function convert(container) {
73
+ if (container.dataset.alMarimoReady === "true") {
74
+ return;
75
+ }
76
+
77
+ var blocks = collectBlocks(container);
78
+ if (blocks.length === 0) {
79
+ // Nothing to run. Leave the markup alone and make it visible again rather
80
+ // than stranding the reader with a permanently hidden block.
81
+ container.dataset.alMarimoReady = "true";
82
+ container.style.visibility = "visible";
83
+ return;
84
+ }
85
+
86
+ var frame = document.createElement("marimo-iframe");
87
+ Array.prototype.forEach.call(container.attributes, function (attribute) {
88
+ if (attribute.name.indexOf("data-") === 0 && attribute.name !== "data-al-marimo-ready") {
89
+ frame.setAttribute(attribute.name, attribute.value);
90
+ }
91
+ });
92
+ blocks.forEach(function (pre) {
93
+ frame.appendChild(pre);
94
+ });
95
+ if (!frame.hasAttribute("data-height")) {
96
+ frame.setAttribute("data-height", estimateHeight(blocks) + "px");
97
+ }
98
+
99
+ container.textContent = "";
100
+ container.appendChild(frame);
101
+ container.dataset.alMarimoReady = "true";
102
+ container.style.visibility = "visible";
103
+ }
104
+
105
+ function ready() {
106
+ Array.prototype.forEach.call(document.querySelectorAll(".al-marimo-inline"), convert);
107
+ }
108
+
109
+ if (document.readyState === "loading") {
110
+ document.addEventListener("DOMContentLoaded", ready);
111
+ } else {
112
+ ready();
113
+ }
114
+ })();
@@ -0,0 +1,213 @@
1
+ let buttonSettings = {
2
+ elements: ['pre'],
3
+ title: 'Open code in an interactive playground',
4
+ position: 'absolute',
5
+ top: '0.5rem',
6
+ right: '0.5rem',
7
+ border: 'none',
8
+ borderRadius: '4px',
9
+ padding: '4px 8px',
10
+ margin: '-4px 22px',
11
+ cursor: 'pointer',
12
+ zIndex: '10',
13
+ filter: 'grayscale(100%)',
14
+ icon: '<img src="https://cms.marimo.io/icons/favicon.svg" alt="icon" width="20" height="20">',
15
+ url: 'https://marimo.app',
16
+ paramName: 'code'
17
+ };
18
+
19
+ let iframeSettings = {
20
+ elements: ['pre'],
21
+ height: '400px',
22
+ width: '100%',
23
+ border: '1px solid #ddd',
24
+ borderRadius: '4px',
25
+ margin: '1rem 0',
26
+ showCode: 'true',
27
+ url: 'https://marimo.app',
28
+ paramName: 'code'
29
+ };
30
+
31
+ /**
32
+ * Configure interactive buttons for code blocks that open the code in a Marimo playground
33
+ *
34
+ * @param {Object} settings - Button customization options
35
+ * @param {string[]} [settings.elements=['pre', 'div.highlight'] - CSS selectors for elements to add buttons to. Default: ['pre', 'div.highlight']
36
+ * @param {string} [settings.title='Open code in an interactive playground'] - Button tooltip text
37
+ * @param {string} [settings.position='absolute'] - CSS position property
38
+ * @param {string} [settings.top='0.5rem'] - Distance from top of container
39
+ * @param {string} [settings.right='0.5rem'] - Distance from right of container
40
+ * @param {string} [settings.border='none'] - Button border style
41
+ * @param {string} [settings.borderRadius='4px'] - Button corner radius
42
+ * @param {string} [settings.padding='4px 8px'] - Button padding
43
+ * @param {string} [settings.margin='-4px 22px'] - Button margin
44
+ * @param {string} [settings.cursor='pointer'] - Mouse cursor style on hover
45
+ * @param {string} [settings.zIndex='10'] - Button stacking order
46
+ * @param {string} [settings.filter='grayscale(100%)'] - Default filter applied to button
47
+ * @param {string} [settings.icon='<img src="https://cms.marimo.io/icons/favicon.svg" alt="icon" width="20" height="20">'] - HTML content for the button
48
+ * @param {string} [settings.url='https://marimo.app'] - Base URL for the Marimo instance
49
+ * @param {string} [settings.paramName='code'] - Query parameter name for the code
50
+ */
51
+ function configureMarimoButtons(settings = {}) {
52
+ buttonSettings = { ...buttonSettings, ...settings };
53
+ }
54
+
55
+ /**
56
+ * Configure marimo iframes
57
+ *
58
+ * @param {Object} settings - Iframe customization options
59
+ * @param {string[]} [settings.elements=['pre', 'div.highlight'] - CSS selectors for elements to add buttons to. Default: ['pre', 'div.highlight']
60
+ * @param {string} [settings.height='400px'] - Height of the iframe
61
+ * @param {string} [settings.width='100%'] - Width of the iframe
62
+ * @param {string} [settings.border='1px solid #ddd'] - Border style of the iframe
63
+ * @param {string} [settings.borderRadius='4px'] - Corner radius of the iframe
64
+ * @param {string} [settings.margin='1rem 0'] - Margin around the iframe
65
+ * @param {string} [settings.showCode='true'] - Whether to show the notebook's code
66
+ * @param {string} [settings.url='https://marimo.app'] - Base URL for the Marimo instance
67
+ * @param {string} [settings.paramName='code'] - Query parameter name for the code
68
+ */
69
+ function configureMarimoIframes(settings = {}) {
70
+ iframeSettings = { ...iframeSettings, ...settings };
71
+ }
72
+
73
+ function generateCell(code, kind="python") {
74
+
75
+ if (kind === "python") {
76
+ return `@app.cell
77
+ def _():
78
+ ${code.split('\n').map(line => ' ' + line).join('\n')}
79
+ `;
80
+ }
81
+ if (kind === "md") {
82
+ return `@app.cell(hide_code=True)
83
+ def _():
84
+ mo.md("""
85
+ ${code.split('\n').map(line => ' ' + line).join('\n')}
86
+ """)
87
+ `;
88
+ }
89
+ }
90
+
91
+ function generateNotebook(cells) {
92
+ return `import marimo
93
+
94
+ app = marimo.App()
95
+
96
+ ${cells}
97
+ `;
98
+ }
99
+
100
+ /**
101
+ * Helper to override settings using data attributes.
102
+ * It takes an element and a settings object and returns a new configuration,
103
+ * where any data-* attribute (e.g. data-title or data-elements) on the element
104
+ * will override the corresponding property in the settings.
105
+ */
106
+ function overrideSettingsWithDataAttributes(element, settings) {
107
+ const config = { ...settings };
108
+ for (const key in element.dataset) {
109
+ let value = element.dataset[key];
110
+ // If key is "elements", assume a comma-separated list.
111
+ if (key.toLowerCase() === "elements") {
112
+ value = value.split(',').map(s => s.trim());
113
+ }
114
+ config[key] = value;
115
+ }
116
+ return config;
117
+ }
118
+
119
+ function createButton(codeElement, config = buttonSettings) {
120
+ const button = document.createElement('button');
121
+ button.className = 'url-copy-button';
122
+ button.title = config.title;
123
+ button.style.position = config.position;
124
+ button.style.top = config.top;
125
+ button.style.right = config.right;
126
+ button.style.border = config.border;
127
+ button.style.borderRadius = config.borderRadius;
128
+ button.style.padding = config.padding;
129
+ button.style.margin = config.margin;
130
+ button.style.cursor = config.cursor;
131
+ button.style.zIndex = config.zIndex;
132
+ button.style.filter = config.filter;
133
+ button.innerHTML = config.icon;
134
+
135
+ button.addEventListener("mouseover", function() {
136
+ button.style.filter = "grayscale(0%)";
137
+ });
138
+ button.addEventListener("mouseout", function() {
139
+ button.style.filter = config.filter;
140
+ });
141
+ button.addEventListener('click', function(e) {
142
+ e.preventDefault();
143
+ const code = generateNotebook(generateCell(codeElement.textContent));
144
+ const encodedCode = encodeURIComponent(code);
145
+ const url = `${config.url}?${config.paramName}=${encodedCode}`;
146
+ window.open(url, '_blank');
147
+ });
148
+
149
+ return button;
150
+ }
151
+
152
+ /**
153
+ * Adds interactive buttons to code blocks that open the code in a Marimo playground.
154
+ * This uses any data attributes on <marimo-button> to override defaults.
155
+ */
156
+ document.addEventListener("DOMContentLoaded", function() {
157
+ document.querySelectorAll('script[type="text/x-marimo-snippets-config"]').forEach(script => {
158
+ eval(script.textContent);
159
+ });
160
+
161
+ const buttons = document.querySelectorAll('marimo-button');
162
+ buttons.forEach(button => {
163
+ // Merge data attribute config with global buttonSettings.
164
+ const buttonConfig = overrideSettingsWithDataAttributes(button, buttonSettings);
165
+ const preElement = button.querySelector(buttonConfig.elements.join(","));
166
+ if (!preElement) {
167
+ return;
168
+ }
169
+ // Ensure the pre element has a relative position for button placement.
170
+ if (getComputedStyle(preElement).position === 'static') {
171
+ preElement.style.position = 'relative';
172
+ }
173
+ preElement.appendChild(createButton(preElement, buttonConfig));
174
+ });
175
+ });
176
+
177
+ /**
178
+ * Replaces code blocks with inline marimo notebooks.
179
+ * This uses any data attributes on <marimo-iframe> to override defaults.
180
+ */
181
+ document.addEventListener("DOMContentLoaded", function() {
182
+ const marimoFrames = document.querySelectorAll("marimo-iframe");
183
+ marimoFrames.forEach(marimoFrame => {
184
+ // Merge data attribute config with global iframeSettings.
185
+ const iframeConfig = overrideSettingsWithDataAttributes(marimoFrame, iframeSettings);
186
+
187
+ console.log("marimoFrame", marimoFrame);
188
+
189
+ const cells = Array.from(marimoFrame.children).map((element) => {
190
+ const allClassNames = Array.from(element.classList).concat(
191
+ Array.from(element.getElementsByTagName("*")).flatMap(el => Array.from(el.classList))
192
+ );
193
+ const kind = allClassNames.includes("language-python") ? "python" : "md";
194
+
195
+ return generateCell(element.textContent, kind);
196
+ });
197
+
198
+ const code = generateNotebook(cells.join("\n"));
199
+
200
+ const iframe = document.createElement('iframe');
201
+ iframe.style.height = iframeConfig.height;
202
+ iframe.style.width = iframeConfig.width;
203
+ iframe.style.border = iframeConfig.border;
204
+ iframe.style.borderRadius = iframeConfig.borderRadius;
205
+ iframe.style.margin = iframeConfig.margin;
206
+
207
+ const encodedCode = encodeURIComponent(code);
208
+ const mode = iframeConfig.showCode === 'false' ? 'read' : 'edit';
209
+ const url = `${iframeConfig.url}?${iframeConfig.paramName}=${encodedCode}&embed=true&show-chrome=false&mode=${mode}&show-code=${iframeConfig.showCode}`;
210
+ iframe.src = url;
211
+ marimoFrame.replaceWith(iframe);
212
+ });
213
+ });
@@ -0,0 +1,10 @@
1
+ {
2
+ "name": "@marimo-team/marimo-snippets",
3
+ "version": "1.0.3",
4
+ "source_url": "https://cdn.jsdelivr.net/npm/@marimo-team/marimo-snippets@1.0.3/src/extractor.js",
5
+ "sha384": "sha384-wu02bTA++0bnvSB8GwNxnJmRB7AYFTgpaidK3uEVGrV6yu6QcdOt+J9vtHvla1vZ",
6
+ "sha256": "QENbXBT2k95BXBwOn5Ctx6Pr0cp46cyYWx2gRMv7vqw=",
7
+ "bytes": 8042,
8
+ "license": "Apache-2.0",
9
+ "note": "Vendored rather than loaded from a CDN. jsDelivr's default package URL serves a dynamically minified build and documents that it must not be used with SRI, since a Terser bump changes the hash and breaks every site at once. The unminified source path is stable, so it is pinned and vendored here."
10
+ }
metadata ADDED
@@ -0,0 +1,144 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: al_marimo
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - al-folio maintainers
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-08-02 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: jekyll
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '3.9'
20
+ - - "<"
21
+ - !ruby/object:Gem::Version
22
+ version: '5.0'
23
+ type: :runtime
24
+ prerelease: false
25
+ version_requirements: !ruby/object:Gem::Requirement
26
+ requirements:
27
+ - - ">="
28
+ - !ruby/object:Gem::Version
29
+ version: '3.9'
30
+ - - "<"
31
+ - !ruby/object:Gem::Version
32
+ version: '5.0'
33
+ - !ruby/object:Gem::Dependency
34
+ name: liquid
35
+ requirement: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '4.0'
40
+ - - "<"
41
+ - !ruby/object:Gem::Version
42
+ version: '6.0'
43
+ type: :runtime
44
+ prerelease: false
45
+ version_requirements: !ruby/object:Gem::Requirement
46
+ requirements:
47
+ - - ">="
48
+ - !ruby/object:Gem::Version
49
+ version: '4.0'
50
+ - - "<"
51
+ - !ruby/object:Gem::Version
52
+ version: '6.0'
53
+ - !ruby/object:Gem::Dependency
54
+ name: bundler
55
+ requirement: !ruby/object:Gem::Requirement
56
+ requirements:
57
+ - - ">="
58
+ - !ruby/object:Gem::Version
59
+ version: '2.0'
60
+ - - "<"
61
+ - !ruby/object:Gem::Version
62
+ version: '3.0'
63
+ type: :development
64
+ prerelease: false
65
+ version_requirements: !ruby/object:Gem::Requirement
66
+ requirements:
67
+ - - ">="
68
+ - !ruby/object:Gem::Version
69
+ version: '2.0'
70
+ - - "<"
71
+ - !ruby/object:Gem::Version
72
+ version: '3.0'
73
+ - !ruby/object:Gem::Dependency
74
+ name: rake
75
+ requirement: !ruby/object:Gem::Requirement
76
+ requirements:
77
+ - - "~>"
78
+ - !ruby/object:Gem::Version
79
+ version: '13.0'
80
+ type: :development
81
+ prerelease: false
82
+ version_requirements: !ruby/object:Gem::Requirement
83
+ requirements:
84
+ - - "~>"
85
+ - !ruby/object:Gem::Version
86
+ version: '13.0'
87
+ - !ruby/object:Gem::Dependency
88
+ name: minitest
89
+ requirement: !ruby/object:Gem::Requirement
90
+ requirements:
91
+ - - "~>"
92
+ - !ruby/object:Gem::Version
93
+ version: '5.0'
94
+ type: :development
95
+ prerelease: false
96
+ version_requirements: !ruby/object:Gem::Requirement
97
+ requirements:
98
+ - - "~>"
99
+ - !ruby/object:Gem::Version
100
+ version: '5.0'
101
+ description: Embeds marimo notebooks and turns Python code blocks into runnable in-browser
102
+ snippets.
103
+ email:
104
+ - maintainers@al-folio.dev
105
+ executables: []
106
+ extensions: []
107
+ extra_rdoc_files: []
108
+ files:
109
+ - CHANGELOG.md
110
+ - LICENSE
111
+ - README.md
112
+ - lib/al_marimo.rb
113
+ - lib/al_marimo/version.rb
114
+ - lib/assets/al_marimo/css/marimo.css
115
+ - lib/assets/al_marimo/js/marimo-init.js
116
+ - lib/assets/al_marimo/js/marimo-snippets.js
117
+ - lib/vendor/provenance.json
118
+ homepage: https://github.com/al-org-dev/al-marimo
119
+ licenses:
120
+ - MIT
121
+ metadata:
122
+ allowed_push_host: https://rubygems.org
123
+ homepage_uri: https://github.com/al-org-dev/al-marimo
124
+ source_code_uri: https://github.com/al-org-dev/al-marimo
125
+ post_install_message:
126
+ rdoc_options: []
127
+ require_paths:
128
+ - lib
129
+ required_ruby_version: !ruby/object:Gem::Requirement
130
+ requirements:
131
+ - - ">="
132
+ - !ruby/object:Gem::Version
133
+ version: '2.7'
134
+ required_rubygems_version: !ruby/object:Gem::Requirement
135
+ requirements:
136
+ - - ">="
137
+ - !ruby/object:Gem::Version
138
+ version: '0'
139
+ requirements: []
140
+ rubygems_version: 3.5.22
141
+ signing_key:
142
+ specification_version: 4
143
+ summary: Marimo interactive notebook plugin for al-folio v1.x
144
+ test_files: []