django-mosaic 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. django_mosaic/__init__.py +0 -0
  2. django_mosaic/admin.py +67 -0
  3. django_mosaic/apps.py +5 -0
  4. django_mosaic/feeds.py +25 -0
  5. django_mosaic/management/__init__.py +0 -0
  6. django_mosaic/management/commands/__init__.py +0 -0
  7. django_mosaic/management/commands/import.py +71 -0
  8. django_mosaic/migrations/0001_initial_squashed_0008_alter_tag_name_alter_tag_unique_together.py +124 -0
  9. django_mosaic/migrations/0009_alter_post_published_at.py +21 -0
  10. django_mosaic/migrations/0010_alter_post_published_at_alter_post_slug_and_more.py +28 -0
  11. django_mosaic/migrations/0011_remove_post_is_draft_post_is_published_and_more.py +37 -0
  12. django_mosaic/migrations/0012_author_post_author.py +38 -0
  13. django_mosaic/migrations/0013_add_author_to_post.py +26 -0
  14. django_mosaic/migrations/0014_contentimage.py +43 -0
  15. django_mosaic/migrations/0015_contentimage_post.py +22 -0
  16. django_mosaic/migrations/0016_alter_contentimage_image_alter_contentimage_thumb.py +25 -0
  17. django_mosaic/migrations/__init__.py +0 -0
  18. django_mosaic/models.py +162 -0
  19. django_mosaic/templates/base.html +26 -0
  20. django_mosaic/templates/home.html +24 -0
  21. django_mosaic/templates/post-detail-include.html +1 -0
  22. django_mosaic/templates/post-detail.html +25 -0
  23. django_mosaic/templates/post-list.html +10 -0
  24. django_mosaic/templates/tag-detail.html +8 -0
  25. django_mosaic/tests/post-1-introduction-to-lorem.md +45 -0
  26. django_mosaic/tests/post-2-advanced-techniques.md +91 -0
  27. django_mosaic/tests.py +1 -0
  28. django_mosaic/urls.py +25 -0
  29. django_mosaic/views.py +49 -0
  30. django_mosaic-0.1.0.dist-info/METADATA +122 -0
  31. django_mosaic-0.1.0.dist-info/RECORD +32 -0
  32. django_mosaic-0.1.0.dist-info/WHEEL +4 -0
