diff --git a/CLAUDE.md b/CLAUDE.md index 9de3d9e..fb6e47c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -176,6 +176,15 @@ The parts that bite if you miss them: - **Links are `django.core.signing` tokens, nothing stored**, and the email tasks take the row, never a link, since django-q keeps task arguments in the database. +## Guest posts live in `apps/blog/` + +`DEVELOPMENT.md` has the details. What bites: + +- **A `PostSubmission` is a queue row, never a page.** Posts are frontend MDX, so accepting one + publishes nothing; the admin's `.mdx` download is the editor's starting file. +- **`body_md` is never rendered as HTML on the backend.** Writes go through + `apps/blog/services.py`, and accept/reject are admin actions over `services.review`. + ## Email Templates live in `backend/apps/core/templates/email//`; `DEVELOPMENT.md` has the diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index ea7bba4..9f080a0 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -302,6 +302,7 @@ production. Tests always use the in-memory backend. | `benchmark_staff_notice` | the same, only when it is waiting for review; links to the admin via `BACKEND_URL` | every active `is_staff` account | | `benchmark_reviewed` | an editor approves (links to the tool's report) or rejects (passes on the note) | the submitter | | `comment_staff_notice` | a comment lands awaiting review, or an edit sends a published one back | every active `is_staff` account | +| `post_submission_staff_notice` | a guest post is submitted (`POST /blog/submissions`); links to the admin via `BACKEND_URL` | every active `is_staff` account | | `comment_reply` | a reply is published - on posting or on approval - and not by the author it answers | the parent comment's author | A staff member's own benchmark submission approves itself on scoring and mails nobody. @@ -592,6 +593,33 @@ What bites: - Abuse control is `NEWSLETTER_SUBSCRIBE_RATE` per IP: each request mails whatever address it names. +## Guest posts + +The footer's "Submit a post" leads to `/submit-post`. The page is public; the form behind it +needs a signed-in account (the first Google sign-in creates one). It takes a title, summary, tags, +a markdown body in `@uiw/react-md-editor` (loaded client-only, previewing through our own +``) and a byline: name, role, bio and up to three links. +`POST /blog/submissions` stores a `PostSubmission` (`backend/apps/blog/`) as `pending` and mails +staff. The writer follows its status under "Posts you submitted" on `/activity`. + +**A submission is a queue row, never a page.** Posts are MDX in `frontend/content/blog/`, built +into the bundle, so accepting one in the admin publishes nothing. The admin's change page offers +the post as a `.mdx` download with the frontmatter filled in and `draft: true`. The editor still +registers the author in `src/lib/blog/authors.ts`, adds the id, and commits the file. + +What bites: + +- **`body_md` is never rendered as HTML on the backend.** The admin shows it in a `
`, and the
+  email carries a plain excerpt.
+- **Writes go through `apps/blog/services.py`.** Refusals are `PostSubmissionError`, a
+  `StaffError`, so they reach the form as a 422 `detail`. Accept and reject are admin actions
+  calling `services.review`, which stamps `reviewed_by` / `reviewed_at`. Only `review_note`,
+  which the writer sees, is editable on the change page.
+- **Author links go through `validate_external_url`**, the catalog's SSRF guard, though nothing
+  fetches them today: they end up as links on our site.
+- Limits are settings: `BLOG_SUBMISSION_RATE` per account, `BLOG_SUBMISSION_MAX_OPEN` pending
+  per account, and `BLOG_SUBMISSION_MIN_LENGTH` / `BLOG_SUBMISSION_MAX_LENGTH` on the body.
+
 ## Rendering and the API-down build
 
 Catalog routes and `sitemap.ts` declare `export const dynamic = 'force-dynamic'`. The frontend
diff --git a/backend/.env.example b/backend/.env.example
index 94c538b..c86ecb8 100644
--- a/backend/.env.example
+++ b/backend/.env.example
@@ -76,6 +76,14 @@ PUBLIC_MEDIA_URL=http://localhost:8007/media
 # Per-IP rate of footer sign-ups; each one mails a confirmation link.
 # NEWSLETTER_SUBSCRIBE_RATE=10/hour
 
+# --- Blog submissions -----------------------------------------------------
+# Guest posts sent from /submit-post: the per-account rate, how many one account
+# may have awaiting review, and the shortest and longest body accepted.
+# BLOG_SUBMISSION_RATE=5/day
+# BLOG_SUBMISSION_MAX_OPEN=3
+# BLOG_SUBMISSION_MIN_LENGTH=300
+# BLOG_SUBMISSION_MAX_LENGTH=60000
+
 # --- Benchmarks -----------------------------------------------------------
 # Largest redacted PDF a submission may carry, the per-account write rate on
 # submissions, the screenshots one run may carry, and the per-run scoring timeout
diff --git a/backend/apps/blog/__init__.py b/backend/apps/blog/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/backend/apps/blog/admin.py b/backend/apps/blog/admin.py
new file mode 100644
index 0000000..18c1735
--- /dev/null
+++ b/backend/apps/blog/admin.py
@@ -0,0 +1,77 @@
+from django.contrib import admin
+from django.core.exceptions import PermissionDenied
+from django.http import HttpResponse
+from django.shortcuts import get_object_or_404
+from django.urls import path, reverse
+from django.utils.html import format_html
+from unfold.admin import ModelAdmin
+
+from apps.blog import mdx, services
+from apps.blog.models import PostSubmission, PostSubmissionStatus
+
+
+@admin.register(PostSubmission)
+class PostSubmissionAdmin(ModelAdmin):
+    list_display = ("title", "author_name", "submitted_by", "status", "created_at")
+    list_filter = ("status",)
+    search_fields = ("title", "author_name", "submitted_by__email")
+    ordering = ("-created_at",)
+    # What the writer sent is evidence, not a draft to edit here; the decision goes
+    # through the actions so it is stamped. Only the note to the writer is typed in.
+    fields = (
+        "status",
+        "review_note",
+        "mdx_export",
+        "title",
+        "description",
+        "tags",
+        "body",
+        "author_name",
+        "author_role",
+        "author_bio",
+        "author_links",
+        "submitted_by",
+        "created_at",
+        "reviewed_by",
+        "reviewed_at",
+    )
+    readonly_fields = tuple(f for f in fields if f != "review_note")
+    actions = ("accept_submissions", "reject_submissions")
+
+    @admin.action(description="Accept - turn into MDX by hand")
+    def accept_submissions(self, request, queryset):
+        for submission in queryset:
+            services.review(submission, user=request.user, status=PostSubmissionStatus.ACCEPTED)
+        self.message_user(request, f"Accepted {queryset.count()} posts.")
+
+    @admin.action(description="Reject - keep the row, so the writer sees the outcome")
+    def reject_submissions(self, request, queryset):
+        for submission in queryset:
+            services.review(submission, user=request.user, status=PostSubmissionStatus.REJECTED)
+        self.message_user(request, f"Rejected {queryset.count()} posts.")
+
+    @admin.display(description="Body (markdown)")
+    def body(self, obj):
+        return format_html('
{}
', obj.body_md) + + @admin.display(description="MDX") + def mdx_export(self, obj): + url = reverse("admin:blog_postsubmission_mdx", args=[obj.pk]) + return format_html('Download {}', url, mdx.filename(obj)) + + def get_urls(self): + export = path( + "/mdx/", + self.admin_site.admin_view(self.export_mdx), + name="blog_postsubmission_mdx", + ) + return [export, *super().get_urls()] + + def export_mdx(self, request, pk): + """The post as a file to drop into `frontend/content/blog/` and finish there.""" + if not self.has_view_permission(request): + raise PermissionDenied + submission = get_object_or_404(PostSubmission, pk=pk) + response = HttpResponse(mdx.render(submission), content_type="text/markdown") + response["Content-Disposition"] = f'attachment; filename="{mdx.filename(submission)}"' + return response diff --git a/backend/apps/blog/api.py b/backend/apps/blog/api.py new file mode 100644 index 0000000..4e3b67e --- /dev/null +++ b/backend/apps/blog/api.py @@ -0,0 +1,40 @@ +"""Guest post submissions. Blog posts themselves are MDX in the frontend, so a +submission is a queue row for staff to read, never a page.""" + +from django.http import HttpRequest +from ninja import Router, Status + +from apps.accounts.api import JWTAuth +from apps.blog import services +from apps.blog.schemas import PostSubmissionIn, PostSubmissionOut +from apps.blog.throttles import PostSubmissionThrottle +from apps.catalog.services import client_ip + +router = Router(tags=["blog"]) + + +@router.post( + "/submissions", + response={201: PostSubmissionOut}, + auth=JWTAuth(), + throttle=[PostSubmissionThrottle()], + summary="Submit a blog post for review", +) +def create_post_submission(request: HttpRequest, payload: PostSubmissionIn): + submission = services.submit_post( + user=request.auth, + data=payload.dict(), + source_ip=client_ip(request), + user_agent=request.META.get("HTTP_USER_AGENT", ""), + ) + return Status(201, submission) + + +@router.get( + "/submissions", + response=list[PostSubmissionOut], + auth=JWTAuth(), + summary="Blog posts you have submitted", +) +def list_my_post_submissions(request: HttpRequest): + return request.auth.post_submissions.all() diff --git a/backend/apps/blog/apps.py b/backend/apps/blog/apps.py new file mode 100644 index 0000000..370754f --- /dev/null +++ b/backend/apps/blog/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class BlogConfig(AppConfig): + name = "apps.blog" + label = "blog" + verbose_name = "Blog" diff --git a/backend/apps/blog/emails.py b/backend/apps/blog/emails.py new file mode 100644 index 0000000..566c5e7 --- /dev/null +++ b/backend/apps/blog/emails.py @@ -0,0 +1,48 @@ +"""The staff notice for each guest post. Queued, so the writer's request never waits +on or fails with the mail provider.""" + +from django.conf import settings +from django.db import transaction +from django.urls import reverse +from django.utils.text import Truncator + +from apps.blog.models import PostSubmission +from apps.core.email import send_templated_email, staff_addresses + +EXCERPT_CHARS = 600 + + +def queue_staff_notice(submission): + from django_q.tasks import async_task + + # The pk, never the row: django-q keeps task arguments in the database. + transaction.on_commit( + lambda: async_task( + "apps.blog.emails.send_staff_notice", + submission.pk, + task_name=f"post submission notice {submission.pk}", + ) + ) + + +def send_staff_notice(submission_pk): + staff = staff_addresses() + if not staff: + return + + submission = PostSubmission.objects.select_related("submitted_by").get(pk=submission_pk) + send_templated_email( + "post_submission_staff_notice", + { + "title": submission.title, + "description": submission.description, + "author_name": submission.author_name, + "author_role": submission.author_role, + "tags": ", ".join(submission.tags), + "excerpt": Truncator(submission.body_md).chars(EXCERPT_CHARS), + "submitter_email": submission.submitted_by.email, + "admin_url": settings.BACKEND_URL + + reverse("admin:blog_postsubmission_change", args=[submission.pk]), + }, + to=staff, + ) diff --git a/backend/apps/blog/mdx.py b/backend/apps/blog/mdx.py new file mode 100644 index 0000000..492c099 --- /dev/null +++ b/backend/apps/blog/mdx.py @@ -0,0 +1,34 @@ +"""A guest post as a starting MDX file for `frontend/content/blog/`. + +Values are JSON-quoted, which YAML reads as plain strings, so a colon or a quote in +a title cannot break the frontmatter. `authors` is left for the editor: the blog +only accepts ids registered in `src/lib/blog/authors.ts`. +""" + +import json + +from django.utils import timezone +from django.utils.text import slugify + + +def filename(submission): + return f"{slugify(submission.title) or f'post-{submission.pk}'}.mdx" + + +def render(submission): + byline = ", ".join(part for part in (submission.author_name, submission.author_role) if part) + lines = [ + "---", + f"title: {json.dumps(submission.title)}", + f"description: {json.dumps(submission.description)}", + f"date: {json.dumps(timezone.localdate().isoformat())}", + f"tags: {json.dumps(submission.tags)}", + "draft: true", + f"# authors: register {json.dumps(byline)} in src/lib/blog/authors.ts, then add the id", + *(f"# author link: {link}" for link in submission.author_links), + "---", + "", + submission.body_md.strip(), + "", + ] + return "\n".join(lines) diff --git a/backend/apps/blog/migrations/0001_initial.py b/backend/apps/blog/migrations/0001_initial.py new file mode 100644 index 0000000..5f315a5 --- /dev/null +++ b/backend/apps/blog/migrations/0001_initial.py @@ -0,0 +1,43 @@ +# Generated by Django 6.1.1 on 2026-10-08 19:25 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='PostSubmission', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('title', models.CharField(max_length=200)), + ('description', models.CharField(help_text="The post's summary line.", max_length=300)), + ('tags', models.JSONField(blank=True, default=list)), + ('body_md', models.TextField(help_text='Raw markdown. Never rendered as HTML on the backend.')), + ('author_name', models.CharField(max_length=120)), + ('author_role', models.CharField(blank=True, max_length=120)), + ('author_bio', models.TextField(blank=True)), + ('author_links', models.JSONField(blank=True, default=list)), + ('source_ip', models.GenericIPAddressField(blank=True, null=True)), + ('user_agent', models.CharField(blank=True, max_length=512)), + ('status', models.CharField(choices=[('pending', 'Pending'), ('accepted', 'Accepted'), ('rejected', 'Rejected')], db_index=True, default='pending', max_length=16)), + ('reviewed_at', models.DateTimeField(blank=True, null=True)), + ('review_note', models.TextField(blank=True, help_text='Shown to the submitter.')), + ('reviewed_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), + ('submitted_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='post_submissions', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ('-created_at',), + }, + ), + ] diff --git a/backend/apps/blog/migrations/__init__.py b/backend/apps/blog/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/apps/blog/models.py b/backend/apps/blog/models.py new file mode 100644 index 0000000..9fbb8f1 --- /dev/null +++ b/backend/apps/blog/models.py @@ -0,0 +1,54 @@ +from django.conf import settings +from django.db import models + +from apps.core.models import TimeStampedModel + + +class PostSubmissionStatus(models.TextChoices): + PENDING = "pending", "Pending" + ACCEPTED = "accepted", "Accepted" + REJECTED = "rejected", "Rejected" + + +class PostSubmission(TimeStampedModel): + """A guest post awaiting review. Accepting one publishes nothing: posts are MDX + in the frontend, so an editor still turns it into a file and commits it.""" + + title = models.CharField(max_length=200) + description = models.CharField(max_length=300, help_text="The post's summary line.") + tags = models.JSONField(default=list, blank=True) + body_md = models.TextField(help_text="Raw markdown. Never rendered as HTML on the backend.") + + author_name = models.CharField(max_length=120) + author_role = models.CharField(max_length=120, blank=True) + author_bio = models.TextField(blank=True) + author_links = models.JSONField(default=list, blank=True) + + # Login is required, so identity is the account rather than a typed address. + submitted_by = models.ForeignKey( + settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="post_submissions" + ) + source_ip = models.GenericIPAddressField(null=True, blank=True) + user_agent = models.CharField(max_length=512, blank=True) + + status = models.CharField( + max_length=16, + choices=PostSubmissionStatus.choices, + default=PostSubmissionStatus.PENDING, + db_index=True, + ) + reviewed_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="+", + ) + reviewed_at = models.DateTimeField(null=True, blank=True) + review_note = models.TextField(blank=True, help_text="Shown to the submitter.") + + class Meta: + ordering = ("-created_at",) + + def __str__(self): + return self.title diff --git a/backend/apps/blog/schemas.py b/backend/apps/blog/schemas.py new file mode 100644 index 0000000..48e05fa --- /dev/null +++ b/backend/apps/blog/schemas.py @@ -0,0 +1,28 @@ +from datetime import datetime + +from ninja import Field, Schema + + +class PostSubmissionIn(Schema): + """Limits match the columns. The body's are settings, checked in `services`.""" + + title: str = Field(min_length=1, max_length=200) + description: str = Field(min_length=1, max_length=300) + body_md: str + author_name: str = Field(min_length=1, max_length=120) + tags: list[str] = [] + author_role: str = Field("", max_length=120) + author_bio: str = Field("", max_length=600) + author_links: list[str] = [] + + +class PostSubmissionOut(Schema): + id: int + title: str + description: str + tags: list[str] + author_name: str + status: str + review_note: str + created_at: datetime + reviewed_at: datetime | None diff --git a/backend/apps/blog/services.py b/backend/apps/blog/services.py new file mode 100644 index 0000000..111c5cd --- /dev/null +++ b/backend/apps/blog/services.py @@ -0,0 +1,91 @@ +"""What a reader may do with a guest post, as plain functions. + +Imports no ninja and takes `user` explicitly, like `apps.comments.services`, so the +route only translates. Refusals are `PostSubmissionError`: a 422 `detail`. +""" + +from django.conf import settings +from django.core.exceptions import ValidationError +from django.db import transaction +from django.utils import timezone + +from apps.blog import emails +from apps.blog.models import PostSubmission, PostSubmissionStatus +from apps.catalog.staff import StaffError +from apps.catalog.validators import validate_external_url + +MAX_LINKS = 3 +MAX_TAGS = 8 + + +class PostSubmissionError(StaffError): + """A submission refused for a reason the writer can act on.""" + + +def _clean_body(body): + body = body.strip() + if len(body) < settings.BLOG_SUBMISSION_MIN_LENGTH: + raise PostSubmissionError( + f"A post needs at least {settings.BLOG_SUBMISSION_MIN_LENGTH} characters." + ) + if len(body) > settings.BLOG_SUBMISSION_MAX_LENGTH: + raise PostSubmissionError( + f"A post can be at most {settings.BLOG_SUBMISSION_MAX_LENGTH} characters." + ) + return body + + +def _clean_tags(tags): + """Trimmed, with case-insensitive repeats dropped. The first spelling wins.""" + cleaned = {} + for tag in tags: + tag = tag.strip() + if tag: + cleaned.setdefault(tag.lower(), tag) + if len(cleaned) > MAX_TAGS: + raise PostSubmissionError(f"Give at most {MAX_TAGS} tags.") + return list(cleaned.values()) + + +def _clean_links(links): + links = [link.strip() for link in links if link.strip()] + if len(links) > MAX_LINKS: + raise PostSubmissionError(f"Give at most {MAX_LINKS} author links.") + for link in links: + try: + validate_external_url(link) + except ValidationError as exc: + raise PostSubmissionError(f"{link}: {exc.messages[0]}") from exc + return links + + +@transaction.atomic +def submit_post(*, user, data, source_ip=None, user_agent=""): + open_count = PostSubmission.objects.filter( + submitted_by=user, status=PostSubmissionStatus.PENDING + ).count() + if open_count >= settings.BLOG_SUBMISSION_MAX_OPEN: + raise PostSubmissionError( + f"You already have {open_count} posts awaiting review. " + "We will get to them before you send more." + ) + data = { + **data, + "body_md": _clean_body(data["body_md"]), + "tags": _clean_tags(data.get("tags", [])), + "author_links": _clean_links(data.get("author_links", [])), + } + submission = PostSubmission.objects.create( + **data, submitted_by=user, source_ip=source_ip, user_agent=user_agent[:512] + ) + emails.queue_staff_notice(submission) + return submission + + +def review(submission, *, user, status): + """Accept or reject. Either way nothing is published: an editor turns an accepted + post into MDX by hand.""" + submission.status = status + submission.reviewed_by = user + submission.reviewed_at = timezone.now() + submission.save(update_fields=["status", "reviewed_by", "reviewed_at", "updated_at"]) diff --git a/backend/apps/blog/throttles.py b/backend/apps/blog/throttles.py new file mode 100644 index 0000000..bc78623 --- /dev/null +++ b/backend/apps/blog/throttles.py @@ -0,0 +1,11 @@ +"""Per-account limit on guest posts. Keyed on the account, like the comment throttle.""" + +from django.conf import settings +from ninja.throttling import AuthRateThrottle + + +class PostSubmissionThrottle(AuthRateThrottle): + scope = "blog_submission" + + def __init__(self): + super().__init__(settings.BLOG_SUBMISSION_RATE) diff --git a/backend/apps/core/templates/email/post_submission_staff_notice/body.html b/backend/apps/core/templates/email/post_submission_staff_notice/body.html new file mode 100644 index 0000000..9c39dd7 --- /dev/null +++ b/backend/apps/core/templates/email/post_submission_staff_notice/body.html @@ -0,0 +1,224 @@ + + + + + New blog post submission: {{ title }} + + + + + + + + + + + + + + + + + +
{{ title }} is waiting for review.
+
+ +
+ + + + + + +
+ +
+ + + + + + +
+ + + + + + +
+ +
+
+
+ +
+
+ +
+ + + + + + +
+ +
+ + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + +
+
A blog post was submitted and is waiting for review.
+
+
Title: {{ title }}
+ Author: {{ author_name }}{% if author_role %}, {{ author_role }}{% endif %}
{% if tags %} Tags: {{ tags }}
{% endif %} Submitted by: {{ submitter_email }} +
+
+
Summary
+
+
{{ description|linebreaksbr }}
+
+
Opening
+
+
{{ excerpt|linebreaksbr }}
+
+ + + + + + +
+ Review in the admin +
+
+
+
+ +
+
+ +
+ + + + + + +
+ +
+ + + + + + +
+ + + + + + +
+
You received this because of an action on Redaction Tools.
+
+
+
+ +
+
+ +
+ + + \ No newline at end of file diff --git a/backend/apps/core/templates/email/post_submission_staff_notice/body.mjml b/backend/apps/core/templates/email/post_submission_staff_notice/body.mjml new file mode 100644 index 0000000..4f1af54 --- /dev/null +++ b/backend/apps/core/templates/email/post_submission_staff_notice/body.mjml @@ -0,0 +1,27 @@ + + + New blog post submission: {{ title }} + {{ title }} is waiting for review. + + + + + + + A blog post was submitted and is waiting for review. + + Title: {{ title }}
+ Author: {{ author_name }}{% if author_role %}, {{ author_role }}{% endif %}
{% if tags %} + Tags: {{ tags }}
{% endif %} + Submitted by: {{ submitter_email }} +
+ Summary + {{ description|linebreaksbr }} + Opening + {{ excerpt|linebreaksbr }} + Review in the admin +
+
+ +
+
diff --git a/backend/apps/core/templates/email/post_submission_staff_notice/body.txt b/backend/apps/core/templates/email/post_submission_staff_notice/body.txt new file mode 100644 index 0000000..95dc47b --- /dev/null +++ b/backend/apps/core/templates/email/post_submission_staff_notice/body.txt @@ -0,0 +1,15 @@ +{% autoescape off %}A blog post was submitted and is waiting for review. + +Title: {{ title }} +Author: {{ author_name }}{% if author_role %}, {{ author_role }}{% endif %}{% if tags %} +Tags: {{ tags }}{% endif %} +Submitted by: {{ submitter_email }} + +Summary: +{{ description }} + +Opening: +{{ excerpt }} + +Review it: {{ admin_url }} +{% endautoescape %} diff --git a/backend/apps/core/templates/email/post_submission_staff_notice/preview.json b/backend/apps/core/templates/email/post_submission_staff_notice/preview.json new file mode 100644 index 0000000..1321c85 --- /dev/null +++ b/backend/apps/core/templates/email/post_submission_staff_notice/preview.json @@ -0,0 +1,10 @@ +{ + "title": "Redacting scanned PDFs without missing the OCR layer", + "description": "What a black box over a scan leaves behind, and how to check for it.", + "author_name": "Ada Writer", + "author_role": "Records officer", + "tags": "OCR, Guides", + "excerpt": "A scanned PDF usually carries two copies of every word: the picture and an invisible OCR layer.\n\nDrawing a box over the picture leaves the second one untouched...", + "submitter_email": "ada@example.com", + "admin_url": "http://localhost:8007/admin/blog/postsubmission/1/change/" +} diff --git a/backend/apps/core/templates/email/post_submission_staff_notice/subject.txt b/backend/apps/core/templates/email/post_submission_staff_notice/subject.txt new file mode 100644 index 0000000..39ad795 --- /dev/null +++ b/backend/apps/core/templates/email/post_submission_staff_notice/subject.txt @@ -0,0 +1 @@ +{% autoescape off %}New blog post submission: {{ title }}{% endautoescape %} diff --git a/backend/config/api.py b/backend/config/api.py index fe0ed11..b55b089 100644 --- a/backend/config/api.py +++ b/backend/config/api.py @@ -10,6 +10,7 @@ from apps.accounts.api import router as accounts_router from apps.benchmarks.api import router as benchmarks_router +from apps.blog.api import router as blog_router from apps.catalog.api import router as catalog_router from apps.catalog.staff import StaffError from apps.catalog.staff_api import router as catalog_staff_router @@ -52,6 +53,7 @@ def get_openapi_operation_id(self, operation: Operation) -> str: api.add_router("/comments/staff/", comments_staff_router) api.add_router("/comments/", comments_router) api.add_router("/newsletter/", newsletter_router) +api.add_router("/blog/", blog_router) @api.exception_handler(StaffError) diff --git a/backend/config/settings/base.py b/backend/config/settings/base.py index 711a474..3909c44 100644 --- a/backend/config/settings/base.py +++ b/backend/config/settings/base.py @@ -77,6 +77,7 @@ "apps.benchmarks", "apps.comments", "apps.newsletter", + "apps.blog", ] MIDDLEWARE = [ @@ -313,6 +314,14 @@ # Per IP. Subscribing needs no account and mails whatever address it is given. NEWSLETTER_SUBSCRIBE_RATE = env("NEWSLETTER_SUBSCRIBE_RATE", default="10/hour") +# --- Blog submissions ------------------------------------------------------ +# Guest posts, per account: how often one may send, and how many may wait for review. +# A post shorter than the minimum is a note, not a draft. +BLOG_SUBMISSION_RATE = env("BLOG_SUBMISSION_RATE", default="5/day") +BLOG_SUBMISSION_MIN_LENGTH = env.int("BLOG_SUBMISSION_MIN_LENGTH", default=300) +BLOG_SUBMISSION_MAX_LENGTH = env.int("BLOG_SUBMISSION_MAX_LENGTH", default=60000) +BLOG_SUBMISSION_MAX_OPEN = env.int("BLOG_SUBMISSION_MAX_OPEN", default=3) + # --- Benchmarks ------------------------------------------------------------ # A redacted one-page case is a few hundred KB; a tool that rasterises every page # at print resolution can reach a few MB. Well above that is not a redaction. @@ -324,6 +333,8 @@ BENCHMARK_MAX_SCREENSHOTS_PER_RUN = env.int("BENCHMARK_MAX_SCREENSHOTS_PER_RUN", default=10) # How long one run may take to score in the worker. OCR at 300 dpi is most of it. BENCHMARK_SCORE_TIMEOUT = env.int("BENCHMARK_SCORE_TIMEOUT", default=300) +BLOG_SUBMISSION_MAX_LENGTH = env.int("BLOG_SUBMISSION_MAX_LENGTH", default=60000) +BLOG_SUBMISSION_MAX_OPEN = env.int("BLOG_SUBMISSION_MAX_OPEN", default=3) # Off only where tesseract is absent and the suite has to run anyway; a score # computed without OCR says so in its report notes. BENCHMARK_OCR = env.bool("BENCHMARK_OCR", default=True) diff --git a/backend/openapi.json b/backend/openapi.json index 9c5fa00..a3f7dc8 100644 --- a/backend/openapi.json +++ b/backend/openapi.json @@ -3650,6 +3650,72 @@ "required": true } } + }, + "/api/v1/blog/submissions": { + "post": { + "operationId": "create_post_submission", + "summary": "Submit a blog post for review", + "parameters": [], + "responses": { + "201": { + "description": "Created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PostSubmissionOut" + } + } + } + } + }, + "tags": [ + "blog" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PostSubmissionIn" + } + } + }, + "required": true + }, + "security": [ + { + "JWTAuth": [] + } + ] + }, + "get": { + "operationId": "list_my_post_submissions", + "summary": "Blog posts you have submitted", + "parameters": [], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/PostSubmissionOut" + }, + "title": "Response", + "type": "array" + } + } + } + } + }, + "tags": [ + "blog" + ], + "security": [ + { + "JWTAuth": [] + } + ] + } } }, "components": { @@ -8678,6 +8744,134 @@ ], "title": "TokenIn", "type": "object" + }, + "PostSubmissionOut": { + "properties": { + "id": { + "title": "Id", + "type": "integer" + }, + "title": { + "title": "Title", + "type": "string" + }, + "description": { + "title": "Description", + "type": "string" + }, + "tags": { + "items": { + "type": "string" + }, + "title": "Tags", + "type": "array" + }, + "author_name": { + "title": "Author Name", + "type": "string" + }, + "status": { + "title": "Status", + "type": "string" + }, + "review_note": { + "title": "Review Note", + "type": "string" + }, + "created_at": { + "format": "date-time", + "title": "Created At", + "type": "string" + }, + "reviewed_at": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Reviewed At" + } + }, + "required": [ + "id", + "title", + "description", + "tags", + "author_name", + "status", + "review_note", + "created_at", + "reviewed_at" + ], + "title": "PostSubmissionOut", + "type": "object" + }, + "PostSubmissionIn": { + "description": "Limits match the columns. The body's are settings, checked in `services`.", + "properties": { + "title": { + "maxLength": 200, + "minLength": 1, + "title": "Title", + "type": "string" + }, + "description": { + "maxLength": 300, + "minLength": 1, + "title": "Description", + "type": "string" + }, + "body_md": { + "title": "Body Md", + "type": "string" + }, + "author_name": { + "maxLength": 120, + "minLength": 1, + "title": "Author Name", + "type": "string" + }, + "tags": { + "default": [], + "items": { + "type": "string" + }, + "title": "Tags", + "type": "array" + }, + "author_role": { + "default": "", + "maxLength": 120, + "title": "Author Role", + "type": "string" + }, + "author_bio": { + "default": "", + "maxLength": 600, + "title": "Author Bio", + "type": "string" + }, + "author_links": { + "default": [], + "items": { + "type": "string" + }, + "title": "Author Links", + "type": "array" + } + }, + "required": [ + "title", + "description", + "body_md", + "author_name" + ], + "title": "PostSubmissionIn", + "type": "object" } }, "securitySchemes": { diff --git a/backend/tests/test_blog_admin.py b/backend/tests/test_blog_admin.py new file mode 100644 index 0000000..a52236c --- /dev/null +++ b/backend/tests/test_blog_admin.py @@ -0,0 +1,66 @@ +"""Staff decide on guest posts in the admin. Accepting publishes nothing - posts are +MDX in the frontend - so the admin also hands the editor a file to start from.""" + +import pytest + +from apps.blog.models import PostSubmission, PostSubmissionStatus + +pytestmark = pytest.mark.django_db + +CHANGELIST = "/admin/blog/postsubmission/" + + +@pytest.fixture +def submission(user): + return PostSubmission.objects.create( + title="Redacting scanned PDFs", + description="What OCR misses and how to check.", + tags=["OCR", "Guides"], + body_md="## The OCR layer\n\nA scan carries two copies of every word.", + author_name="Ada Writer", + submitted_by=user, + ) + + +def _act(admin_client, action, submission): + return admin_client.post( + CHANGELIST, {"action": action, "_selected_action": [str(submission.pk)]}, follow=True + ) + + +def test_accepting_records_who_decided_and_when(admin_client, staff_user, submission): + _act(admin_client, "accept_submissions", submission) + + submission.refresh_from_db() + assert submission.status == PostSubmissionStatus.ACCEPTED + assert submission.reviewed_by == staff_user + assert submission.reviewed_at is not None + + +def test_rejecting_keeps_the_row_so_the_writer_sees_the_outcome(admin_client, submission): + _act(admin_client, "reject_submissions", submission) + + submission.refresh_from_db() + assert submission.status == PostSubmissionStatus.REJECTED + + +def test_the_mdx_export_carries_frontmatter_the_blog_accepts(admin_client, submission): + response = admin_client.get(f"{CHANGELIST}{submission.pk}/mdx/") + + assert response.status_code == 200 + assert 'filename="redacting-scanned-pdfs.mdx"' in response["Content-Disposition"] + text = response.content.decode() + assert text.startswith('---\ntitle: "Redacting scanned PDFs"\n') + assert 'description: "What OCR misses and how to check."\n' in text + assert 'tags: ["OCR", "Guides"]\n' in text + assert "draft: true\n" in text + assert text.endswith("---\n\n## The OCR layer\n\nA scan carries two copies of every word.\n") + + +def test_the_change_page_shows_the_body_and_links_the_export(admin_client, submission): + response = admin_client.get(f"{CHANGELIST}{submission.pk}/change/") + + assert response.status_code == 200 + page = response.content.decode() + assert "A scan carries two copies of every word." in page + assert f"{CHANGELIST}{submission.pk}/mdx/" in page diff --git a/backend/tests/test_blog_api.py b/backend/tests/test_blog_api.py new file mode 100644 index 0000000..baaaddd --- /dev/null +++ b/backend/tests/test_blog_api.py @@ -0,0 +1,120 @@ +"""The post-submission HTTP surface: who may send a guest post, and what comes back. + +Blog posts are MDX in the frontend, so a submission is a queue row for staff, +never a page. The rules live in `apps.blog.services`; this proves the wiring. +""" + +import pytest +from django.core.cache import cache + +pytestmark = pytest.mark.django_db + +BASE = "/api/v1/blog/submissions" +BODY = ("A walkthrough of redacting scanned PDFs. " * 20).strip() + + +@pytest.fixture(autouse=True) +def clear_throttle_cache(): + """Throttle counters live in the cache and would leak between tests.""" + cache.clear() + yield + cache.clear() + + +def _payload(**overrides): + return { + "title": "Redacting scanned PDFs", + "description": "What OCR misses and how to check.", + "tags": ["OCR", "Guides"], + "body_md": BODY, + "author_name": "Ada Writer", + } | overrides + + +def _post(client, auth=None, **overrides): + return client.post(BASE, _payload(**overrides), content_type="application/json", **(auth or {})) + + +def test_submitting_needs_a_signed_in_account(client): + assert _post(client).status_code == 401 + + +def test_a_signed_in_submission_is_queued_as_pending(client, user, bearer): + from apps.blog.models import PostSubmission + + response = _post(client, bearer) + + assert response.status_code == 201 + assert response.json()["status"] == "pending" + submission = PostSubmission.objects.get() + assert (submission.submitted_by, submission.title, submission.body_md) == ( + user, + "Redacting scanned PDFs", + BODY, + ) + + +def test_a_post_too_short_to_review_is_refused(client, bearer): + response = _post(client, bearer, body_md="Too short.") + + assert response.status_code == 422 + assert "at least" in response.json()["detail"] + + +def test_a_post_over_the_length_cap_is_refused(client, bearer, settings): + settings.BLOG_SUBMISSION_MAX_LENGTH = len(BODY) - 1 + + response = _post(client, bearer) + + assert response.status_code == 422 + assert "at most" in response.json()["detail"] + + +def test_author_links_must_be_public_web_addresses(client, bearer): + response = _post(client, bearer, author_links=["javascript:alert(1)"]) + + assert response.status_code == 422 + assert "http" in response.json()["detail"] + + +def test_an_account_can_leave_only_so_many_posts_awaiting_review(client, bearer, settings): + settings.BLOG_SUBMISSION_MAX_OPEN = 1 + assert _post(client, bearer).status_code == 201 + + response = _post(client, bearer, title="A second post") + + assert response.status_code == 422 + assert "awaiting review" in response.json()["detail"] + + +def test_submitting_is_rate_limited_per_account(client, bearer, settings): + """At the default 5/day: the throttle reads its rate once, at import.""" + settings.BLOG_SUBMISSION_MAX_OPEN = 100 + for _ in range(5): + assert _post(client, bearer).status_code == 201 + + assert _post(client, bearer).status_code == 429 + + +def test_an_account_lists_only_its_own_submissions(client, bearer, staff_user): + from apps.blog.models import PostSubmission + + PostSubmission.objects.create( + title="Someone else's", + description="-", + body_md="-", + author_name="-", + submitted_by=staff_user, + ) + _post(client, bearer) + + response = client.get(BASE, **bearer) + + assert response.status_code == 200 + assert [row["title"] for row in response.json()] == ["Redacting scanned PDFs"] + + +def test_an_overlong_title_is_a_validation_error_not_a_crash(client, bearer): + response = _post(client, bearer, title="T" * 201) + + assert response.status_code == 422 diff --git a/backend/tests/test_blog_emails.py b/backend/tests/test_blog_emails.py new file mode 100644 index 0000000..2b7b4c4 --- /dev/null +++ b/backend/tests/test_blog_emails.py @@ -0,0 +1,45 @@ +"""Staff hear about every guest post, once it has committed.""" + +import pytest +from django.core import mail + +from apps.blog import services + +pytestmark = pytest.mark.django_db + +BODY = ("A walkthrough of redacting scanned PDFs. " * 20).strip() + + +def _submit(user): + return services.submit_post( + user=user, + data={ + "title": "Redacting scanned PDFs", + "description": "What OCR misses and how to check.", + "body_md": BODY, + "author_name": "Ada Writer", + }, + ) + + +def test_staff_are_told_about_a_new_post( + user, staff_user, settings, django_capture_on_commit_callbacks +): + with django_capture_on_commit_callbacks(execute=True): + submission = _submit(user) + + (notice,) = [m for m in mail.outbox if staff_user.email in m.to] + assert "Redacting scanned PDFs" in notice.subject + assert f"{settings.BACKEND_URL}/admin/blog/postsubmission/{submission.pk}/change/" in ( + notice.body + ) + assert user.email in notice.body + + +def test_nothing_is_sent_before_the_submission_commits( + user, staff_user, django_capture_on_commit_callbacks +): + with django_capture_on_commit_callbacks(execute=False): + _submit(user) + + assert mail.outbox == [] diff --git a/backend/tests/test_blog_services.py b/backend/tests/test_blog_services.py new file mode 100644 index 0000000..54cadf5 --- /dev/null +++ b/backend/tests/test_blog_services.py @@ -0,0 +1,35 @@ +"""The rules on a guest post, as functions: what is cleaned, and what is refused.""" + +import pytest + +from apps.blog import services + +pytestmark = pytest.mark.django_db + +BODY = ("A walkthrough of redacting scanned PDFs. " * 20).strip() + + +def _submit(user, **overrides): + data = { + "title": "Redacting scanned PDFs", + "description": "What OCR misses and how to check.", + "body_md": BODY, + "author_name": "Ada Writer", + } | overrides + return services.submit_post(user=user, data=data) + + +def test_tags_are_trimmed_and_deduplicated(user): + submission = _submit(user, tags=[" OCR ", "ocr", "", "Guides"]) + + assert submission.tags == ["OCR", "Guides"] + + +def test_more_than_eight_tags_is_refused(user): + with pytest.raises(services.PostSubmissionError, match="at most 8 tags"): + _submit(user, tags=[f"tag-{n}" for n in range(9)]) + + +def test_more_than_three_author_links_is_refused(user): + with pytest.raises(services.PostSubmissionError, match="at most 3 author links"): + _submit(user, author_links=[f"https://example.com/{n}" for n in range(4)]) diff --git a/frontend/bun.lock b/frontend/bun.lock index 1962329..1819eeb 100644 --- a/frontend/bun.lock +++ b/frontend/bun.lock @@ -8,6 +8,7 @@ "@tanstack/react-query": "5.103.1", "@tanstack/react-query-devtools": "5.103.1", "@tanstack/react-table": "9.2.4", + "@uiw/react-md-editor": "^4.1.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "fumadocs-core": "16.15.13", @@ -734,6 +735,8 @@ "@types/node": ["@types/node@24.13.5", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-TXyindR+lBr22aJIdMQzCFHPHR6cR4js838mRDCSz5hOKWZvZwsXSSiXDmjRj4iJmgl+sR9O+1mkoVBSMadNug=="], + "@types/prismjs": ["@types/prismjs@1.26.6", "", {}, "sha512-vqlvI7qlMvcCBbVe0AKAb4f97//Hy0EBTaiW8AalRnG/xAN5zOiWWyrNqNXeq8+KAuvRewjCVY1+IPxk4RdNYw=="], + "@types/react": ["@types/react@19.3.0", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg=="], "@types/react-dom": ["@types/react-dom@19.3.0", "", { "peerDependencies": { "@types/react": "^19.3.0" } }, "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q=="], @@ -764,6 +767,12 @@ "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.70.0", "", { "dependencies": { "@typescript-eslint/types": "8.70.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-BoC8PiO4Hkdo0TVJh9Ntxr5MxPDI7/oFsrygN5ADelFSeXG/qgNuucIGA+L5Z6JpPTE/uRfcTWtscjbUaufepQ=="], + "@uiw/copy-to-clipboard": ["@uiw/copy-to-clipboard@1.0.21", "", {}, "sha512-apdzZJyJC/IEj21N22ry1H022pgpSA+FwNKxmvOGQ9rUMdyRbHf1nZq2UwqnfGOuTr7iOKLzNgWsCJtAwyAZpw=="], + + "@uiw/react-markdown-preview": ["@uiw/react-markdown-preview@5.2.1", "", { "dependencies": { "@babel/runtime": "^7.17.2", "@uiw/copy-to-clipboard": "~1.0.12", "react-markdown": "~10.1.0", "rehype-attr": "~4.0.0", "rehype-autolink-headings": "~7.1.0", "rehype-ignore": "^2.0.0", "rehype-prism-plus": "~2.0.0", "rehype-raw": "^7.0.0", "rehype-rewrite": "~4.0.0", "rehype-slug": "~6.0.0", "remark-gfm": "~4.0.0", "remark-github-blockquote-alert": "^1.0.0", "unist-util-visit": "^5.0.0" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-JjvcHveT6glhlJYJx1XGBZij6wkw+VwREV6Z6m/GpsjPPdLjF1x8nlPBSB/ATyUF4lD7C8ttMkCqVH9N9XMgEA=="], + + "@uiw/react-md-editor": ["@uiw/react-md-editor@4.1.2", "", { "dependencies": { "@babel/runtime": "^7.14.6", "@uiw/react-markdown-preview": "^5.2.0", "rehype": "~13.0.0", "rehype-prism-plus": "~2.0.0" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-EA19S+GS1KDi9MvKoCjJ16luGBE2yZLaBp+6tkNJvIYQ+J6zxdkTAHWl0i+Te02HwQrVMIjVG74M54uyW8K7wQ=="], + "@ungap/structured-clone": ["@ungap/structured-clone@1.4.0", "", {}, "sha512-1mEZtMKPM09vDmQt5y7YvmN2+DFTP7Tg0EWXdic8/C6VRnpb33e4ghisCIE3WZjsE2N8mf+QV1Zqh7ZFYLWInQ=="], "@unrs/resolver-binding-android-arm-eabi": ["@unrs/resolver-binding-android-arm-eabi@1.12.2", "", { "os": "android", "cpu": "arm" }, "sha512-g5T90pqg1bo/7mytQx6F4iBNC0Wsh9cu+z9veDbFjc7HjpesJFWD7QMS0NGStXM075+7dJPPVvBbpZlnrdpi/w=="], @@ -912,6 +921,8 @@ "baseline-browser-mapping": ["baseline-browser-mapping@2.11.24", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-hYrgxie335U08WqICoGqKRzV1HFXv6zdxwJE4ekCb80CM9a0SVVsN4QPwT67RraRo+9h8IATk6uxHJw7QSkdOg=="], + "bcp-47-match": ["bcp-47-match@2.0.3", "", {}, "sha512-JtTezzbAibu8G0R9op9zb3vcWZd9JF6M0xOYGPn0fNCd7wOpRB1mU2mH9T8gaBGbAAyIIVgB2G7xG0GP98zMAQ=="], + "bidi-js": ["bidi-js@1.1.0", "", { "dependencies": { "require-from-string": "^2.0.2" } }, "sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg=="], "body-parser": ["body-parser@2.3.0", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^2.0.0", "debug": "^4.4.3", "http-errors": "^2.0.1", "iconv-lite": "^0.7.2", "on-finished": "^2.4.1", "qs": "^6.15.2", "raw-body": "^3.0.2", "type-is": "^2.1.0" } }, "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw=="], @@ -1010,6 +1021,8 @@ "css-select": ["css-select@5.2.2", "", { "dependencies": { "boolbase": "^1.0.0", "css-what": "^6.1.0", "domhandler": "^5.0.2", "domutils": "^3.0.1", "nth-check": "^2.0.1" } }, "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw=="], + "css-selector-parser": ["css-selector-parser@3.3.0", "", {}, "sha512-Y2asgMGFqJKF4fq4xHDSlFYIkeVfRsm69lQC1q9kbEsH5XtnINTMrweLkjYMeaUgiXBy/uvKeO/a1JHTNnmB2g=="], + "css-tree": ["css-tree@3.2.1", "", { "dependencies": { "mdn-data": "2.27.1", "source-map-js": "^1.2.1" } }, "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA=="], "css-what": ["css-what@6.2.2", "", {}, "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA=="], @@ -1064,6 +1077,8 @@ "devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="], + "direction": ["direction@2.0.1", "", { "bin": { "direction": "cli.js" } }, "sha512-9S6m9Sukh1cZNknO1CWAr2QAWsbKLafQiyM5gZ7VgXHeuaoUwffKN4q6NC4A/Mf9iiPlOXQEKW/Mv/mh9/3YFA=="], + "doctrine": ["doctrine@2.1.0", "", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw=="], "dom-accessibility-api": ["dom-accessibility-api@0.6.3", "", {}, "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w=="], @@ -1302,12 +1317,22 @@ "hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="], + "hast-util-from-html": ["hast-util-from-html@2.0.3", "", { "dependencies": { "@types/hast": "^3.0.0", "devlop": "^1.1.0", "hast-util-from-parse5": "^8.0.0", "parse5": "^7.0.0", "vfile": "^6.0.0", "vfile-message": "^4.0.0" } }, "sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw=="], + "hast-util-from-parse5": ["hast-util-from-parse5@8.0.3", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "devlop": "^1.0.0", "hastscript": "^9.0.0", "property-information": "^7.0.0", "vfile": "^6.0.0", "vfile-location": "^5.0.0", "web-namespaces": "^2.0.0" } }, "sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg=="], + "hast-util-has-property": ["hast-util-has-property@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-MNilsvEKLFpV604hwfhVStK0usFY/QmM5zX16bo7EjnAEGofr5YyI37kzopBlZJkHD4t887i+q/C8/tr5Q94cA=="], + + "hast-util-heading-rank": ["hast-util-heading-rank@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-EJKb8oMUXVHcWZTDepnr+WNbfnXKFNf9duMesmr4S8SXTJBJ9M4Yok08pu9vxdJwdlGRhVumk9mEhkEvKGifwA=="], + + "hast-util-is-element": ["hast-util-is-element@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g=="], + "hast-util-parse-selector": ["hast-util-parse-selector@4.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A=="], "hast-util-raw": ["hast-util-raw@9.1.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "@ungap/structured-clone": "^1.0.0", "hast-util-from-parse5": "^8.0.0", "hast-util-to-parse5": "^8.0.0", "html-void-elements": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "parse5": "^7.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0", "web-namespaces": "^2.0.0", "zwitch": "^2.0.0" } }, "sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw=="], + "hast-util-select": ["hast-util-select@6.0.4", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "bcp-47-match": "^2.0.0", "comma-separated-tokens": "^2.0.0", "css-selector-parser": "^3.0.0", "devlop": "^1.0.0", "direction": "^2.0.0", "hast-util-has-property": "^3.0.0", "hast-util-to-string": "^3.0.0", "hast-util-whitespace": "^3.0.0", "nth-check": "^2.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "unist-util-visit": "^5.0.0", "zwitch": "^2.0.0" } }, "sha512-RqGS1ZgI0MwxLaKLDxjprynNzINEkRHY2i8ln4DDjgv9ZhcYVIHN9rlpiYsqtFwrgpYU361SyWDQcGNIBVu3lw=="], + "hast-util-to-estree": ["hast-util-to-estree@3.1.3", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "devlop": "^1.0.0", "estree-util-attach-comments": "^3.0.0", "estree-util-is-identifier-name": "^3.0.0", "hast-util-whitespace": "^3.0.0", "mdast-util-mdx-expression": "^2.0.0", "mdast-util-mdx-jsx": "^3.0.0", "mdast-util-mdxjs-esm": "^2.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "style-to-js": "^1.0.0", "unist-util-position": "^5.0.0", "zwitch": "^2.0.0" } }, "sha512-48+B/rJWAp0jamNbAAf9M7Uf//UVqAoMmgXhBdxTDJLGKY+LRnZ99qcG+Qjl5HfMpYNzS5v4EAwVEF34LeAj7w=="], "hast-util-to-html": ["hast-util-to-html@9.0.5", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-whitespace": "^3.0.0", "html-void-elements": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "stringify-entities": "^4.0.0", "zwitch": "^2.0.4" } }, "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw=="], @@ -1316,6 +1341,8 @@ "hast-util-to-parse5": ["hast-util-to-parse5@8.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "devlop": "^1.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "web-namespaces": "^2.0.0", "zwitch": "^2.0.0" } }, "sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA=="], + "hast-util-to-string": ["hast-util-to-string@3.0.1", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-XelQVTDWvqcl3axRfI0xSeoVKzyIFPwsAGSLIsKdJKQMXDYJS4WYrBNF/8J7RdhIcFI2BOHgAifggsvsxp/3+A=="], + "hast-util-whitespace": ["hast-util-whitespace@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw=="], "hastscript": ["hastscript@9.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-parse-selector": "^4.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0" } }, "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w=="], @@ -1830,6 +1857,8 @@ "parse-ms": ["parse-ms@4.0.0", "", {}, "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw=="], + "parse-numeric-range": ["parse-numeric-range@1.3.0", "", {}, "sha512-twN+njEipszzlMJd4ONUYgSfZPDxgHhT9Ahed5uTigpQn90FggW4SA/AIPq/6a149fTbE9qBEcSwE3FAEp6wQQ=="], + "parse5": ["parse5@8.0.1", "", { "dependencies": { "entities": "^8.0.0" } }, "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw=="], "parse5-htmlparser2-tree-adapter": ["parse5-htmlparser2-tree-adapter@7.1.0", "", { "dependencies": { "domhandler": "^5.0.3", "parse5": "^7.0.0" } }, "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g=="], @@ -1994,6 +2023,8 @@ "reflect.getprototypeof": ["reflect.getprototypeof@1.0.10", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.7", "get-proto": "^1.0.1", "which-builtin-type": "^1.2.1" } }, "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw=="], + "refractor": ["refractor@5.0.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/prismjs": "^1.0.0", "hastscript": "^9.0.0", "parse-entities": "^4.0.0" } }, "sha512-QXOrHQF5jOpjjLfiNk5GFnWhRXvxjUVnlFxkeDmewR5sXkr3iM46Zo+CnRR8B+MDVqkULW4EcLVcRBNOPXHosw=="], + "regex": ["regex@6.1.0", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg=="], "regex-recursion": ["regex-recursion@6.0.2", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg=="], @@ -2002,14 +2033,34 @@ "regexp.prototype.flags": ["regexp.prototype.flags@1.5.4", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-errors": "^1.3.0", "get-proto": "^1.0.1", "gopd": "^1.2.0", "set-function-name": "^2.0.2" } }, "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA=="], + "rehype": ["rehype@13.0.2", "", { "dependencies": { "@types/hast": "^3.0.0", "rehype-parse": "^9.0.0", "rehype-stringify": "^10.0.0", "unified": "^11.0.0" } }, "sha512-j31mdaRFrwFRUIlxGeuPXXKWQxet52RBQRvCmzl5eCefn/KGbomK5GMHNMsOJf55fgo3qw5tST5neDuarDYR2A=="], + + "rehype-attr": ["rehype-attr@4.0.2", "", { "dependencies": { "unified": "~11.0.0", "unist-util-visit": "~5.0.0" } }, "sha512-v4+gw7pvUVLbG/dUpLgBE6r3TWTBYJ7z+sfAH3zapmM5CKzk5+CopFQgr4gMR6OBSKl/qpI6HR7gv1Cbig0uow=="], + + "rehype-autolink-headings": ["rehype-autolink-headings@7.1.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@ungap/structured-clone": "^1.0.0", "hast-util-heading-rank": "^3.0.0", "hast-util-is-element": "^3.0.0", "unified": "^11.0.0", "unist-util-visit": "^5.0.0" } }, "sha512-rItO/pSdvnvsP4QRB1pmPiNHUskikqtPojZKJPPPAVx9Hj8i8TwMBhofrrAYRhYOOBZH9tgmG5lPqDLuIWPWmw=="], + + "rehype-ignore": ["rehype-ignore@2.0.3", "", { "dependencies": { "hast-util-select": "^6.0.0", "unified": "^11.0.0", "unist-util-visit": "^5.0.0" } }, "sha512-IzhP6/u/6sm49sdktuYSmeIuObWB+5yC/5eqVws8BhuGA9kY25/byz6uCy/Ravj6lXUShEd2ofHM5MyAIj86Sg=="], + + "rehype-parse": ["rehype-parse@9.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "hast-util-from-html": "^2.0.0", "unified": "^11.0.0" } }, "sha512-ksCzCD0Fgfh7trPDxr2rSylbwq9iYDkSn8TCDmEJ49ljEUBxDVCzCHv7QNzZOfODanX4+bWQ4WZqLCRWYLfhag=="], + + "rehype-prism-plus": ["rehype-prism-plus@2.0.2", "", { "dependencies": { "hast-util-to-string": "^3.0.1", "parse-numeric-range": "^1.3.0", "refractor": "^5.0.0", "rehype-parse": "^9.0.1", "unist-util-filter": "^5.0.1", "unist-util-visit": "^5.1.0" } }, "sha512-jTHb8ZtQHd2VWAAKeCINgv/8zNEF0+LesmwJak69GemoPVN9/8fGEARTvqOpKqmN57HwaM9z8UKBVNVJe8zggw=="], + "rehype-raw": ["rehype-raw@7.0.0", "", { "dependencies": { "@types/hast": "^3.0.0", "hast-util-raw": "^9.0.0", "vfile": "^6.0.0" } }, "sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww=="], "rehype-recma": ["rehype-recma@1.0.0", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/hast": "^3.0.0", "hast-util-to-estree": "^3.0.0" } }, "sha512-lqA4rGUf1JmacCNWWZx0Wv1dHqMwxzsDWYMTowuplHF3xH0N/MmrZ/G3BDZnzAkRmxDadujCjaKM2hqYdCBOGw=="], + "rehype-rewrite": ["rehype-rewrite@4.0.4", "", { "dependencies": { "hast-util-select": "^6.0.0", "unified": "^11.0.3", "unist-util-visit": "^5.0.0" } }, "sha512-L/FO96EOzSA6bzOam4DVu61/PB3AGKcSPXpa53yMIozoxH4qg1+bVZDF8zh1EsuxtSauAhzt5cCnvoplAaSLrw=="], + + "rehype-slug": ["rehype-slug@6.0.0", "", { "dependencies": { "@types/hast": "^3.0.0", "github-slugger": "^2.0.0", "hast-util-heading-rank": "^3.0.0", "hast-util-to-string": "^3.0.0", "unist-util-visit": "^5.0.0" } }, "sha512-lWyvf/jwu+oS5+hL5eClVd3hNdmwM1kAC0BUvEGD19pajQMIzcNUd/k9GsfQ+FfECvX+JE+e9/btsKH0EjJT6A=="], + + "rehype-stringify": ["rehype-stringify@10.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "hast-util-to-html": "^9.0.0", "unified": "^11.0.0" } }, "sha512-k9ecfXHmIPuFVI61B9DeLPN0qFHfawM6RsuX48hoqlaKSF61RskNjSm1lI8PhBEM0MRdLxVVm4WmTqJQccH9mA=="], + "remark": ["remark@15.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "unified": "^11.0.0" } }, "sha512-Eht5w30ruCXgFmxVUSlNWQ9iiimq07URKeFS3hNc8cUWy1llX4KDWfyEDZRycMc+znsN9Ux5/tJ/BFdgdOwA3A=="], "remark-gfm": ["remark-gfm@4.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-gfm": "^3.0.0", "micromark-extension-gfm": "^3.0.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "unified": "^11.0.0" } }, "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg=="], + "remark-github-blockquote-alert": ["remark-github-blockquote-alert@1.3.1", "", { "dependencies": { "unist-util-visit": "^5.0.0" } }, "sha512-OPNnimcKeozWN1w8KVQEuHOxgN3L4rah8geMOLhA5vN9wITqU4FWD+G26tkEsCGHiOVDbISx+Se5rGZ+D1p0Jg=="], + "remark-mdx": ["remark-mdx@3.1.1", "", { "dependencies": { "mdast-util-mdx": "^3.0.0", "micromark-extension-mdxjs": "^3.0.0" } }, "sha512-Pjj2IYlUY3+D8x00UJsIOg5BEvfMyeI+2uLPn9VO9Wg4MEtN/VTIq2NEJQfde9PnX15KgtHyl9S0BcTnWrIuWg=="], "remark-parse": ["remark-parse@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "micromark-util-types": "^2.0.0", "unified": "^11.0.0" } }, "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA=="], @@ -2242,6 +2293,8 @@ "unified": ["unified@11.0.5", "", { "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", "devlop": "^1.0.0", "extend": "^3.0.0", "is-plain-obj": "^4.0.0", "trough": "^2.0.0", "vfile": "^6.0.0" } }, "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA=="], + "unist-util-filter": ["unist-util-filter@5.0.1", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-pHx7D4Zt6+TsfwylH9+lYhBhzyhEnCXs/lbq/Hstxno5z4gVdyc2WEW0asfjGKPyG4pEKrnBv5hdkO6+aRnQJw=="], + "unist-util-is": ["unist-util-is@6.0.1", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g=="], "unist-util-position": ["unist-util-position@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA=="], @@ -2476,6 +2529,8 @@ "glob/minimatch": ["minimatch@10.2.6", "", { "dependencies": { "brace-expansion": "^5.0.8" } }, "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A=="], + "hast-util-from-html/parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="], + "hast-util-raw/parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="], "htmlnano/commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="], @@ -2524,6 +2579,8 @@ "raw-body/iconv-lite": ["iconv-lite@0.7.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ=="], + "rehype-attr/unist-util-visit": ["unist-util-visit@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg=="], + "sharp/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], "string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], @@ -2596,6 +2653,8 @@ "glob/minimatch/brace-expansion": ["brace-expansion@5.0.12", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ=="], + "hast-util-from-html/parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], + "hast-util-raw/parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], "juice/cheerio/parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="], diff --git a/frontend/package.json b/frontend/package.json index 6160be6..a77286d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -21,6 +21,7 @@ "@tanstack/react-query": "5.103.1", "@tanstack/react-query-devtools": "5.103.1", "@tanstack/react-table": "9.2.4", + "@uiw/react-md-editor": "^4.1.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "fumadocs-core": "16.15.13", diff --git a/frontend/src/app/(site)/submit-post/page.tsx b/frontend/src/app/(site)/submit-post/page.tsx new file mode 100644 index 0000000..ed51c22 --- /dev/null +++ b/frontend/src/app/(site)/submit-post/page.tsx @@ -0,0 +1,70 @@ +import type { Metadata } from 'next' +import Link from 'next/link' + +import { auth } from '@/auth' +import { Button } from '@/components/ui/button' +import { PostSubmissionForm } from '@/features/blog/post-submission-form' +import { canonicalMetadata } from '@/lib/seo/canonical' + +export const metadata: Metadata = { + title: 'Write for the Redaction Tools blog', + description: + 'Pitch a guest post on redaction: a workflow, a pitfall, a comparison. An editor reads every submission before anything is published.', + ...canonicalMetadata('/submit-post'), +} + +// Like /submit: the explainer is public and indexable, only the form is gated. +export default async function SubmitPostPage() { + const session = await auth() + const signedIn = Boolean(session && !session.error) + + return ( +
+
+

+ Write for the blog +

+

+ Records officers, lawyers, journalists and engineers redact for a living. If you have + learned something the hard way, we would like to publish it. +

+
+ +
+

What we look for

+
    +
  • Practical posts: a workflow, a failure you caught, a comparison you ran yourself.
  • +
  • + Your own words, in markdown. Not published anywhere else, and not a product pitch. +
  • +
  • + A submission is a queue item, never a live page. An editor reads it, may suggest + changes, and publishes it under your name if it is accepted. You can follow its status + on{' '} + + your activity page + + . +
  • +
+
+ +
+ {signedIn ? ( + + ) : ( +
+

Your post

+

+ Sign in to submit. The first sign-in creates your account - it is how we reach you + about your post, and what keeps this queue readable. +

+ +
+ )} +
+
+ ) +} diff --git a/frontend/src/components/layout/site-footer.test.tsx b/frontend/src/components/layout/site-footer.test.tsx index 16e88d8..96868d5 100644 --- a/frontend/src/components/layout/site-footer.test.tsx +++ b/frontend/src/components/layout/site-footer.test.tsx @@ -86,6 +86,15 @@ describe('SiteFooter', () => { ) }) + it('invites guest posts', () => { + render() + + expect(screen.getByRole('link', { name: 'Submit a post' })).toHaveAttribute( + 'href', + '/submit-post', + ) + }) + it('links to the about page', () => { render() diff --git a/frontend/src/components/layout/site-footer.tsx b/frontend/src/components/layout/site-footer.tsx index fb10fea..796d9df 100644 --- a/frontend/src/components/layout/site-footer.tsx +++ b/frontend/src/components/layout/site-footer.tsx @@ -81,6 +81,9 @@ export function SiteFooter() { Submit a tool + + Submit a post + Your listings diff --git a/frontend/src/features/account/my-activity.test.tsx b/frontend/src/features/account/my-activity.test.tsx index b4f1449..dd6c039 100644 --- a/frontend/src/features/account/my-activity.test.tsx +++ b/frontend/src/features/account/my-activity.test.tsx @@ -4,6 +4,7 @@ import { describe, expect, it } from 'vitest' import { makeMySubmission } from '@/features/benchmarks/fixtures' import { makeListing } from '@/features/catalog/fixtures' import { getListMyBenchmarkSubmissionsQueryKey } from '@/lib/api/generated/benchmarks/benchmarks' +import { getListMyPostSubmissionsQueryKey } from '@/lib/api/generated/blog/blog' import { getListMyClaimsQueryKey, getListMyListingsQueryKey, @@ -13,6 +14,7 @@ import type { MyClaimOut, MyListingOut, MySubmissionOut, + PostSubmissionOut, ToolSubmissionOut, } from '@/lib/api/generated/model' import { makeTestQueryClient, renderWithProviders } from '@/test/render' @@ -45,21 +47,39 @@ function makeToolSubmission(overrides: Partial = {}): ToolSub } } +function makePostSubmission(overrides: Partial = {}): PostSubmissionOut { + return { + id: 1, + title: 'Redacting scanned PDFs', + description: 'What OCR misses.', + tags: ['OCR'], + author_name: 'Ada Writer', + status: 'pending', + review_note: '', + created_at: '2026-10-01T09:00:00Z', + reviewed_at: null, + ...overrides, + } +} + function render({ benchmarks = [], claims = [], tools = [], + posts = [], listings = [], }: { benchmarks?: MySubmissionOut[] claims?: MyClaimOut[] tools?: ToolSubmissionOut[] + posts?: PostSubmissionOut[] listings?: MyListingOut[] } = {}) { const queryClient = makeTestQueryClient() queryClient.setQueryData(getListMyBenchmarkSubmissionsQueryKey(), benchmarks) queryClient.setQueryData(getListMyClaimsQueryKey(), claims) queryClient.setQueryData(getListMySubmissionsQueryKey(), tools) + queryClient.setQueryData(getListMyPostSubmissionsQueryKey(), posts) queryClient.setQueryData(getListMyListingsQueryKey(), listings) return renderWithProviders(, { queryClient }) } @@ -105,6 +125,19 @@ describe('MyActivity', () => { expect(within(tools).getByText('Under review')).toBeInTheDocument() }) + it("lists submitted posts with the editor's note once there is one", () => { + render({ + posts: [ + makePostSubmission({ status: 'rejected', review_note: 'Too close to our OCR post.' }), + ], + }) + + const posts = section(/posts you submitted/i) + expect(within(posts).getByText('Redacting scanned PDFs')).toBeInTheDocument() + expect(within(posts).getByText('Not accepted')).toBeInTheDocument() + expect(within(posts).getByText('Too close to our OCR post.')).toBeInTheDocument() + }) + it('lists the listings maintained, linking to where they are edited', () => { render({ listings: [makeListing()] }) diff --git a/frontend/src/features/account/my-activity.tsx b/frontend/src/features/account/my-activity.tsx index ad703b6..e2017bf 100644 --- a/frontend/src/features/account/my-activity.tsx +++ b/frontend/src/features/account/my-activity.tsx @@ -8,6 +8,7 @@ import { Card } from '@/components/ui/card' import { Skeleton } from '@/components/ui/skeleton' import { SUBMISSION_STATUS } from '@/features/benchmarks/my-submissions' import { useListMyBenchmarkSubmissions } from '@/lib/api/generated/benchmarks/benchmarks' +import { useListMyPostSubmissions } from '@/lib/api/generated/blog/blog' import { useListMyClaims, useListMyListings, @@ -33,13 +34,20 @@ const TOOL_STATUS: Record = { duplicate: { label: 'Already listed', tone: 'neutral' }, } +const POST_STATUS: Record = { + pending: { label: 'Awaiting review', tone: 'neutral' }, + accepted: { label: 'Accepted', tone: 'ok' }, + rejected: { label: 'Not accepted', tone: 'warn' }, +} + function statusOf(table: Record, status: string): Status { return table[status] ?? { label: status, tone: 'neutral' } } /** * Everything the signed-in account has sent us, in one place: benchmark results, - * listing claims, tools proposed for the catalog, and the listings it maintains. + * listing claims, tools proposed for the catalog, guest posts, and the listings it + * maintains. * Each section is a summary that links on to the page where the work is done - this * page decides nothing itself. */ @@ -49,6 +57,7 @@ export function MyActivity() { + ) @@ -112,6 +121,28 @@ function ToolSubmissionSection() { ) } +function PostSubmissionSection() { + const { data } = useListMyPostSubmissions() + return ( + + Nothing yet. Have something to say about redaction?{' '} + Submit a post. + + } + items={data?.map((post) => ({ + key: post.id, + title: post.title, + // The editor's note says why, so it wins over the summary once there is one. + detail: post.review_note || post.description, + status: statusOf(POST_STATUS, post.status), + }))} + /> + ) +} + function ListingSection() { const { data } = useListMyListings() return ( diff --git a/frontend/src/features/blog/markdown-editor.tsx b/frontend/src/features/blog/markdown-editor.tsx new file mode 100644 index 0000000..d1b6e13 --- /dev/null +++ b/frontend/src/features/blog/markdown-editor.tsx @@ -0,0 +1,44 @@ +'use client' + +import '@uiw/react-md-editor/markdown-editor.css' + +import { useTheme } from 'next-themes' +import dynamic from 'next/dynamic' + +import { Markdown } from '@/components/markdown' + +// The editor touches `document` as it loads, so it never renders on the server. +// The no-highlight build: code fences in a draft do not need a syntax highlighter. +const MDEditor = dynamic(() => import('@uiw/react-md-editor/nohighlight'), { + ssr: false, + loading: () =>
, +}) + +/** + * A markdown body with a toolbar and a live preview. The preview is our own + * ``, so it shows what a reader would see and, like it, leaves raw + * HTML as text. + */ +export function MarkdownEditor({ + id, + value, + onChange, +}: { + id: string + value: string + onChange: (value: string) => void +}) { + const { resolvedTheme } = useTheme() + + return ( +
+ onChange(next ?? '')} + height={480} + textareaProps={{ id }} + components={{ preview: (source) => {source} }} + /> +
+ ) +} diff --git a/frontend/src/features/blog/post-submission-form.test.tsx b/frontend/src/features/blog/post-submission-form.test.tsx new file mode 100644 index 0000000..f283010 --- /dev/null +++ b/frontend/src/features/blog/post-submission-form.test.tsx @@ -0,0 +1,88 @@ +import { screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { describe, expect, it, vi } from 'vitest' + +import { renderWithProviders } from '@/test/render' + +import { PostSubmissionForm } from './post-submission-form' + +// The real editor loads client-only through next/dynamic; what matters here is +// the value it hands back, so a labelled textarea stands in for it. +vi.mock('./markdown-editor', () => ({ + MarkdownEditor: ({ + id, + value, + onChange, + }: { + id: string + value: string + onChange: (value: string) => void + }) =>