diff --git a/articles/models.py b/articles/models.py index 1986c88..0ed0813 100644 --- a/articles/models.py +++ b/articles/models.py @@ -30,6 +30,18 @@ class AdminUrlMixin: ) +def format_article_content(content): + md = markdown.Markdown( + extensions=[ + "extra", + CodeHiliteExtension(linenums=False, guess_lang=False), + LazyLoadingImageExtension(), + ] + ) + content = re.sub(r"(\s)#(\w+)", r"\1\#\2", content) + return md.convert(content) + + class Article(AdminUrlMixin, models.Model): DRAFT = "draft" PUBLISHED = "published" @@ -85,16 +97,7 @@ class Article(AdminUrlMixin, models.Model): @cached_property def get_formatted_content(self): - md = markdown.Markdown( - extensions=[ - "extra", - CodeHiliteExtension(linenums=False, guess_lang=False), - LazyLoadingImageExtension(), - ] - ) - content = self.content - content = re.sub(r"(\s)#(\w+)", r"\1\#\2", content) - return md.convert(content) + return format_article_content(self.content) def publish(self): if not self.published_at: diff --git a/articles/static/live-preview.js b/articles/static/live-preview.js new file mode 100644 index 0000000..d2fef9e --- /dev/null +++ b/articles/static/live-preview.js @@ -0,0 +1,56 @@ +// Returns a function, that, as long as it continues to be invoked, will not +// be triggered. The function will be called after it stops being called for +// N milliseconds. If `immediate` is passed, trigger the function on the +// leading edge, instead of the trailing. +function debounce(func, wait, immediate) { + var timeout; + return function () { + var context = this, args = arguments; + var later = function () { + timeout = null; + if (!immediate) func.apply(context, args); + }; + var callNow = immediate && !timeout; + clearTimeout(timeout); + timeout = setTimeout(later, wait); + if (callNow) func.apply(context, args); + }; +} + +let preview = null; +window.onload = function () { + const previewButton = document.querySelector("input#_live_preview"); + previewButton.addEventListener("click", event => { + event.preventDefault(); + const params = "width=800,height=1000,menubar=no,toolbar=no,location=no,status=no,resizable=yes,scrollbars=yes"; + if (preview !== null) { + preview.close(); + } + preview = window.open("about:blank", "Preview", params); + const id = Number(window.location.pathname.match(/\d+/)[0]); + const loadPreview = debounce(function () { + const body = new FormData(); + const articleContent = document.getElementById("id_content").value; + body.set("content", articleContent); + const csrfToken = document.querySelector("input[name=csrfmiddlewaretoken]").value; + body.set("csrfmiddlewaretoken", csrfToken); + fetch(`/api/render/${id}/`, {method: "POST", body: body}) + .then(function (response) { + response.text().then(value => { + preview.document.querySelector("html").innerHTML = value + }); + }) + }, 500); + preview.onload = loadPreview; + const content = document.getElementById("id_content"); + content.addEventListener("input", event => { + event.preventDefault(); + loadPreview(); + }); + }) +}; +window.onbeforeunload = function () { + if (preview !== null) { + preview.close(); + } +}; diff --git a/articles/templates/admin/articles/article/submit_line.html b/articles/templates/admin/articles/article/submit_line.html index c3cbe7b..3f07b06 100644 --- a/articles/templates/admin/articles/article/submit_line.html +++ b/articles/templates/admin/articles/article/submit_line.html @@ -1,11 +1,15 @@ {% extends "admin/submit_line.html" %} -{% load i18n admin_urls %} +{% load static %} {% block submit-row %} {{ block.super }} {% if original.status != original.PUBLISHED %} - + {% elif original.status != original.DRAFT %} - + {% endif %} - + + {% if original %} + + {% endif %} + {% endblock %} diff --git a/articles/tests/test_api_views.py b/articles/tests/test_api_views.py new file mode 100644 index 0000000..67764f1 --- /dev/null +++ b/articles/tests/test_api_views.py @@ -0,0 +1,65 @@ +import pytest +from django.test import Client +from django.urls import reverse + +from articles.models import Article, format_article_content + + +@pytest.mark.django_db +def test_unauthenticated_render_redirects(published_article: Article, client: Client): + api_res = client.post( + reverse("api-render-article", kwargs={"article_pk": published_article.pk}), + data={"content": published_article.content}, + ) + assert api_res.status_code == 302 + + +@pytest.mark.django_db +def test_render_article_same_content(published_article: Article, client: Client): + client.force_login(published_article.author) + api_res = client.post( + reverse("api-render-article", kwargs={"article_pk": published_article.pk}), + data={"content": published_article.content}, + ) + standard_res = client.get( + reverse("article-detail", kwargs={"slug": published_article.slug}) + ) + assert api_res.status_code == 200 + assert standard_res.status_code == 200 + # ignore an expected difference + api_content = api_res.content.decode("utf-8") # type: str + standard_content = standard_res.content.decode("utf-8") + api_content = api_content.replace( + "?next=/api/render/1/", + "?next=/some-article-slug/", + ) + + assert api_content == standard_content + + +@pytest.mark.django_db +def test_render_article_change_content(published_article: Article, client: Client): + client.force_login(published_article.author) + preview_content = "This is a different content **with strong emphasis**" + api_res = client.post( + reverse("api-render-article", kwargs={"article_pk": published_article.pk}), + data={"content": preview_content}, + ) + assert api_res.status_code == 200 + api_content = api_res.content.decode("utf-8") # type: str + html_preview_content = format_article_content(preview_content) + assert html_preview_content in api_content + + +@pytest.mark.django_db +def test_render_article_doesnt_save(published_article, client: Client): + client.force_login(published_article.author) + original_content = published_article.content + preview_content = "This is a different content **with strong emphasis**" + api_res = client.post( + reverse("api-render-article", kwargs={"article_pk": published_article.pk}), + data={"content": preview_content}, + ) + assert api_res.status_code == 200 + published_article.refresh_from_db() + assert published_article.content == original_content diff --git a/articles/views/api.py b/articles/views/api.py new file mode 100644 index 0000000..efaf7f8 --- /dev/null +++ b/articles/views/api.py @@ -0,0 +1,16 @@ +from django.contrib.auth.decorators import login_required +from django.http import HttpResponse +from django.shortcuts import render +from django.views.decorators.http import require_POST + +from articles.models import Article + + +@login_required +@require_POST +def render_article(request, article_pk): + template = "articles/article_detail.html" + article = Article.objects.get(pk=article_pk) + article.content = request.POST.get("content") + html = render(request, template, context={"article": article}) + return HttpResponse(html) diff --git a/blog/urls.py b/blog/urls.py index 527e5af..989000a 100644 --- a/blog/urls.py +++ b/blog/urls.py @@ -18,7 +18,7 @@ from django.contrib import admin from django.urls import path from django.views.generic import TemplateView -from articles.views import feeds, html +from articles.views import api, feeds, html from blog import settings urlpatterns = [ @@ -32,6 +32,7 @@ urlpatterns = [ path("", html.ArticlesListView.as_view(), name="articles-list"), path("drafts/", html.DraftsListView.as_view(), name="drafts-list"), path("feed/", feeds.CompleteFeed(), name="complete-feed"), + path("api/render//", api.render_article, name="api-render-article"), path("", html.ArticleDetailView.as_view(), name="article-detail-old"), path("/", html.ArticleDetailView.as_view(), name="article-detail"), ]