Skip to main content

max / makenotwork

Describe the four markdown section editors as one Rich field Shape 5. All four hand-written markdown textareas call crate::quasi::rich_field now, so the box, the Write/Preview pair and the preview pane come out of makeover-webview 0.52.0 rather than out of four templates and one regex. The id is built as <prefix>-body through Filling::id_prefix, which is exactly what the four templates already had: text-body, post-body, new-psec-body, edit-psec-body, new-section-body. That is what keeps media-picker.js and each surface's own script working, since both reach these controls by id from outside. The preview is filled from a new POST /api/preview/markdown, which runs render_creator_markdown: the pipeline that publishes, ammonia and media-host restriction included. What it replaces was a regex over h1-h3, bold, italic and inline code, so a creator now previews what saving does rather than what that regex did. Sanitising stays where it already was. markdown-editor.js binds the pair wherever one appears and rescans on htmx:after:settle. Nothing is shown until it sets data-ready, so a reader with no script gets the plain textarea. Autosave and section reordering stay hand-written, by ruling. Height is the app's: three sizes in style.css replace 400px of CSS and three different rows attributes. partial-item-text-editor.js loses 32 lines; the other three keep every line they had, which is what the shape turned out to be. openapi.json's version string was already stale at HEAD (0.11.21 against 0.12.0) and is regenerated here.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-20 17:58 UTC
Signed with PGP, not checked
Commit: 69979a5e8d2ab195be9dd84229308a6c7a78b18d
Parent: 084e419
16 files changed, +517 insertions, -135 deletions
@@ -281,6 +281,31 @@
281 281 {% endfor %}
282 282 ```
283 283
284 + ### Markdown fields
285 +
286 + A textarea whose value is markdown is not written by hand. Call the described
287 + field instead, from any Askama template:
288 +
289 + ```html
290 + {{ crate::quasi::rich_field::html("post", "Content", "Write it in Markdown...", post_body, crate::quasi::rich_field::Height::Tall)|safe }}
291 + ```
292 +
293 + The first argument scopes the control's `id`, which comes out as
294 + `<prefix>-body`. Pick the prefix so the id matches whatever already reaches the
295 + control from outside: `media-picker.js` is handed a textarea id as a literal
296 + `data-arg`, and each surface's own script reads its value back with
297 + `getElementById`.
298 +
299 + What comes back is the whole group: label, textarea, a Write/Preview pair and
300 + an empty preview pane, all from `makeover-webview`, with the matching rules in
301 + the generated `static/layout.css`. `static/markdown-editor.js` binds the pair
302 + wherever one appears, including after an HTMX swap, and fills the pane from
303 + `POST /api/preview/markdown` so the preview is what publishing produces rather
304 + than a second renderer. Nothing renders markdown in the browser.
305 +
306 + Autosave and section reordering stay hand-written per surface. Neither is
307 + described, on the ruling behind Shape 5 (wiki `mnw-shape-conversion-plans`).
308 +
284 309 ### Template Variables
285 310
286 311 Every full-page template needs at minimum:
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "makenotwork"
3 - version = "0.12.0"
3 + version = "0.13.0"
4 4 edition = "2024"
5 5 license = "LicenseRef-PolyForm-Noncommercial-1.0.0"
6 6 # Server binary: never published to a registry. Marks the crate private so
@@ -163,6 +163,13 @@
163 163 # screen names FieldKind and Tone directly; it has to track what quasi-router
164 164 # resolves or the two `layout::` paths are different crates.
165 165 makeover-layout = "0.31.0"
166 + # The webview renderer's field emitter, called directly by
167 + # `quasi::rich_field` so one markdown editor's markup comes from the same
168 + # place a whole described screen's would. Pinned here rather than reached
169 + # through quasi-webview for makeover-layout's reason: two `form::` paths from
170 + # two resolutions are two crates, and the `data-format="markdown"` rules in
171 + # `static/layout.css` are generated from this one.
172 + makeover-webview = "0.52.0"
166 173 # For the request head the per-viewer state factory reads. axum re-exports it,
167 174 # but the factory's signature is quasi-axum's and names `http::request::Parts`.
168 175 http = "1.3.1"
@@ -6,7 +6,7 @@
6 6 "license": {
7 7 "name": "PolyForm Noncommercial 1.0.0"
8 8 },
9 - "version": "0.11.21"
9 + "version": "0.13.0"
10 10 },
11 11 "paths": {
12 12 "/api/git/{owner}/{repo}/notes": {
@@ -180,6 +180,12 @@
180 180 // API read endpoints (GET): burst 60, then 10/sec (prevents enumeration)
181 181 pub const API_READ_RATE_LIMIT_MS: u64 = 100;
182 182 pub const API_READ_RATE_LIMIT_BURST: u32 = 60;
183 + // Markdown preview: burst 10, then 2/sec. A preview is a button press rather
184 + // than a keystroke, so it sits above the validation limiter and well below the
185 + // write one; the work is one `render_permissive` over a bounded body and
186 + // nothing is stored.
187 + pub const PREVIEW_RATE_LIMIT_PER_SEC: u64 = 2;
188 + pub const PREVIEW_RATE_LIMIT_BURST: u32 = 10;
183 189 // API export endpoints: burst 3, then 1/sec
184 190 pub const API_EXPORT_RATE_LIMIT_PER_SEC: u64 = 1;
185 191 pub const API_EXPORT_RATE_LIMIT_BURST: u32 = 3;
@@ -583,6 +589,7 @@
583 589 const _: () = assert!(API_WRITE_RATE_LIMIT_BURST > 0);
584 590 const _: () = assert!(API_READ_RATE_LIMIT_BURST > 0);
585 591 const _: () = assert!(API_EXPORT_RATE_LIMIT_BURST > 0);
592 + const _: () = assert!(PREVIEW_RATE_LIMIT_BURST > 0);
586 593 const _: () = assert!(LICENSE_KEY_RATE_LIMIT_BURST > 0);
587 594 const _: () = assert!(UPLOAD_RATE_LIMIT_BURST > 0);
588 595 const _: () = assert!(OAUTH_RATE_LIMIT_BURST > 0);
@@ -2,40 +2,12 @@
2 2 var __cfg = document.getElementById('partial-item-text-editor-cfg');
3 3 var ITEM_ID = __cfg ? __cfg.dataset.itemId : '';
4 4
5 - function switchEditorTab(tab) {
6 - document.querySelectorAll('.editor-tab').forEach(t => t.classList.remove('is-selected'));
7 - document.querySelector('.editor-tab[data-tab="' + tab + '"]').classList.add('is-selected');
8 -
9 - document.querySelectorAll('.editor-panel').forEach(p => p.classList.remove('active'));
10 - document.getElementById(tab + '-panel').classList.add('active');
11 -
12 - if (tab === 'preview') {
13 - renderMarkdownPreview();
14 - }
15 - }
16 -
17 - function renderMarkdownPreview() {
18 - const body = document.getElementById('text-body').value;
19 - const preview = document.getElementById('markdown-preview');
20 -
21 - // Basic markdown rendering (paragraphs, bold, italic, headers)
22 - let html = body
23 - .split('\n\n').map(p => p.trim()).filter(p => p).map(p => {
24 - // Headers
25 - if (p.startsWith('### ')) return '<h3>' + escapeHtml(p.slice(4)) + '</h3>';
26 - if (p.startsWith('## ')) return '<h2>' + escapeHtml(p.slice(3)) + '</h2>';
27 - if (p.startsWith('# ')) return '<h1>' + escapeHtml(p.slice(2)) + '</h1>';
28 - // Paragraph with inline formatting
29 - let text = escapeHtml(p);
30 - text = text.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
31 - text = text.replace(/\*(.+?)\*/g, '<em>$1</em>');
32 - text = text.replace(/`(.+?)`/g, '<code>$1</code>');
33 - return '<p>' + text + '</p>';
34 - }).join('\n');
35 -
36 - preview.innerHTML = html || '<p class="text-editor-placeholder">Nothing to preview...</p>';
37 - }
38 -
5 + // The Write/Preview pair, the preview pane and the markdown rendering behind
6 + // them are the described field's now (crate::quasi::rich_field, bound by
7 + // markdown-editor.js). What used to be here was a regex over h1-h3, bold,
8 + // italic and inline code, which is not what publishing does; the pane is
9 + // filled from /api/preview/markdown instead. What stays is this screen's own:
10 + // the word count, the explicit save, and the autosave clock.
39 11
40 12 function updateWordCount() {
41 13 const body = document.getElementById('text-body').value;
@@ -106,6 +78,5 @@
106 78 }, 30000);
107 79 });
108 80
109 - window.switchEditorTab = switchEditorTab;
110 81 window.saveTextContent = saveTextContent;
111 82 })();
@@ -5183,9 +5183,8 @@
5183 5183 Apply `.is-selected` to any interactive container to mark it as the
5184 5184 currently-selected option. A badge never takes it: a badge reports a status
5185 5185 and nobody selects "Enabled", so badges take a `data-tone` instead.
5186 - Visibility toggles (`.tab-content.active`, `.section-panel.active`,
5187 - `.editor-panel.active`) also keep `.active` since they're not selection
5188 - state. */
5186 + Visibility toggles (`.tab-content.active`, `.section-panel.active`) also
5187 + keep `.active` since they're not selection state. */
5189 5188 .is-selected {
5190 5189 background: var(--highlight-faint);
5191 5190 border-color: var(--focus-ring);
@@ -5368,52 +5367,6 @@
5368 5367 width: 100%;
5369 5368 }
5370 5369
5371 - .editor-tabs {
5372 - display: flex;
5373 - gap: 0;
5374 - margin-bottom: 0;
5375 - }
5376 -
5377 - .editor-tab {
5378 - background: var(--surface-sunken);
5379 - border: none;
5380 - padding: var(--gap-peer) var(--gap-pane);
5381 - font-family: var(--font-mono);
5382 - font-size: var(--text-note);
5383 - cursor: pointer;
5384 - opacity: 0.6;
5385 - transition: opacity 0.2s ease, background 0.2s ease;
5386 - }
5387 -
5388 - .editor-tab.is-selected {
5389 - background: var(--surface-sunken);
5390 - opacity: 1;
5391 - }
5392 -
5393 - .editor-tab:hover {
5394 - opacity: 1;
5395 - }
5396 -
5397 - .editor-panel {
5398 - display: none;
5399 - }
5400 -
5401 - .editor-panel.active {
5402 - display: block;
5403 - }
5404 -
5405 - .editor-panel textarea {
5406 - width: 100%;
5407 - min-height: 400px;
5408 - padding: var(--gap-section);
5409 - font-family: var(--font-mono);
5410 - font-size: var(--text-note);
5411 - line-height: 1.6;
5412 - resize: vertical;
5413 - border: none;
5414 - background: var(--surface-sunken);
5415 - }
5416 -
5417 5370 .editor-footer {
5418 5371 display: flex;
5419 5372 justify-content: space-between;
@@ -5423,38 +5376,78 @@
5423 5376 opacity: 0.7;
5424 5377 }
5425 5378
5426 - .markdown-preview {
5379 + /* ===========================================
5380 + RICH FIELD (crate::quasi::rich_field)
5381 + =========================================== */
5382 +
5383 + /* The wrapper is this app's; the group inside it is the renderer's, and
5384 + `static/layout.css` carries every rule that decides what is showing. What is
5385 + left for this file is the two things a description does not say: how tall the
5386 + editor stands, and how the rendered preview reads.
5387 +
5388 + Selected through the `[data-format="markdown"]` mark rather than through
5389 + makeover's class names, so nothing here overlaps the generated sheet. */
5390 +
5391 + .rich-field [data-format="markdown"] > textarea {
5392 + padding: var(--gap-section);
5393 + font-family: var(--font-mono);
5394 + font-size: var(--text-note);
5395 + line-height: 1.6;
5396 + resize: vertical;
5397 + border: none;
5398 + background: var(--surface-sunken);
5399 + }
5400 +
5401 + /* Three sizes for four surfaces: the item editor and the blog editor were
5402 + already the same surface written two ways (400px of CSS, and rows="20"). */
5403 + .rich-field--compact [data-format="markdown"] > textarea,
5404 + .rich-field--compact [data-editor-preview] {
5405 + min-height: 9rem;
5406 + }
5407 +
5408 + .rich-field--standard [data-format="markdown"] > textarea,
5409 + .rich-field--standard [data-editor-preview] {
5410 + min-height: 15rem;
5411 + }
5412 +
5413 + .rich-field--tall [data-format="markdown"] > textarea,
5414 + .rich-field--tall [data-editor-preview] {
5427 5415 min-height: 400px;
5416 + }
5417 +
5418 + /* The pane stands where the control stood, so it reads as prose rather than as
5419 + source. The rules below are the retired `.markdown-preview` set, unchanged
5420 + except for what selects them. */
5421 + .rich-field [data-editor-preview] {
5428 5422 padding: var(--gap-pane);
5429 - background: var(--surface-overlay);
5430 5423 font-family: var(--font-sans);
5431 5424 line-height: 1.8;
5432 5425 }
5433 5426
5434 - .markdown-preview h1,
5435 - .markdown-preview h2,
5436 - .markdown-preview h3 {
5427 + .rich-field [data-editor-preview] h1,
5428 + .rich-field [data-editor-preview] h2,
5429 + .rich-field [data-editor-preview] h3 {
5437 5430 margin-top: var(--gap-pane);
5438 5431 margin-bottom: var(--gap-peer);
5439 5432 }
5440 5433
5441 - .markdown-preview h1 {
5434 + .rich-field [data-editor-preview] h1 {
5442 5435 font-size: var(--text-title);
5443 5436 }
5444 5437
5445 - .markdown-preview h2 {
5438 + .rich-field [data-editor-preview] h2 {
5446 5439 font-size: var(--text-head);
5447 5440 }
5448 5441
5449 - .markdown-preview h3 {
5442 + .rich-field [data-editor-preview] h3 {
5450 5443 font-size: var(--text-lead);
5451 5444 }
5452 5445
5453 - .markdown-preview p {
5446 + .rich-field [data-editor-preview] p {
5454 5447 margin-bottom: var(--gap-section);
5455 5448 }
5456 5449
5457 - .markdown-preview code {
5450 + .rich-field [data-editor-preview] code {
5458 5451 background: var(--surface-sunken);
5459 5452 padding: var(--gap-bound);
5460 5453 font-family: var(--font-mono);
@@ -11070,10 +11063,6 @@
11070 11063 font-size: var(--text-note);
11071 11064 }
11072 11065
11073 - .text-editor-placeholder {
11074 - opacity: 0.6;
11075 - }
11076 -
11077 11066 .text-editor-actions {
11078 11067 margin-top: var(--gap-section);
11079 11068 }
@@ -67,6 +67,11 @@
67 67 <script src="/static/synckit-billing.js?v=0620"></script>
68 68 <script src="/static/synckit-tabs.js?v=0623"></script>
69 69 <script src="/static/whats_new.js?v=0522"></script>
70 + {# Binds the described markdown field's Write/Preview chrome wherever one
71 + appears. Loaded here rather than beside each of the four surfaces because
72 + three of them arrive inside HTMX-swapped partials, and the binder rescans
73 + on settle. #}
74 + <script src="/static/markdown-editor.js?v=0820"></script>
70 75 {% block scripts %}{% endblock %}
71 76 </body>
72 77 </html>
@@ -37,6 +37,7 @@
37 37 pub mod library_contacts;
38 38 pub mod library_tabs;
39 39 pub mod project_tabs;
40 + pub mod rich_field;
40 41 pub mod settings_tabs;
41 42 pub mod ssh_keys;
42 43 pub mod user_analytics;
@@ -34,11 +34,11 @@
34 34 <span id="blog-slug-spinner" class="htmx-indicator blog-editor-slug-spinner">Checking...</span>
35 35 <span id="blog-slug-status"></span>
36 36 </div>
37 - <div class="form-group">
38 - <label for="post-body">Content (Markdown)</label>
39 - <textarea id="post-body" rows="20" class="blog-editor-input input--sm w-full" placeholder="Write your post in Markdown...">{{ post_body }}</textarea>
40 - <button type="button" class="btn-secondary blog-editor-media-btn" data-action="mediaPickerOpen" data-arg="post-body">Insert Image</button>
41 - </div>
37 + {# The described markdown field. Keeps the id `post-body`, which is
38 + what the Insert Image button hands the media picker and what
39 + `blog-editor.js` reads on save and on autosave. #}
40 + {{ crate::quasi::rich_field::html("post", "Content (Markdown)", "Write your post in Markdown...", post_body, crate::quasi::rich_field::Height::Tall)|safe }}
41 + <button type="button" class="btn-secondary blog-editor-media-btn" data-action="mediaPickerOpen" data-arg="post-body">Insert Image</button>
42 42 {% if is_changelog_project %}
43 43 <div class="form-group blog-editor-landing-toggle">
44 44 <label for="post-show-on-landing">
@@ -1,22 +1,14 @@
1 + {# The Write/Preview pair, the textarea and the preview pane are the described
2 + markdown field's, not this template's: see `crate::quasi::rich_field`. The
3 + control keeps the id `text-body`, which is what the Insert Image button below
4 + passes to the media picker and what `partial-item-text-editor.js` reads. #}
1 5 <div class="text-editor" id="text-editor">
2 - <div class="editor-tabs">
3 - <button type="button" class="editor-tab is-selected" data-tab="write" data-action="switchEditorTab" data-arg="write">Write</button>
4 - <button type="button" class="editor-tab" data-tab="preview" data-action="switchEditorTab" data-arg="preview">Preview</button>
5 - </div>
6 + {{ crate::quasi::rich_field::html("text", "Content", "Write your content in Markdown...", body.as_deref().unwrap_or(""), crate::quasi::rich_field::Height::Tall)|safe }}
6 7
7 - <div class="editor-panel active" id="write-panel">
8 - <textarea id="text-body" name="body" placeholder="Write your content in Markdown...">{{ body.as_deref().unwrap_or("") }}</textarea>
9 - <button type="button" class="btn-secondary text-editor-insert-btn" data-action="mediaPickerOpen" data-arg="text-body">Insert Image</button>
10 - <div class="editor-footer">
11 - <span class="word-count" id="word-count">{{ word_count.unwrap_or(0) }} words</span>
12 - <span class="reading-time">{{ reading_time_minutes.unwrap_or(1) }} min read</span>
13 - </div>
14 - </div>
15 -
16 - <div class="editor-panel" id="preview-panel">
17 - <div class="markdown-preview" id="markdown-preview">
18 - <p class="text-editor-placeholder">Preview will appear here...</p>
19 - </div>
8 + <button type="button" class="btn-secondary text-editor-insert-btn" data-action="mediaPickerOpen" data-arg="text-body">Insert Image</button>
9 + <div class="editor-footer">
10 + <span class="word-count" id="word-count">{{ word_count.unwrap_or(0) }} words</span>
11 + <span class="reading-time">{{ reading_time_minutes.unwrap_or(1) }} min read</span>
20 12 </div>
21 13
22 14 <div class="text-editor-actions">
@@ -29,4 +21,4 @@
29 21 </div>
30 22
31 23 <div id="partial-item-text-editor-cfg" hidden data-item-id="{{ item.id }}"></div>
32 - <script src="/static/partial-item-text-editor.js?v=0623"></script>
24 + <script src="/static/partial-item-text-editor.js?v=0820"></script>