Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .storybook/main.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
/** @type { import('@storybook/react-webpack5').StorybookConfig } */
const config = {
stories: ["../storybook/**/*.stories.@(js|jsx)"],
addons: ["@storybook/addon-essentials"],
framework: {
name: "@storybook/react-webpack5",
options: {},
},
webpackFinal: (config) => {
config.module.rules = config.module.rules.concat([
{
test: /\.html$/,
type: "asset/source",
},
]);
return config;
},
};

module.exports = config;
26 changes: 26 additions & 0 deletions .storybook/middleware.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
const { createProxyMiddleware } = require("http-proxy-middleware");
const {
createDjangoAPIMiddleware,
} = require("storybook-django/src/middleware");

const djangoOrigin = process.env.DJANGO_ORIGIN || "http://localhost:8000";

// storybook-django middleware for the pattern-library API (handles POST body restreaming)
const djangoAPI = createDjangoAPIMiddleware({
origin: djangoOrigin,
apiPath: ["/pattern-library/"],
});

module.exports = function expressMiddleware(router) {
// Pattern library API proxy (POST requests with JSON body)
djangoAPI(router);

// Static files proxy (CSS, JS, images, fonts)
router.use(
"/static/",
createProxyMiddleware({
target: djangoOrigin,
changeOrigin: true,
})
);
};
83 changes: 83 additions & 0 deletions .storybook/preview-head.html

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looking at the explicit imports on this file, I'm concerned that this leaves room for unexpected drifts between base.html and this file when we load new files.

One idea to consider – perhaps we can create a small .html that takes care of loading these scripts/stylesheet, then both base.html and this file can share it. It definitely calls for a refactor on base.html though, I'm not sure if we really want to touch it 😅. What do you think of this?

Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
<!-- Fonts and icons (external CDNs — loaded directly) -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
Comment on lines +2 to +3

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think these 2 can be removed since we are not using Google Font

<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@24,400,0,0">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.6.0/css/all.min.css">
Comment on lines +4 to +5

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems to me like Font Awesome is only being used in _social_icon_links.html, which are used in _footer_v3.html – I have a feeling this icon library can be removed, we should look into this to confirm and remove them in a separate PR :)


<!-- Project CSS (proxied to Django via middleware) -->
<link href="/static/css/styles.css" rel="stylesheet">
<link href="/static/css/components.css" rel="stylesheet">
<link href="/static/css/boostlook.css" rel="stylesheet">
<link href="/static/css/v3/components.css" rel="stylesheet">

<!-- Alpine.js -->
<script src="https://cdn.jsdelivr.net/npm/@ryangjchandler/alpine-clipboard@2.x.x/dist/alpine-clipboard.js" defer></script>
<script src="https://cdn.jsdelivr.net/npm/@alpinejs/persist@3.x.x/dist/cdn.min.js" defer></script>
<script src="//unpkg.com/alpinejs" defer></script>

<!-- Project JS — scripts that only need to load once (utilities, global listeners) -->
<script src="/static/js/theme_handling.js"></script>
<script src="/static/js/utils.js"></script>
<script src="/static/js/highlight.js"></script>
<script src="/static/js/highlight-block.js"></script>
<script src="/static/js/dialog.js" defer></script>
<script src="/static/js/install-card.js" defer></script>

<!-- Theme meta tags (same as base.html) -->
<meta class="meta-theme" name="theme-color" content="#FAFAFA" data-dark="#18181B" data-light="#FAFAFA">
<meta class="meta-theme" name="color-scheme" content="light" data-dark="dark" data-light="light">

<!-- Hide carousel scrollbar (the track scrolls via JS, scrollbar is unwanted) -->
<style>
.post-cards--horizontal .post-cards__list {
-ms-overflow-style: none;
scrollbar-width: none;
}
.post-cards--horizontal .post-cards__list::-webkit-scrollbar {
display: none;
}
</style>

<!--
Prevent demo links (href="#", href="#_", href="#someId") from navigating
the Storybook iframe. In a real page these are harmless hash changes,
but Storybook intercepts them as route changes.
-->
<script>
document.addEventListener('click', function(e) {
var link = e.target.closest('a[href]');
if (!link) return;
var href = link.getAttribute('href');
if (href && (href === '#' || href.charAt(0) === '#')) {
e.preventDefault();
if (href !== '#') {
window.location.hash = href.substring(1);
}
}
}, true);
</script>