File without changes
django_mosaic/admin.py ADDED
@@ -0,0 +1,67 @@
1
+ from django.contrib import admin
2
+ from django.db import models
3
+ from django import forms
4
+ from django.utils.html import format_html
5
+ from mosaic.models import Post, Tag, ContentImage
6
+
7
+
8
+ class ContentImageInlineAdmin(admin.TabularInline):
9
+ model = ContentImage
10
+ readonly_fields = ["thumb", "thumbnail_preview", "copy_markdown_button"]
11
+ fields = ["image", "thumbnail_preview", "caption", "alt", "copy_markdown_button"]
12
+
13
+ def thumbnail_preview(self, obj):
14
+ if obj.thumb:
15
+ return format_html(
16
+ '<img src="{}" style="max-height: 100px; max-width: 200px;" />',
17
+ obj.thumb.url,
18
+ )
19
+ return "No thumbnail"
20
+
21
+ thumbnail_preview.short_description = "Preview"
22
+
23
+ def copy_markdown_button(self, obj):
24
+ if obj.pk:
25
+ markdown_text = obj.markdown()
26
+ return format_html(
27
+ '<button type="button" class="button" '
28
+ 'onclick="navigator.clipboard.writeText(this.dataset.markdown).then(() => '
29
+ "{{ this.textContent = 'Copied!'; setTimeout(() => this.textContent = 'Copy Markdown', 1500) }})\""
30
+ 'data-markdown="{}">'
31
+ "Copy Markdown"
32
+ "</button>",
33
+ markdown_text.replace('"', "&quot;"),
34
+ )
35
+ return ""
36
+
37
+ copy_markdown_button.short_description = "Markdown"
38
+
39
+
40
+ class PostAdmin(admin.ModelAdmin):
41
+ readonly_fields = ["created_at"]
42
+ list_display = ["title", "published_at", "namespace", "is_published", "created_at"]
43
+ list_filter = ["is_published", "namespace", "tags", "published_at"]
44
+
45
+ formfield_overrides = {
46
+ models.TextField: {
47
+ "widget": forms.Textarea(
48
+ attrs={"rows": "20", "style": "max-height: none; width: 100%"}
49
+ )
50
+ },
51
+ }
52
+
53
+ inlines = [ContentImageInlineAdmin]
54
+
55
+
56
+ class ContentImageAdmin(admin.ModelAdmin):
57
+ readonly_fields = ["image", "thumb"]
58
+ list_display = ["alt", "caption", "post", "post__created_at"]
59
+
60
+
61
+ class TagAdmin(admin.ModelAdmin):
62
+ pass
63
+
64
+
65
+ admin.site.register(Post, PostAdmin)
66
+ admin.site.register(Tag, TagAdmin)
67
+ admin.site.register(ContentImage, ContentImageAdmin)
django_mosaic/apps.py ADDED
@@ -0,0 +1,5 @@
1
+ from django.apps import AppConfig
2
+
3
+
4
+ class BlogConfig(AppConfig):
5
+ name = "mosaic"
django_mosaic/feeds.py ADDED
@@ -0,0 +1,25 @@
1
+ from django.contrib.syndication.views import Feed
2
+ from django.conf import settings
3
+
4
+ from mosaic.models import Post, Namespace
5
+
6
+
7
+ class PostFeed(Feed):
8
+ title = settings.CONSTANTS["site"]["title"]
9
+ link = "/"
10
+ description = settings.CONSTANTS["site"]["description"]
11
+
12
+ def get_object(self, request, namespace):
13
+ return Namespace.objects.get(name=namespace)
14
+
15
+ def items(self, obj):
16
+ return Post.objects.filter(namespace=obj)
17
+
18
+ def item_title(self, item):
19
+ return item.title
20
+
21
+ def item_description(self, item):
22
+ return item.content
23
+
24
+ def item_pubdate(self, item):
25
+ return item.published_at
File without changes
File without changes
@@ -0,0 +1,71 @@
1
+ from pathlib import Path
2
+ import yaml
3
+ import dateutil
4
+ import logging
5
+
6
+ from django.core.management.base import BaseCommand
7
+ from django.utils.text import slugify
8
+ from mosaic.models import Post, Tag, Namespace
9
+
10
+ EXPECTED_KEYWORDS = ["title", "date", "draft"]
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+
15
+ class Command(BaseCommand):
16
+ help = "Imports markdown posts with a yaml header"
17
+
18
+ def add_arguments(self, parser):
19
+ parser.add_argument("path", type=Path)
20
+ parser.add_argument("category", type=str)
21
+
22
+ def handle(self, *args, **options):
23
+ ns = Namespace.objects.get(name=options["category"])
24
+
25
+ for file in options["path"].glob("**/*.md"):
26
+ logger.info(f"Importing {file}")
27
+ with open(file, "r") as f:
28
+ try:
29
+ file_content = f.read()
30
+ _, header, content = file_content.split("---", maxsplit=2)
31
+
32
+ header = yaml.load(header, Loader=yaml.BaseLoader)
33
+
34
+ if not all(ek in header.keys() for ek in EXPECTED_KEYWORDS):
35
+ raise ValueError(
36
+ f"Could not find all expected keywords in post metadata. Expected {EXPECTED_KEYWORDS}, found {header.keys()}"
37
+ )
38
+
39
+ slug = header.get("slug", slugify(header["title"]))
40
+
41
+ tags = []
42
+ header_tags = [t.strip() for t in header.get("tags", "").split(",") if t]
43
+ header_categories = [c.strip() for c in header.get("categories", "").split(",") if c]
44
+ header_tags.extend(header_categories)
45
+
46
+ if header_tags:
47
+ for t in header_tags:
48
+ if not isinstance(t, str):
49
+ logger.warning(
50
+ f"Could not process tag {t}, not a string"
51
+ )
52
+ continue
53
+
54
+ t, _ = Tag.objects.get_or_create(name=t, namespace=ns)
55
+ tags.append(t)
56
+
57
+ post = Post(
58
+ title=header["title"],
59
+ is_published=not header["draft"],
60
+ published_at=dateutil.parser.parse(header["date"]),
61
+ slug=slug,
62
+ namespace=ns,
63
+ summary=header.get("description", ""),
64
+ content=content,
65
+ )
66
+ post.save()
67
+ for t in tags:
68
+ post.tags.add(t)
69
+ logger.info(f"Created post {post} with tags {tags}")
70
+ except Exception as e:
71
+ logger.error(f"Could not import {file}: {e}", exc_info=True)
@@ -0,0 +1,124 @@
1
+ # Generated by Django 6.0.1 on 2026-01-22 13:17
2
+
3
+ import django.db.models.deletion
4
+ import django.utils.timezone
5
+ from django.db import migrations, models
6
+
7
+
8
+ def add_default_namespace(apps, schema_editor):
9
+ Namespace = apps.get_model("mosaic", "Namespace")
10
+ Namespace.objects.create(name="public")
11
+ Namespace.objects.create(name="private")
12
+
13
+
14
+ class Migration(migrations.Migration):
15
+
16
+ replaces = [
17
+ ("mosaic", "0001_initial"),
18
+ ("mosaic", "0002_remove_post_category_alter_post_options_and_more"),
19
+ ("mosaic", "0003_post__summary"),
20
+ ("mosaic", "0004_alter_post__summary"),
21
+ ("mosaic", "0005_tag_is_public"),
22
+ (
23
+ "mosaic",
24
+ "0006_namespace_remove_post__summary_remove_post_is_public_and_more",
25
+ ),
26
+ ("mosaic", "0007_post_namespace_tag_namespace"),
27
+ ("mosaic", "0008_alter_tag_name_alter_tag_unique_together"),
28
+ ]
29
+
30
+ dependencies = []
31
+
32
+ operations = [
33
+ migrations.CreateModel(
34
+ name="Tag",
35
+ fields=[
36
+ (
37
+ "id",
38
+ models.BigAutoField(
39
+ auto_created=True,
40
+ primary_key=True,
41
+ serialize=False,
42
+ verbose_name="ID",
43
+ ),
44
+ ),
45
+ ("name", models.CharField(max_length=256, unique=True)),
46
+ ],
47
+ ),
48
+ migrations.CreateModel(
49
+ name="Namespace",
50
+ fields=[
51
+ (
52
+ "id",
53
+ models.BigAutoField(
54
+ auto_created=True,
55
+ primary_key=True,
56
+ serialize=False,
57
+ verbose_name="ID",
58
+ ),
59
+ ),
60
+ ("name", models.SlugField(max_length=256, unique=True)),
61
+ ],
62
+ ),
63
+ migrations.RunPython(
64
+ code=add_default_namespace,
65
+ ),
66
+ migrations.CreateModel(
67
+ name="Post",
68
+ fields=[
69
+ (
70
+ "id",
71
+ models.BigAutoField(
72
+ auto_created=True,
73
+ primary_key=True,
74
+ serialize=False,
75
+ verbose_name="ID",
76
+ ),
77
+ ),
78
+ ("title", models.CharField(max_length=512, unique=True)),
79
+ ("content", models.TextField()),
80
+ ("slug", models.SlugField(max_length=256, unique=True)),
81
+ ("is_draft", models.BooleanField(default=True)),
82
+ ("created_at", models.DateTimeField(auto_now_add=True)),
83
+ ("changed_at", models.DateTimeField(auto_now=True)),
84
+ ("tags", models.ManyToManyField(to="mosaic.tag")),
85
+ (
86
+ "published_at",
87
+ models.DateTimeField(blank=True, default=django.utils.timezone.now),
88
+ ),
89
+ ("summary", models.CharField(blank=True, max_length=1024)),
90
+ ],
91
+ options={
92
+ "ordering": ["-published_at"],
93
+ },
94
+ ),
95
+ migrations.AddField(
96
+ model_name="post",
97
+ name="namespace",
98
+ field=models.ForeignKey(
99
+ default=1,
100
+ on_delete=django.db.models.deletion.PROTECT,
101
+ to="mosaic.namespace",
102
+ ),
103
+ preserve_default=False,
104
+ ),
105
+ migrations.AddField(
106
+ model_name="tag",
107
+ name="namespace",
108
+ field=models.ForeignKey(
109
+ default=1,
110
+ on_delete=django.db.models.deletion.PROTECT,
111
+ to="mosaic.namespace",
112
+ ),
113
+ preserve_default=False,
114
+ ),
115
+ migrations.AlterField(
116
+ model_name="tag",
117
+ name="name",
118
+ field=models.CharField(max_length=256),
119
+ ),
120
+ migrations.AlterUniqueTogether(
121
+ name="tag",
122
+ unique_together={("name", "namespace")},
123
+ ),
124
+ ]
@@ -0,0 +1,21 @@
1
+ # Generated by Django 6.0.1 on 2026-01-22 13:20
2
+
3
+ from django.db import migrations, models
4
+
5
+
6
+ class Migration(migrations.Migration):
7
+
8
+ dependencies = [
9
+ (
10
+ "mosaic",
11
+ "0001_initial_squashed_0008_alter_tag_name_alter_tag_unique_together",
12
+ ),
13
+ ]
14
+
15
+ operations = [
16
+ migrations.AlterField(
17
+ model_name="post",
18
+ name="published_at",
19
+ field=models.DateTimeField(blank=True),
20
+ ),
21
+ ]
@@ -0,0 +1,28 @@
1
+ # Generated by Django 6.0.1 on 2026-01-22 18:00
2
+
3
+ from django.db import migrations, models
4
+
5
+
6
+ class Migration(migrations.Migration):
7
+
8
+ dependencies = [
9
+ ("mosaic", "0009_alter_post_published_at"),
10
+ ]
11
+
12
+ operations = [
13
+ migrations.AlterField(
14
+ model_name="post",
15
+ name="published_at",
16
+ field=models.DateTimeField(blank=True, null=True),
17
+ ),
18
+ migrations.AlterField(
19
+ model_name="post",
20
+ name="slug",
21
+ field=models.SlugField(blank=True, max_length=256, unique=True),
22
+ ),
23
+ migrations.AlterField(
24
+ model_name="post",
25
+ name="tags",
26
+ field=models.ManyToManyField(blank=True, null=True, to="mosaic.tag"),
27
+ ),
28
+ ]
@@ -0,0 +1,37 @@
1
+ # Generated by Django 6.0.1 on 2026-01-26 08:51
2
+
3
+ from django.db import migrations, models
4
+
5
+
6
+ def invert_is_draft(apps, schema):
7
+ Post = apps.get_model("mosaic", "Post")
8
+ drafts = Post.objects.filter(is_published=True)
9
+ public = Post.objects.filter(is_published=False)
10
+ drafts.update(is_published=False)
11
+ public.update(is_published=True)
12
+
13
+
14
+ class Migration(migrations.Migration):
15
+
16
+ dependencies = [
17
+ ("mosaic", "0010_alter_post_published_at_alter_post_slug_and_more"),
18
+ ]
19
+
20
+ operations = [
21
+ migrations.RenameField(
22
+ model_name="post",
23
+ old_name="is_draft",
24
+ new_name="is_published"
25
+ ),
26
+ migrations.AlterField(
27
+ model_name="post",
28
+ name="is_published",
29
+ field=models.BooleanField(default=False),
30
+ ),
31
+ migrations.AlterField(
32
+ model_name="post",
33
+ name="tags",
34
+ field=models.ManyToManyField(blank=True, to="mosaic.tag"),
35
+ ),
36
+ migrations.RunPython(invert_is_draft),
37
+ ]
@@ -0,0 +1,38 @@
1
+ # Generated by Django 6.0.1 on 2026-02-03 10:48
2
+
3
+ import django.db.models.deletion
4
+ from django.conf import settings
5
+ from django.db import migrations, models
6
+
7
+
8
+ class Migration(migrations.Migration):
9
+
10
+ dependencies = [
11
+ ("mosaic", "0011_remove_post_is_draft_post_is_published_and_more"),
12
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
13
+ ]
14
+
15
+ operations = [
16
+ migrations.CreateModel(
17
+ name="Author",
18
+ fields=[
19
+ (
20
+ "id",
21
+ models.BigAutoField(
22
+ auto_created=True,
23
+ primary_key=True,
24
+ serialize=False,
25
+ verbose_name="ID",
26
+ ),
27
+ ),
28
+ ("h_card", models.JSONField()),
29
+ (
30
+ "user",
31
+ models.OneToOneField(
32
+ on_delete=django.db.models.deletion.CASCADE,
33
+ to=settings.AUTH_USER_MODEL,
34
+ ),
35
+ ),
36
+ ],
37
+ ),
38
+ ]
@@ -0,0 +1,26 @@
1
+ # Generated by Django 6.0.1 on 2026-02-03 10:48
2
+
3
+ import django.db.models.deletion
4
+ from django.conf import settings
5
+ from django.db import migrations, models
6
+
7
+
8
+ class Migration(migrations.Migration):
9
+
10
+ dependencies = [
11
+ ("mosaic", "0012_author_post_author"),
12
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
13
+ ]
14
+
15
+ operations = [
16
+ migrations.AddField(
17
+ model_name="post",
18
+ name="author",
19
+ field=models.ForeignKey(
20
+ default=1,
21
+ on_delete=django.db.models.deletion.PROTECT,
22
+ to="mosaic.author",
23
+ ),
24
+ preserve_default=False,
25
+ ),
26
+ ]
@@ -0,0 +1,43 @@
1
+ # Generated by Django 6.0.1 on 2026-02-03 15:39
2
+
3
+ import mosaic.models
4
+ from django.db import migrations, models
5
+
6
+
7
+ class Migration(migrations.Migration):
8
+
9
+ dependencies = [
10
+ ("mosaic", "0013_add_author_to_post"),
11
+ ]
12
+
13
+ operations = [
14
+ migrations.CreateModel(
15
+ name="ContentImage",
16
+ fields=[
17
+ (
18
+ "id",
19
+ models.BigAutoField(
20
+ auto_created=True,
21
+ primary_key=True,
22
+ serialize=False,
23
+ verbose_name="ID",
24
+ ),
25
+ ),
26
+ (
27
+ "image",
28
+ models.ImageField(upload_to="static/images"),
29
+ ),
30
+ (
31
+ "thumb",
32
+ models.ImageField(
33
+ blank=True,
34
+ editable=False,
35
+ null=True,
36
+ upload_to="static/images",
37
+ ),
38
+ ),
39
+ ("caption", models.CharField(blank=True, default="", max_length=2048)),
40
+ ("alt", models.CharField(blank=True, default="", max_length=2048)),
41
+ ],
42
+ ),
43
+ ]
@@ -0,0 +1,22 @@
1
+ # Generated by Django 6.0.1 on 2026-02-03 15:45
2
+
3
+ import django.db.models.deletion
4
+ from django.db import migrations, models
5
+
6
+
7
+ class Migration(migrations.Migration):
8
+
9
+ dependencies = [
10
+ ("mosaic", "0014_contentimage"),
11
+ ]
12
+
13
+ operations = [
14
+ migrations.AddField(
15
+ model_name="contentimage",
16
+ name="post",
17
+ field=models.ForeignKey(
18
+ default=1, on_delete=django.db.models.deletion.CASCADE, to="mosaic.post"
19
+ ),
20
+ preserve_default=False,
21
+ ),
22
+ ]
@@ -0,0 +1,25 @@
1
+ # Generated by Django 6.0.1 on 2026-02-03 16:10
2
+
3
+ from django.db import migrations, models
4
+
5
+
6
+ class Migration(migrations.Migration):
7
+
8
+ dependencies = [
9
+ ("mosaic", "0015_contentimage_post"),
10
+ ]
11
+
12
+ operations = [
13
+ migrations.AlterField(
14
+ model_name="contentimage",
15
+ name="image",
16
+ field=models.ImageField(upload_to="content/images/"),
17
+ ),
18
+ migrations.AlterField(
19
+ model_name="contentimage",
20
+ name="thumb",
21
+ field=models.ImageField(
22
+ blank=True, editable=False, null=True, upload_to="content/images/"
23
+ ),
24
+ ),
25
+ ]
File without changes
@@ -0,0 +1,162 @@
1
+ import bleach
2
+ import markdown
3
+ import secrets
4
+ from PIL import Image
5
+ from io import BytesIO
6
+ import os
7
+ import logging
8
+
9
+ import django.utils.timezone
10
+ from django.utils.text import slugify
11
+ from django.urls import reverse
12
+ from django.db import models
13
+ from django.contrib.auth.models import User
14
+ from django.core.files.base import ContentFile
15
+
16
+ logger = logging.getLogger("mosaic")
17
+
18
+
19
+ class Namespace(models.Model):
20
+ name = models.SlugField(max_length=256, unique=True, blank=False, null=False)
21
+
22
+ def __str__(self):
23
+ return self.name
24
+
25
+ def __repr__(self):
26
+ return f"<Namespace {self.name}>"
27
+
28
+
29
+ class Author(models.Model):
30
+ user = models.OneToOneField(User, on_delete=models.CASCADE)
31
+ h_card = models.JSONField()
32
+
33
+ def __str__(self):
34
+ return self.user.username
35
+
36
+ def __repr__(self):
37
+ return f"<Author {self.user.username}>"
38
+
39
+
40
+ class ContentImage(models.Model):
41
+ image = models.ImageField(upload_to="content/images/")
42
+ thumb = models.ImageField(
43
+ upload_to="content/images/", blank=True, null=True, editable=False
44
+ )
45
+ caption = models.CharField(max_length=2048, null=False, blank=True, default="")
46
+ alt = models.CharField(max_length=2048, null=False, blank=True, default="")
47
+ post = models.ForeignKey("Post", on_delete=models.CASCADE)
48
+
49
+ def __str__(self):
50
+ return "Image"
51
+
52
+ def __repr__(self):
53
+ return f"<Image {self.image} [{self.alt[:50]}]>"
54
+
55
+ def save(self, *args, **kwargs):
56
+ # Only generate thumbnail on creation
57
+ if not self.pk and self.image:
58
+ try:
59
+ # Generate random filename
60
+ ext = self.image.name.split(".")[-1]
61
+ random_name = secrets.token_hex(16)
62
+ new_filename = f"{random_name}.{ext}"
63
+
64
+ # Rename the image file
65
+ self.image.name = new_filename
66
+
67
+ # Generate thumbnail
68
+ img = Image.open(self.image.file)
69
+ img.thumbnail((600, 600), Image.Resampling.LANCZOS)
70
+
71
+ thumb_io = BytesIO()
72
+ img.save(thumb_io, format="PNG", quality=85)
73
+ thumb_io.seek(0)
74
+
75
+ # Save thumbnail with _thumb suffix
76
+ thumb_filename = f"{random_name}_thumb.{ext}"
77
+ self.thumb.save(
78
+ thumb_filename, ContentFile(thumb_io.read()), save=False
79
+ )
80
+ except Exception as e:
81
+ logger.warning(f"Failed to create thumbnail: {e}")
82
+
83
+ super().save(*args, **kwargs)
84
+
85
+ def markdown(self):
86
+ if self.thumb:
87
+ thumb = self.thumb
88
+ else:
89
+ thumb = self.image
90
+
91
+ if self.caption:
92
+ return f"<figure><a href='{self.image.url}'><img src='{thumb.url}' alt='{self.alt}'></a><figcaption>{self.caption}</figcaption></figure>"
93
+ return (
94
+ f"<a href='{self.image.url}'><img src='{thumb.url}' alt='{self.alt}'></a>"
95
+ )
96
+
97
+
98
+ class Post(models.Model):
99
+ author = models.ForeignKey(Author, on_delete=models.PROTECT)
100
+ title = models.CharField(max_length=512, blank=False, null=False, unique=True)
101
+ content = models.TextField()
102
+ slug = models.SlugField(max_length=256, blank=True, null=False, unique=True)
103
+ summary = models.CharField(max_length=1024, null=False, blank=True)
104
+
105
+ namespace = models.ForeignKey(
106
+ "Namespace", on_delete=models.PROTECT, blank=False, null=False
107
+ )
108
+ is_published = models.BooleanField(default=False, blank=False, null=False)
109
+
110
+ tags = models.ManyToManyField("Tag", blank=True)
111
+
112
+ created_at = models.DateTimeField(auto_now_add=True, blank=False, null=False)
113
+ published_at = models.DateTimeField(blank=True, null=True)
114
+ changed_at = models.DateTimeField(auto_now=True, blank=False, null=False)
115
+
116
+ def save(self, *args, **kwargs):
117
+ # no longer update the slug once it's been published
118
+ if not self.is_published and not self.slug:
119
+ self.slug = slugify(self.title)
120
+ if not self.summary:
121
+ self.summary = bleach.clean(
122
+ markdown.markdown(self.content), strip=True, tags={}
123
+ )[:200]
124
+ if self.is_published and not self.published_at:
125
+ self.published_at = django.utils.timezone.now()
126
+ elif not self.is_published:
127
+ self.published_at = None
128
+ return super().save(*args, **kwargs)
129
+
130
+ def get_absolute_url(self):
131
+ return reverse(
132
+ "post-detail", args=[self.namespace.name, self.published_at.year, self.slug]
133
+ )
134
+
135
+ def __str__(self):
136
+ return f"{self.title}"
137
+
138
+ def __repr__(self):
139
+ date = self.published_at or self.created_at
140
+ return f"<Post {self.title} - {date.year} [{self.namespace.name}]>"
141
+
142
+ class Meta:
143
+ ordering = ["-published_at"]
144
+
145
+
146
+ class Tag(models.Model):
147
+ name = models.CharField(max_length=256, blank=False, null=False)
148
+ namespace = models.ForeignKey(
149
+ "Namespace", on_delete=models.PROTECT, null=False, blank=False
150
+ )
151
+
152
+ def get_absolute_url(self):
153
+ return reverse("tag-detail", args=[self.namespace.name, self.name])
154
+
155
+ def __str__(self):
156
+ return f"{self.name} ({self.namespace.name})"
157
+
158
+ def __repr__(self):
159
+ return f"<Tag {self.name} [{self.namespace.name}]>"
160
+
161
+ class Meta:
162
+ unique_together = ("name", "namespace")
@@ -0,0 +1,26 @@
1
+ {% load static %}
2
+ <!DOCTYPE html>
3
+ <html lang="en">
4
+ <head>
5
+ <meta charset="UTF-8">
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
7
+ <link rel="stylesheet" href={% static "css/style.css" %}>
8
+ <title>{{ CONSTANTS.site.title }}{% block title %}{% endblock %}</title>
9
+ {% include "includes/header.html" %}
10
+ {% block head_extra %}{% endblock %}
11
+ </head>
12
+ <body>
13
+ <header class="wrap">
14
+ <nav>
15
+ <a href={% url 'home' %}>{{ CONSTANTS.site.title }}</a>
16
+ <a href={% url 'post-list' 'private' %}>Friends & Family</a>
17
+ </nav>
18
+ </header>
19
+ <main class="wrap">
20
+ {% block content %}{% endblock %}
21
+ </main>
22
+ <footer>
23
+ {% block footer_extra %}{% endblock %}
24
+ </footer>
25
+ </body>
26
+ </html>
@@ -0,0 +1,24 @@
1
+ {% extends "base.html" %}
2
+ {% load markdownify %}
3
+
4
+ {% block content %}
5
+
6
+ {% include "includes/about.html" %}
7
+
8
+ <hr/>
9
+
10
+ <h2>Writing</h2>
11
+ <ul>
12
+ {% for post in posts %}
13
+ {% include "post-detail-include.html" %}
14
+ {% endfor %}
15
+ </ul>
16
+
17
+ <hr />
18
+
19
+ <h2>Topics</h2>
20
+ {% for t in tags %}
21
+ <a href={{ t.get_absolute_url }}>{{ t.name }}</a>{% if not forloop.last %}, {% endif %}
22
+ {% endfor %}
23
+
24
+ {% endblock %}
@@ -0,0 +1 @@
1
+ <li><a href="{{ post.get_absolute_url }}" class="plain"><code class="meta">{{ post.published_at | date:"Y-m-d" }}</code> {{ post.title }}</a></li>
@@ -0,0 +1,25 @@
1
+ {% extends "base.html" %}
2
+ {% load markdownify %}
3
+
4
+ {% block title %} - {{ post.title }}{% endblock %}
5
+
6
+ {% block content %}
7
+ <article class="h-entry">
8
+ <hgroup>
9
+ <h1 class="p-name">{{ post.title }}</h1>
10
+ <small>
11
+ Written on
12
+ <time class="dt-published" datetime="{{ post.published_at|date:'Y-m-d H:i:s' }}">{{ post.published_at|date:'Y-m-d' }}</time>
13
+ by <a class="p-author h-card" href="{% url 'home' %}">{{ post.author.user.username }}</a>
14
+ {% if post.tags %} in {% for t in post.tags.all %}<a href={{ t.get_absolute_url }}>{{ t.name }}</a> {% endfor %}{% endif %}
15
+ </small>
16
+ </hgroup>
17
+ <div class="e-summary" hidden="true">
18
+ {{ post.summary }}
19
+ </div>
20
+ <div class="e-content">
21
+ {{ post.content|markdownify }}
22
+ </div>
23
+
24
+ </article>
25
+ {% endblock %}
@@ -0,0 +1,10 @@
1
+ {% extends "base.html" %}
2
+
3
+ {% block content %}
4
+ <h2>Writing</h2>
5
+ <ul>
6
+ {% for post in posts %}
7
+ <li><code>{{ post.published_at| date:"Y m d"}}</code> <a href={{ post.get_absolute_url }}>{{ post.title }}</a></li>
8
+ {% endfor %}
9
+ </ul>
10
+ {% endblock %}
@@ -0,0 +1,8 @@
1
+ {% extends "base.html" %}
2
+
3
+ {% block content %}
4
+ <h2>Posts for <strong>{{ tag.name }}</strong></h2>
5
+ {% for post in posts %}
6
+ {% include "post-detail-include.html" %}
7
+ {% endfor %}
8
+ {% endblock %}
@@ -0,0 +1,45 @@
1
+ ---
2
+ title: "Introduction to Lorem Ipsum"
3
+ date: 2026-01-15T10:30:00Z
4
+ draft: false
5
+ tags: ["lorem", "ipsum", "introduction"]
6
+ categories: ["General"]
7
+ author: "Lorem Writer"
8
+ description: "A comprehensive introduction to the world of lorem ipsum and its applications."
9
+ ---
10
+
11
+ # Introduction to Lorem Ipsum
12
+
13
+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.
14
+
15
+ ## The Origins
16
+
17
+ Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.
18
+
19
+ ### Classical Foundations
20
+
21
+ Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo.
22
+
23
+ ## Key Concepts
24
+
25
+ Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt:
26
+
27
+ - Neque porro quisquam est
28
+ - Qui dolorem ipsum quia dolor sit amet
29
+ - Consectetur adipisci velit
30
+ - Sed quia non numquam eius modi tempora incidunt
31
+
32
+ ### Practical Applications
33
+
34
+ At vero eos et accusamus et iusto odio dignissimos ducimus qui blanditiis praesentium voluptatum deleniti atque corrupti quos dolores et quas molestias excepturi sint occaecati cupiditate non provident.
35
+
36
+ ```javascript
37
+ const lorem = "ipsum dolor sit amet";
38
+ console.log(lorem);
39
+ ```
40
+
41
+ ## Conclusion
42
+
43
+ Similique sunt in culpa qui officia deserunt mollitia animi, id est laborum et dolorum fuga. Et harum quidem rerum facilis est et expedita distinctio. Nam libero tempore, cum soluta nobis est eligendi optio cumque nihil impedit quo minus id quod maxime placeat facere possimus.
44
+
45
+ Omnis voluptas assumenda est, omnis dolor repellendus. Temporibus autem quibusdam et aut officiis debitis aut rerum necessitatibus saepe eveniet ut et voluptates repudiandae sint et molestiae non recusandae.
@@ -0,0 +1,91 @@
1
+ ---
2
+ title: "Advanced Lorem Ipsum Techniques"
3
+ date: 2026-01-20T14:45:00Z
4
+ draft: false
5
+ tags: ["lorem", "advanced", "techniques", "tutorial"]
6
+ categories: ["Technical", "Tutorials"]
7
+ author: "Ipsum Expert"
8
+ description: "Exploring advanced techniques and methodologies in lorem ipsum generation and usage."
9
+ featured_image: "/images/lorem-advanced.jpg"
10
+ ---
11
+
12
+ # Advanced Lorem Ipsum Techniques
13
+
14
+ Itaque earum rerum hic tenetur a sapiente delectus, ut aut reiciendis voluptatibus maiores alias consequatur aut perferendis doloribus asperiores repellat. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
15
+
16
+ ## Understanding the Framework
17
+
18
+ Ut enim ad minima veniam, quis nostrum exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? Quis autem vel eum iure reprehenderit qui in ea voluptate velit esse quam nihil molestiae consequatur.
19
+
20
+ ### Core Principles
21
+
22
+ 1. **Primum Principium**: Lorem ipsum dolor sit amet, consectetur adipiscing elit
23
+ 2. **Secundum Principium**: Sed do eiusmod tempor incididunt ut labore et dolore
24
+ 3. **Tertium Principium**: Ut enim ad minim veniam, quis nostrud exercitation
25
+
26
+ ## Implementation Strategies
27
+
28
+ Vel illum qui dolorem eum fugiat quo voluptas nulla pariatur. At vero eos et accusamus et iusto odio dignissimos ducimus qui blanditiis praesentium voluptatum deleniti atque corrupti.
29
+
30
+ ### Method One: The Classical Approach
31
+
32
+ ```python
33
+ def generate_lorem():
34
+ text = "Lorem ipsum dolor sit amet"
35
+ return text.upper()
36
+
37
+ # Usage
38
+ result = generate_lorem()
39
+ print(result)
40
+ ```
41
+
42
+ Quos dolores et quas molestias excepturi sint occaecati cupiditate non provident, similique sunt in culpa qui officia deserunt mollitia animi, id est laborum et dolorum fuga.
43
+
44
+ ### Method Two: Modern Variations
45
+
46
+ > "Et harum quidem rerum facilis est et expedita distinctio. Nam libero tempore, cum soluta nobis est eligendi optio cumque nihil impedit quo minus id quod maxime placeat facere possimus, omnis voluptas assumenda est."
47
+
48
+ ## Performance Considerations
49
+
50
+ | Metric | Classical | Modern | Optimized |
51
+ |--------|-----------|--------|-----------|
52
+ | Speed | 100ms | 50ms | 25ms |
53
+ | Memory | 1MB | 512KB | 256KB |
54
+ | Accuracy | 95% | 97% | 99% |
55
+
56
+ Omnis dolor repellendus. Temporibus autem quibusdam et aut officiis debitis aut rerum necessitatibus saepe eveniet ut et voluptates repudiandae sint et molestiae non recusandae.
57
+
58
+ ## Best Practices
59
+
60
+ Itaque earum rerum hic tenetur a sapiente delectus:
61
+
62
+ - **Consistency**: Ut aut reiciendis voluptatibus maiores alias consequatur
63
+ - **Scalability**: Aut perferendis doloribus asperiores repellat
64
+ - **Maintainability**: Sed ut perspiciatis unde omnis iste natus error
65
+ - **Documentation**: Sit voluptatem accusantium doloremque laudantium
66
+
67
+ ### Error Handling
68
+
69
+ Nam libero tempore, cum soluta nobis est eligendi optio cumque nihil impedit quo minus id quod maxime placeat facere possimus. Omnis voluptas assumenda est, omnis dolor repellendus.
70
+
71
+ ```bash
72
+ #!/bin/bash
73
+ echo "Lorem ipsum dolor sit amet"
74
+ exit 0
75
+ ```
76
+
77
+ ## Future Directions
78
+
79
+ Temporibus autem quibusdam et aut officiis debitis aut rerum necessitatibus saepe eveniet ut et voluptates repudiandae sint et molestiae non recusandae. Itaque earum rerum hic tenetur a sapiente delectus, ut aut reiciendis voluptatibus maiores alias consequatur aut perferendis doloribus asperiores repellat.
80
+
81
+ ### Emerging Trends
82
+
83
+ Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo.
84
+
85
+ ## Conclusion
86
+
87
+ Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit.
88
+
89
+ ---
90
+
91
+ *This post was last updated on January 20, 2026.*
django_mosaic/tests.py ADDED
@@ -0,0 +1 @@
1
+ # Create your tests here.
django_mosaic/urls.py ADDED
@@ -0,0 +1,25 @@
1
+ from django.urls import path, include
2
+ from django_magic_authorization.urls import protected_path
3
+ from django.conf import settings
4
+ from django.conf.urls.static import static
5
+
6
+ from mosaic.views import post_list, post_detail, home, about, tag_detail
7
+ from mosaic.feeds import PostFeed
8
+
9
+ mosaic_patterns = [
10
+ path("tag/<str:name>", tag_detail, name="tag-detail"),
11
+ path("posts", post_list, name="post-list"),
12
+ path("posts/<int:year>/<str:post_slug>", post_detail, name="post-detail"),
13
+ path("feed", PostFeed(), name="feed"),
14
+ ]
15
+
16
+ urlpatterns = [
17
+ path("", home, name="home"),
18
+ path("<slug:namespace>/", include(mosaic_patterns)),
19
+ protected_path(
20
+ "private/", include(mosaic_patterns), kwargs={"namespace": "private"}
21
+ ),
22
+ ]
23
+
24
+ if settings.DEBUG:
25
+ urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
django_mosaic/views.py ADDED
@@ -0,0 +1,49 @@
1
+ from django.shortcuts import render, get_object_or_404
2
+ from django.conf import settings
3
+ from mosaic.models import Post, Tag
4
+
5
+
6
+ def _get_posts(namespace="public"):
7
+ return Post.objects.filter(namespace__name=namespace, is_published=True)
8
+
9
+
10
+ def home(request):
11
+ posts = _get_posts()
12
+ tags = Tag.objects.filter(post__in=posts).distinct()
13
+
14
+ return render(
15
+ request,
16
+ "home.html",
17
+ {"posts": posts, "tags": tags, "CONSTANTS": settings.CONSTANTS},
18
+ )
19
+
20
+
21
+ def post_list(request, namespace):
22
+ posts = _get_posts(namespace)
23
+ return render(
24
+ request, "post-list.html", {"posts": posts, "CONSTANTS": settings.CONSTANTS}
25
+ )
26
+
27
+
28
+ def post_detail(request, namespace, year, post_slug):
29
+ post = get_object_or_404(Post, slug=post_slug)
30
+
31
+ return render(
32
+ request, "post-detail.html", {"post": post, "CONSTANTS": settings.CONSTANTS}
33
+ )
34
+
35
+
36
+ def tag_detail(request, namespace, name):
37
+ tag = get_object_or_404(Tag, name=name, namespace__name=namespace)
38
+
39
+ posts = _get_posts(namespace).filter(tags__name=tag)
40
+
41
+ return render(
42
+ request,
43
+ "tag-detail.html",
44
+ {"posts": posts, "tag": tag, "CONSTANTS": settings.CONSTANTS},
45
+ )
46
+
47
+
48
+ def about(request):
49
+ return render(request, "about.html")
@@ -0,0 +1,122 @@
1
+ Metadata-Version: 2.3
2
+ Name: django-mosaic
3
+ Version: 0.1.0
4
+ Summary: A simple django blogging engine for the indieweb
5
+ Keywords: django,blog,indieweb
6
+ Author: j23n
7
+ Author-email: j23n <oss@j23n.com>
8
+ License: BSD 3-Clause License
9
+
10
+ Copyright (c) 2026, j23n
11
+
12
+ Redistribution and use in source and binary forms, with or without
13
+ modification, are permitted provided that the following conditions are met:
14
+
15
+ 1. Redistributions of source code must retain the above copyright notice, this
16
+ list of conditions and the following disclaimer.
17
+
18
+ 2. Redistributions in binary form must reproduce the above copyright notice,
19
+ this list of conditions and the following disclaimer in the documentation
20
+ and/or other materials provided with the distribution.
21
+
22
+ 3. Neither the name of the copyright holder nor the names of its
23
+ contributors may be used to endorse or promote products derived from
24
+ this software without specific prior written permission.
25
+
26
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
27
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
28
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
29
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
30
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
31
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
32
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
33
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
34
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
35
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
36
+
37
+ Classifier: Development Status :: 3 - Alpha
38
+ Classifier: Environment :: Web Environment
39
+ Classifier: Framework :: Django
40
+ Classifier: Framework :: Django :: 5.2
41
+ Classifier: Intended Audience :: Developers
42
+ Classifier: Operating System :: OS Independent
43
+ Classifier: Programming Language :: Python
44
+ Classifier: Programming Language :: Python :: 3
45
+ Classifier: Programming Language :: Python :: 3.12
46
+ Classifier: Programming Language :: Python :: 3.13
47
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
48
+ Requires-Dist: bleach>=6.3.0
49
+ Requires-Dist: django>=5.2.11
50
+ Requires-Dist: django-magic-authorization>=0.1.5
51
+ Requires-Dist: django-markdownify>=0.9.6
52
+ Requires-Dist: fabric>=3.2.2
53
+ Requires-Dist: pillow>=12.1.0
54
+ Requires-Python: >=3.12
55
+ Project-URL: Homepage, https://app.radicle.xyz/nodes/iris.radicle.xyz/rad:zVdCS3rdBtnLLui7jaHTf4X9j6FD
56
+ Project-URL: Repository, https://app.radicle.xyz/nodes/iris.radicle.xyz/rad:zVdCS3rdBtnLLui7jaHTf4X9j6FD
57
+ Project-URL: Bug Tracker, https://app.radicle.xyz/nodes/iris.radicle.xyz/rad:zVdCS3rdBtnLLui7jaHTf4X9j6FD/issues
58
+ Project-URL: Changelog, https://app.radicle.xyz/nodes/iris.radicle.xyz/rad:zVdCS3rdBtnLLui7jaHTf4X9j6FD/tree/CHANGELOG.md
59
+ Description-Content-Type: text/markdown
60
+
61
+ # Mosaic
62
+
63
+ A simple blog system in the spirit of the <a href="https://indieweb.org">IndieWeb</a>. It's aimed to get up and running as quickly as possible with your own, easily customizable CMS
64
+
65
+ ## Installation
66
+
67
+ First, install the package using your favorite python package manager
68
+
69
+ `uv add django-mosaic`
70
+
71
+ or
72
+
73
+ `pip install django-mosaic`
74
+
75
+ Second, you need to enable the app in your Django project.
76
+
77
+ ```python
78
+ # settings.py
79
+ INSTALLED_APPS = [
80
+ ...
81
+ "django_mosaic"
82
+ ...
83
+ ]
84
+ ```
85
+
86
+ Third, run the migrations to add the relevant schemas to your database
87
+
88
+ ```
89
+ uv run python manage.py migrate
90
+ ```
91
+
92
+ ## Quickstart
93
+
94
+ Start the development server.
95
+
96
+ ```
97
+ uv run python manage.py runserver
98
+ ```
99
+
100
+ Mosaic exposes all its features through the admin. First, create a user for the admin.
101
+
102
+ ```
103
+ uv run python manage.py createsuperuser
104
+ ```
105
+
106
+ Go to <a href="http://localhost:8000/admin">http://localhost:8000/admin"</a>.
107
+
108
+ ![[docs/img/01-admin.png]]
109
+
110
+ You can write a post right within the admin, in [markdown](https://daringfireball.net/projects/markdown/).
111
+
112
+ ![[docs/img/02-create-post.png]]
113
+
114
+ By default, there are two `namespace`s, `public` and `private`. Everything you post in `public` will be visible to, well, everyone. Posts in `private` will be visible only to those with a secret `AccessToken`, which you can also generate in the admin.
115
+
116
+ Only `Post`s with the `is_draft` flag set to `False` will be shown on your website.
117
+
118
+ Hit the save button and go to [https://localhost:8000]
119
+
120
+ ![[docs/img/03-home.png]]
121
+
122
+ Have fun!
@@ -0,0 +1,32 @@
1
+ django_mosaic/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ django_mosaic/admin.py,sha256=lFCV0wMq7Mn5TUWzGX8e7HJFY6y4lyQwLNU-9iZkcFs,2169
3
+ django_mosaic/apps.py,sha256=YOJ8IE-88UocaWFDcKsyCfoTEQx1UYBKQNq24xNCu08,85
4
+ django_mosaic/feeds.py,sha256=m0FBPYAOwOLRwj9E719QgainaPcDlSyfKmklGCvvhx4,644
5
+ django_mosaic/management/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
+ django_mosaic/management/commands/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
+ django_mosaic/management/commands/import.py,sha256=fVmwmm849xRvKH2CtNB_ntD6qkwEy5kTnyP1bSQkqbU,2800
8
+ django_mosaic/migrations/0001_initial_squashed_0008_alter_tag_name_alter_tag_unique_together.py,sha256=6mR9oPji8tbArE-KybAunmLMPwnSxFZ0ra3ZdfqxXHg,4086
9
+ django_mosaic/migrations/0009_alter_post_published_at.py,sha256=EC_8KIbL4c8SYAcXvFoPeyojXe6JPRIrgT4KxYJJteM,469
10
+ django_mosaic/migrations/0010_alter_post_published_at_alter_post_slug_and_more.py,sha256=2X30klrDVpyPcPYDAQYe7MHR7gyByyhGSJiNIEroVTk,761
11
+ django_mosaic/migrations/0011_remove_post_is_draft_post_is_published_and_more.py,sha256=S0tz3PZ4H34NOhL8Iy9jT37jg7568keMXIQz0g63jY4,1042
12
+ django_mosaic/migrations/0012_author_post_author.py,sha256=0ddEJIVRH_0honzg4eIkDHXrb1TAYI8T8PTmAmv5br0,1096
13
+ django_mosaic/migrations/0013_add_author_to_post.py,sha256=QfKBB5YXDsT5SGYY-fcqvooqd8txlNzAhVwhBeeh1aU,675
14
+ django_mosaic/migrations/0014_contentimage.py,sha256=GPevq1rikS8U1XwEnTouSM_ta7j_KKFORoPH3sphIKg,1255
15
+ django_mosaic/migrations/0015_contentimage_post.py,sha256=l-N8FHJGbSxO5QDuqQpqRldhMeKbnchvYmFWEeU3-lw,540
16
+ django_mosaic/migrations/0016_alter_contentimage_image_alter_contentimage_thumb.py,sha256=PjsNi1Q4pO-6JHTaq4dEofoQHKNq4dLUVe1LMVSjyT8,646
17
+ django_mosaic/migrations/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
18
+ django_mosaic/models.py,sha256=wbBlgpLy5Qycn3ObTaIJJCWgu3HFUjO5HzRC4L8NR0U,5323
19
+ django_mosaic/templates/base.html,sha256=xZw1wdaLz3Ly_desnqux-iGiu2iiuXR0fos4u4DZ1sA,696
20
+ django_mosaic/templates/home.html,sha256=7OKq65DyP2iaQW3peVASJB_gjM9Xenxsnkx5zdzxqq4,383
21
+ django_mosaic/templates/post-detail-include.html,sha256=isuSnEbl_4_fnJsKFxRN2IN11FY7cAd7A_cvof80iS0,147
22
+ django_mosaic/templates/post-detail.html,sha256=mWNQ8nQMIVVonykW7Y2YsiB_NtwWjpK8lfMgjOmdtCk,728
23
+ django_mosaic/templates/post-list.html,sha256=1Hg4krfs3SIaiYZCaUGgOy9GkEr3tG4fvFdUUmLuvn8,243
24
+ django_mosaic/templates/tag-detail.html,sha256=jU88SR4MNLwsD6pvQMpfQv4OlOCDdA-8U2s0tIrrrhA,191
25
+ django_mosaic/tests/post-1-introduction-to-lorem.md,sha256=JX2NcpbQvxZcnb6uGWEHnsPLhV-jyNZYP4NWIk6-vpU,2138
26
+ django_mosaic/tests/post-2-advanced-techniques.md,sha256=pFe5Yv_x1jfH1fdyewtmqmMRWUfCcjBLmKexMeCSyIM,3944
27
+ django_mosaic/tests.py,sha256=qWDvA9ZhVCQ1rPbkoFify7o_fDirXMUdYMxF12q3WIM,26
28
+ django_mosaic/urls.py,sha256=rv4jssWfX_qrzIP5hVw_Bx06IkUmDH_bqhDuo5x45KU,848
29
+ django_mosaic/views.py,sha256=U32A3UpLXv-Yhs65Z9jIP-OxCQIZ-DLzviAZrmb5hss,1255
30
+ django_mosaic-0.1.0.dist-info/WHEEL,sha256=5DEXXimM34_d4Gx1AuF9ysMr1_maoEtGKjaILM3s4w4,80
31
+ django_mosaic-0.1.0.dist-info/METADATA,sha256=UT9nOgs89T-FQ6m8mQwQkMM5TgdREBjMgcYegHL62Q0,4675
32
+ django_mosaic-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.9.29
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any