diff --git a/README.md b/README.md index f2e4c59..4f1d9cf 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,16 @@ Posts can include one JPEG, PNG, or WebP image up to 8 MB. Images are converted Long posts can also include multiple ordered step images. Upload them under **Step images**, then place `[[image:1]]`, `[[image:2]]`, and so on directly in the article text where each repair step needs an image. Each step image supports an optional caption and opens full-size when selected. +To add a code snippet, place it between triple-backtick fences. Add an optional language name after the opening fence to label the snippet: + +````text +```python +print("hello, circuit") +``` +```` + +Code is displayed as literal text and can be copied from the article view. Image markers written inside a code fence remain literal text. + ## Accounts and MFA The initial administrator account is `admin` with password `admin`, as requested for first-run access. Sign in, scan the displayed QR code with any iPhone or Android TOTP authenticator, and change this password before exposing the service to the internet. New registrations are held for approval in **Accounts**; accepted users must enroll a TOTP authenticator before they can publish. diff --git a/TODO.md b/TODO.md index 87569a9..a501f72 100644 --- a/TODO.md +++ b/TODO.md @@ -11,7 +11,7 @@ - [x] Configure TLS reverse proxy and production domain for `eternityproject.fi`. - [x] Add validated post image uploads with an in-article preview and click-to-expand view. - [x] Add ordered inline step images with captions for long-form repair and engineering posts. -- [ ] Add code snippets to long-form stories. +- [x] Add code snippets to long-form stories. - [x] Allow users to change their email addresses. - [x] Add user email verification for registration and email-address changes. - [x] Add user real names and use them as the published-by name. diff --git a/app.py b/app.py index c4a650f..846ebf1 100644 --- a/app.py +++ b/app.py @@ -233,16 +233,22 @@ def save_inline_images(post_id): def article_blocks(body, images): image_map = {str(index): image for index, image in enumerate(images, start=1)} blocks = [] - marker = re.compile(r"\[\[image:(\d+)\]\]") + marker = re.compile( + r"```(?P[A-Za-z0-9][A-Za-z0-9_-]*)?[ \t]*\r?\n(?P.*?)(?:\r?\n)?```|\[\[image:(?P\d+)\]\]", + re.DOTALL, + ) cursor = 0 for match in marker.finditer(body): if match.start() > cursor: blocks.append(("text", body[cursor:match.start()])) - image = image_map.get(match.group(1)) - if image is not None: - blocks.append(("image", image)) + if match.group("code") is not None: + blocks.append(("code", {"language": match.group("language") or "text", "content": match.group("code")})) else: - blocks.append(("text", match.group(0))) + image = image_map.get(match.group("image")) + if image is not None: + blocks.append(("image", image)) + else: + blocks.append(("text", match.group(0))) cursor = match.end() if cursor < len(body): blocks.append(("text", body[cursor:])) diff --git a/static/css/site.css b/static/css/site.css index fb087f2..2472ac0 100644 --- a/static/css/site.css +++ b/static/css/site.css @@ -14,6 +14,7 @@ nav { display:flex; align-items:center; gap:22px; font-family:"DM Mono", monospa .auth-layout { min-height:calc(100vh - 160px); padding:70px max(10vw, 24px); display:grid; grid-template-columns:1fr minmax(280px, 390px); gap:10vw; align-items:center; }.auth-copy h1 { font-size:clamp(45px, 6vw, 82px); line-height:.9; margin:15px 0; }.auth-copy p { max-width:380px; font-size:18px; line-height:1.5; color:var(--muted); }.auth-form, .editor-form { display:grid; gap:19px; }.auth-form { padding:27px; border:1px solid var(--ink); background:rgba(241,241,233,.8); }.auth-form label, .editor-form label { display:grid; gap:7px; font:11px "DM Mono", monospace; }input, textarea { width:100%; resize:vertical; background:#fbfbf6; border:1px solid var(--ink); border-radius:0; padding:12px; color:var(--ink); font:15px "Space Grotesk", sans-serif; }input:focus, textarea:focus { outline:3px solid var(--acid); outline-offset:1px; }.form-switch { font-size:13px; color:var(--muted); }.form-switch a { color:var(--ink); text-decoration:underline; }.editor-form { max-width:900px; }.two-col { display:grid; grid-template-columns:1fr 2fr; gap:19px; } .step-images { margin:0; padding:16px; border:1px solid var(--ink); }.step-images legend { padding:0 5px; font:11px "DM Mono", monospace; }.step-images p { margin:0 0 15px; color:var(--muted); font-size:13px; line-height:1.45; }.step-images code { color:var(--orange); font:12px "DM Mono", monospace; }.step-image-row { display:grid; grid-template-columns:1fr 1fr; gap:12px; margin-bottom:12px; }.add-step-image { justify-self:start; border:1px solid var(--ink); background:transparent; padding:8px 10px; font:11px "DM Mono", monospace; cursor:pointer; }.add-step-image:hover { background:var(--acid); } .article { max-width:850px; margin:0 auto; padding:80px 24px 100px; }.article h1 { font-size:clamp(48px, 7vw, 90px); line-height:.93; margin:23px 0; }.article-lead { border-left:4px solid var(--orange); padding-left:18px; max-width:720px; font-size:23px; line-height:1.35; }.byline { padding:20px 0; border-top:1px solid var(--ink); border-bottom:1px solid var(--ink); margin:35px 0; }.article-image-button { display:block; max-width:680px; width:100%; margin:0 0 30px; padding:0; border:0; background:none; cursor:zoom-in; }.article-image { display:block; width:100%; max-height:430px; object-fit:cover; border:1px solid var(--ink); }.inline-image { max-width:680px; margin:28px 0; }.inline-image .article-image-button { margin:0; }.inline-image figcaption { padding-top:8px; color:var(--muted); font:12px "DM Mono", monospace; }.image-dialog { width:min(94vw, 1200px); max-width:none; padding:0; border:1px solid var(--ink); background:var(--ink); }.image-dialog::backdrop { background:rgba(17,33,43,.85); }.image-dialog img { display:block; width:100%; max-height:88vh; object-fit:contain; }.dialog-close { position:absolute; top:10px; right:10px; border:1px solid var(--paper); background:var(--ink); color:var(--paper); padding:8px 10px; font:12px "DM Mono", monospace; cursor:pointer; }.article-body { max-width:680px; font-size:18px; line-height:1.7; margin-bottom:35px; }.article-text { white-space:pre-wrap; } +.code-block { margin:28px 0; border:1px solid var(--ink); background:var(--ink); }.code-block-head { display:flex; align-items:center; justify-content:space-between; min-height:38px; padding:7px 10px 7px 14px; color:var(--paper); font:11px "DM Mono", monospace; letter-spacing:.7px; text-transform:uppercase; }.copy-code { border:1px solid var(--paper); background:transparent; color:var(--paper); padding:5px 8px; font:11px "DM Mono", monospace; cursor:pointer; }.copy-code:hover { border-color:var(--acid); background:var(--acid); color:var(--ink); }.code-block pre { margin:0; padding:18px; overflow:auto; background:#fbfbf6; color:var(--ink); font:14px/1.55 "DM Mono", monospace; white-space:pre; }.code-block code { font:inherit; } .flash { margin:16px max(5vw, 24px) 0; padding:11px 14px; font:12px "DM Mono", monospace; border:1px solid var(--ink); }.flash.error { border-color:var(--orange); background:#ffe3d9; }.flash.success { background:var(--acid); }footer { border-top:1px solid var(--ink); padding:23px max(5vw, 24px); display:flex; justify-content:space-between; font:10px "DM Mono", monospace; color:var(--muted); } .mfa-qr { display:block; width:200px; max-width:100%; margin-top:28px; border:8px solid #fff; image-rendering:pixelated; }.mfa-manual { margin-top:18px; color:var(--muted); font:12px "DM Mono", monospace; }.mfa-manual summary { cursor:pointer; }.mfa-secret { display:block; width:max-content; max-width:100%; overflow-wrap:anywhere; margin-top:10px; padding:14px; border:1px solid var(--ink); background:var(--acid); color:var(--ink); font:14px "DM Mono", monospace; }.account-table { border:1px solid var(--ink); }.account-row { display:grid; grid-template-columns:2fr 1.5fr 1fr 1fr; gap:16px; align-items:center; min-height:64px; padding:12px 16px; border-bottom:1px solid var(--ink); font:13px "DM Mono", monospace; }.account-row:last-child { border-bottom:0; }.account-head { min-height:auto; padding:10px 16px; background:var(--ink); color:var(--paper); font-size:10px; }.account-row small { color:var(--orange); font:10px "DM Mono", monospace; }.account-row .email { display:block; margin-top:5px; color:var(--muted); overflow-wrap:anywhere; } @media (max-width:720px) { .site-header { align-items:flex-start; }.site-header nav { justify-content:flex-end; flex-wrap:wrap; gap:10px 15px; }.hero { min-height:560px; }.scope { width:220px; height:220px; right:-45px; }.post-grid, .auth-layout, .two-col, .step-image-row { grid-template-columns:1fr; }.auth-layout { gap:40px; padding:60px 24px; }footer { flex-wrap:wrap; gap:8px 16px; }.user-chip { display:none; }.account-row { grid-template-columns:1fr 1fr; }.account-head { display:none; } } diff --git a/templates/editor.html b/templates/editor.html index 0e13d74..4ad3201 100644 --- a/templates/editor.html +++ b/templates/editor.html @@ -6,7 +6,7 @@
- +
Step images

