max / makenotwork
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
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> |