<!--
Scripts that self-initialize via readyState check (carousel.js, code-block.js)
must be re-loaded AFTER storybook-django injects Pattern HTML.
storybook-django fires a synthetic DOMContentLoaded after each Pattern render,
so we dynamically load these scripts on each DOMContentLoaded event.
-->
<script>
(function() {
var scriptsToReinit = [
'/static/js/carousel.js',
'/static/js/code-block.js'
];
document.addEventListener('DOMContentLoaded', function() {
scriptsToReinit.forEach(function(src) {
var old = document.querySelector('script[data-storybook-reinit="' + src + '"]');
if (old) old.parentNode.removeChild(old);
var s = document.createElement('script');
s.src = src + '?_=' + Date.now();
s.setAttribute('data-storybook-reinit', src);
document.body.appendChild(s);
});
});
})();
</script>
54 changes: 54 additions & 0 deletions .storybook/preview.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
/** @type { import('@storybook/react').Preview } */
const preview = {
parameters: {
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/i,
},
},
backgrounds: {
default: 'light',
values: [
{
name: 'light',
value: '#FAFAFA',
},
{
name: 'dark',
value: '#18181B',
},
],
},
},
decorators: [
(Story, context) => {
// Add v3 class to both html and body for v3 component styles
document.documentElement.classList.add("v3");
document.body.classList.add("v3");

// Sync Storybook's background selector with project's theme system
const bgValue = context.globals.backgrounds?.value;
if (bgValue) {
const bgConfig = context.parameters.backgrounds?.values?.find((b) => b.value === bgValue);
const theme = bgConfig?.name === 'dark' ? 'dark' : 'light';
// Use the project's saveColorMode function if available
if (typeof window.saveColorMode === 'function') {
window.saveColorMode(theme);
} else {
// Fallback: directly set localStorage and dispatch event
localStorage.setItem('colorMode', theme);
window.dispatchEvent(new StorageEvent('storage', {
key: 'colorMode',
oldValue: localStorage.getItem('colorMode'),
newValue: theme,
}));
}
}

return Story();
},
],
};

export default preview;
34 changes: 34 additions & 0 deletions config/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,10 @@
host_list = env.list("ALLOWED_HOSTS", default="localhost")
ALLOWED_HOSTS = [el.strip() for el in host_list]

# Add 'web' for Docker container-to-container communication (Storybook -> Django)
if "web" not in ALLOWED_HOSTS:
ALLOWED_HOSTS.append("web")


INSTALLED_APPS = [
"django_admin_env_notice", # Third-party
Expand Down Expand Up @@ -109,6 +113,23 @@
"taggit",
]

# Pattern Library (for Storybook) — only when installed AND explicitly enabled.
# Defaults to True in DEBUG mode; set ENABLE_PATTERN_LIBRARY=false in production
# unless you intentionally want to expose the endpoint.
ENABLE_PATTERN_LIBRARY = env.bool("ENABLE_PATTERN_LIBRARY", default=DEBUG)

try:
if ENABLE_PATTERN_LIBRARY:
import pattern_library # noqa: F401

INSTALLED_APPS += ["pattern_library"]
except ImportError:
pass

# Pre-built Storybook static bundle (output of `yarn build-storybook`).
# Served at /storybook/ by StorybookView, staff-only.
STORYBOOK_ROOT = BASE_DIR / "var" / "storybook"

# Our Apps
INSTALLED_APPS += [
"ak",
Expand All @@ -126,6 +147,18 @@
"asciidoctor_sandbox",
]

# django-pattern-library settings (used by storybook-django)
PATTERN_LIBRARY = {
"SECTIONS": (
("v3/includes", ["v3/includes"]),
("v3/examples", ["v3/examples"]),
("includes", ["includes"]),
),
"TEMPLATE_SUFFIX": ".html",
"PATTERN_BASE_TEMPLATE_NAME": "patterns/base.html",
"BASE_TEMPLATE_NAMES": ["patterns/base.html"],
}