Upload images for individual repair steps, then place [[image:1]], [[image:2]], and so on in the article text.

diff --git a/templates/post.html b/templates/post.html index f739cd8..e30fad7 100644 --- a/templates/post.html +++ b/templates/post.html @@ -1,7 +1,7 @@ {% extends 'base.html' %} {% block title %}{{ post.title }} | Eternity Project{% endblock %} {% block content %} -

{{ post.title }}

{{ post.excerpt }}

{% if post.image_filename %}{% endif %}
{% for kind, content in blocks %}{% if kind == 'text' %}
{{ content }}
{% else %}
{% if content.caption %}
{{ content.caption }}
{% endif %}
{% endif %}{% endfor %}
{% if current_user and current_user.id == post.author_id %}Edit post{% endif %}
+

{{ post.title }}

{{ post.excerpt }}

{% if post.image_filename %}{% endif %}
{% for kind, content in blocks %}{% if kind == 'text' %}
{{ content }}
{% elif kind == 'code' %}
{{ content.language }}
{{ content.content }}
{% else %}
{% if content.caption %}
{{ content.caption }}
{% endif %}
{% endif %}{% endfor %}
{% if current_user and current_user.id == post.author_id %}Edit post{% endif %}
- + {% endblock %} diff --git a/test_code_snippets.py b/test_code_snippets.py new file mode 100644 index 0000000..d3dedbe --- /dev/null +++ b/test_code_snippets.py @@ -0,0 +1,29 @@ +import unittest + +import app as application + + +class CodeSnippetTestCase(unittest.TestCase): + def test_fenced_code_is_parsed_with_its_language(self): + blocks = application.article_blocks('Intro\n```python\nprint("hi")\n```\nEnd', []) + + self.assertEqual(blocks[0], ("text", "Intro\n")) + self.assertEqual(blocks[1], ("code", {"language": "python", "content": 'print("hi")'})) + self.assertEqual(blocks[2], ("text", "\nEnd")) + + def test_code_fence_keeps_image_marker_as_literal_code(self): + image = {"filename": "step.webp"} + blocks = application.article_blocks("```\n[[image:1]]\n```\n[[image:1]]", [image]) + + self.assertEqual(blocks[0], ("code", {"language": "text", "content": "[[image:1]]"})) + self.assertEqual(blocks[1], ("text", "\n")) + self.assertEqual(blocks[2], ("image", image)) + + def test_unterminated_fence_is_plain_text(self): + body = "```python\nprint('not closed')" + + self.assertEqual(application.article_blocks(body, []), [("text", body)]) + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file