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"),
]