AUTH_USER_MODEL = "users.User"
CSRF_COOKIE_HTTPONLY = True
# See https://docs.djangoproject.com/en/4.2/ref/settings/#csrf-trusted-origins
Expand All @@ -142,6 +175,7 @@
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"core.middleware.PatternLibraryStaffMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
"allauth.account.middleware.AccountMiddleware",
Expand Down
11 changes: 11 additions & 0 deletions config/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
RedirectToReleaseView,
RedirectToToolsView,
StaticContentTemplateView,
StorybookView,
UserGuideTemplateView,
)
from marketing.views import PlausibleRedirectView, WhitePaperView
Expand Down Expand Up @@ -351,6 +352,9 @@
),
# Internal functions
path("internal/clear-cache/", ClearCacheView.as_view(), name="clear-cache"),
# Storybook — pre-built static bundle, staff only
path("storybook/", StorybookView.as_view(), {"path": ""}, name="storybook"),
path("storybook/<path:path>", StorybookView.as_view()),
path(
"internal/modernized-docs/<path:content_path>",
ModernizedDocsView.as_view(),
Expand Down Expand Up @@ -446,4 +450,11 @@
]
)

# Pattern library (for Storybook) — only when ENABLE_PATTERN_LIBRARY=True.
# Protected by PatternLibraryStaffMiddleware (core/middleware.py).
if getattr(settings, "ENABLE_PATTERN_LIBRARY", False):
from pattern_library import urls as pattern_library_urls

urlpatterns.insert(0, path("pattern-library/", include(pattern_library_urls)))

handler404 = "ak.views.custom_404_view"
27 changes: 27 additions & 0 deletions core/middleware.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
from django.contrib.auth.views import redirect_to_login
from django.http import HttpResponseForbidden

_PATTERN_LIBRARY_PREFIX = "/pattern-library/"


class PatternLibraryStaffMiddleware:
"""Restrict the /pattern-library/ endpoint to staff users only.

django-pattern-library is a development tool that exposes raw template
rendering. This middleware ensures it requires staff auth even when the
endpoint is enabled (ENABLE_PATTERN_LIBRARY=True), so it can never be
accessed by unauthenticated or non-staff users.

Must appear in MIDDLEWARE after AuthenticationMiddleware.
"""

def __init__(self, get_response):
self.get_response = get_response

def __call__(self, request):
if request.path.startswith(_PATTERN_LIBRARY_PREFIX):
if not request.user.is_authenticated:
return redirect_to_login(request.get_full_path())
if not request.user.is_staff:
return HttpResponseForbidden("Staff access required.")
return self.get_response(request)
40 changes: 40 additions & 0 deletions core/views.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import mimetypes
import os
import re
from pathlib import Path

import requests
from django.utils import timezone
Expand All @@ -13,9 +15,11 @@
from dateutil.parser import parse
from django.conf import settings
from django.db.models import Exists, OuterRef
from django.contrib.admin.views.decorators import staff_member_required
from django.contrib.auth.mixins import UserPassesTestMixin
from django.core.cache import caches
from django.http import (
FileResponse,
Http404,
HttpResponse,
HttpResponseNotFound,
Expand Down Expand Up @@ -1453,6 +1457,42 @@ def get(self, request: HttpRequest, campaign_identifier: str, main_path: str = "
return HttpResponseRedirect(redirect_path)


@method_decorator(staff_member_required, name="dispatch")
class StorybookView(View):
"""Serve the pre-built Storybook static bundle, restricted to staff only.

Build the bundle first: yarn build-storybook (outputs to var/storybook/).
Then visit /storybook/ while logged in as a staff user.
"""

def get(self, request, path=""):
root = Path(settings.STORYBOOK_ROOT)

if not root.exists():
return HttpResponse(
"Storybook has not been built yet.\n\nRun: yarn build-storybook",
status=503,
content_type="text/plain",
)

target = (root / path) if path else (root / "index.html")

# Prevent path traversal
try:
target.resolve().relative_to(root.resolve())
except ValueError:
raise Http404

if not target.exists() or not target.is_file():
raise Http404

content_type, _ = mimetypes.guess_type(str(target))
return FileResponse(
open(target, "rb"),
content_type=content_type or "application/octet-stream",
)


class V3ComponentDemoView(V3Mixin, TemplateView):
"""Demo page for V3 design system components."""

Expand Down
20 changes: 20 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,26 @@ services:
- .:/code
stop_signal: SIGKILL

storybook:
build:
context: .
dockerfile: docker/Dockerfile.storybook
depends_on:
- web
environment:
- "DJANGO_ORIGIN=http://web:8000"
networks:
- backend
- frontend
ports:
- "6006:6006"
volumes:
- ./.storybook:/code/.storybook
- ./storybook:/code/storybook
- ./templates:/code/templates
- ./static:/code/static
- ./var:/code/var

maildev:
image: maildev/maildev
init: true
Expand Down
Loading
Loading