docusaurus-plugin-album 0.0.0-stage β†’ 0.1.0

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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 kywk
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,104 @@
1
- # Temporary Holding Version
1
+ # docusaurus-plugin-album
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Pinterest-style masonry photo album plugin for [Docusaurus](https://docusaurus.io) with a Google Photos timeline scrubber, immersive hero header, and zero-dependency full-screen lightbox.
4
+
5
+ - **Pinterest Masonry Grid**: Responsive multi-column layout with hover overlays for titles, locations, and series badges.
6
+ - **Google Photos Timeline Scrubber**: Floating year/month indicator on scroll with one-click smooth jumping across years.
7
+ - **Immersive Post Hero Header**: Full-width cover image, caption, location badge (`πŸ“`), and album series badge (`πŸ“`).
8
+ - **Zero-Dependency Lightbox**: Keyboard navigation (`←` / `β†’` / `Escape`), background preload of adjacent photos, scroll locking, and automatic author avatar exclusion.
9
+ - **Drop-In Blog Wrapper**: Wraps `@docusaurus/plugin-content-blog` with build-time lazy image injection and lightweight route props for fast page loads even with hundreds of albums.
10
+
11
+ ## Requirements
12
+
13
+ | Dependency | Version |
14
+ | --- | --- |
15
+ | Docusaurus | 3.8 or newer (tested with 3.10) |
16
+ | React | 18 or 19 (tested with 19.2) |
17
+ | Node.js | 20.19 or newer |
18
+
19
+ ## Install
20
+
21
+ ```bash
22
+ npm install docusaurus-plugin-album
23
+ ```
24
+
25
+ ## Usage
26
+
27
+ Register `docusaurus-plugin-album` in `docusaurus.config.ts`:
28
+
29
+ ```ts
30
+ // docusaurus.config.ts
31
+ import type { Config } from '@docusaurus/types';
32
+ import type { AlbumPluginOptions } from 'docusaurus-plugin-album';
33
+
34
+ const config: Config = {
35
+ // ...
36
+ plugins: [
37
+ [
38
+ 'docusaurus-plugin-album',
39
+ {
40
+ id: 'album',
41
+ path: 'blog.album',
42
+ routeBasePath: 'album',
43
+ blogTitle: 'Photo Album',
44
+ blogDescription: 'Travel and photography archive.',
45
+ } satisfies AlbumPluginOptions,
46
+ ],
47
+ ],
48
+ };
49
+
50
+ export default config;
51
+ ```
52
+
53
+ ## Album Post Frontmatter
54
+
55
+ Create Markdown files under your configured `path` (default: `blog.album/`):
56
+
57
+ ```markdown
58
+ ---
59
+ title: Autumn in Hokkaido Biei
60
+ date: 2025-09-05
61
+ cover: /img/album/biei-cover.jpg
62
+ cover_caption: Morning light over the Blue Pond in Biei
63
+ location: Biei, Hokkaido
64
+ album_series: 2025 Northern Autumn
65
+ tags:
66
+ - Japan
67
+ - Photography
68
+ ---
69
+
70
+ Introductory notes for the album...
71
+
72
+ <!--truncate-->
73
+
74
+ ![Blue Pond Panorama](/img/album/biei-01.jpg)
75
+
76
+ ![White Birch Reflections](/img/album/biei-02.jpg)
77
+ ```
78
+
79
+ ### Supported Frontmatter Fields
80
+
81
+ | Field | Type | Description |
82
+ | --- | --- | --- |
83
+ | `title` | `string` | Album title |
84
+ | `date` | `string \| Date` | Album date (`YYYY-MM-DD`) |
85
+ | `cover` | `string` | Cover image URL. Posts without `cover` are hidden from the masonry index |
86
+ | `cover_caption` | `string` | Optional caption displayed over the hero cover image |
87
+ | `location` | `string` | Optional location badge (`πŸ“`) |
88
+ | `album_series` | `string` | Optional series/collection badge (`πŸ“`) |
89
+ | `tags` | `string[]` | Standard Docusaurus blog tags |
90
+
91
+ All standard [`@docusaurus/plugin-content-blog` options](https://docusaurus.io/docs/api/plugins/@docusaurus/plugin-content-blog) are supported.
92
+
93
+ ## Development
94
+
95
+ ```bash
96
+ pnpm install
97
+ pnpm --filter docusaurus-plugin-album build
98
+ pnpm --filter docusaurus-plugin-album test
99
+ pnpm playground
100
+ ```
101
+
102
+ ## License
103
+
104
+ MIT
package/lib/index.d.ts ADDED
@@ -0,0 +1,26 @@
1
+ import type { BlogContent, PluginOptions as BlogPluginOptions } from '@docusaurus/plugin-content-blog';
2
+ import type { LoadContext, OptionValidationContext, Plugin } from '@docusaurus/types';
3
+ import type { AlbumPluginOptions } from './types';
4
+ export type { AlbumFrontMatter, AlbumPluginOptions, AlbumPostMeta, LightboxImage } from './types';
5
+ type HastNode = {
6
+ type?: string;
7
+ tagName?: string;
8
+ properties?: Record<string, unknown>;
9
+ children?: HastNode[];
10
+ };
11
+ /**
12
+ * Build-time Rehype plugin that injects `loading="lazy"` and `decoding="async"`
13
+ * into markdown body `<img>` elements so static HTML does not trigger eager
14
+ * preload requests for every photo in an album post.
15
+ */
16
+ export declare function rehypeAlbumLazyImages(): (tree: HastNode) => void;
17
+ /**
18
+ * Pre-parses year, month, and display label from permalink or post date.
19
+ */
20
+ export declare function parsePostDate(permalink: string, date: Date | string | undefined): {
21
+ year: string;
22
+ month: string;
23
+ displayDate: string;
24
+ };
25
+ export declare function validateOptions({ validate, options, }: OptionValidationContext<AlbumPluginOptions | undefined, BlogPluginOptions>): BlogPluginOptions;
26
+ export default function pluginAlbum(context: LoadContext, options: BlogPluginOptions): Promise<Plugin<BlogContent>>;
package/lib/index.js ADDED
@@ -0,0 +1,186 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ var __importDefault = (this && this.__importDefault) || function (mod) {
36
+ return (mod && mod.__esModule) ? mod : { "default": mod };
37
+ };
38
+ Object.defineProperty(exports, "__esModule", { value: true });
39
+ exports.rehypeAlbumLazyImages = rehypeAlbumLazyImages;
40
+ exports.parsePostDate = parsePostDate;
41
+ exports.validateOptions = validateOptions;
42
+ exports.default = pluginAlbum;
43
+ const node_path_1 = __importDefault(require("node:path"));
44
+ const plugin_content_blog_1 = __importStar(require("@docusaurus/plugin-content-blog"));
45
+ const PERMALINK_DATE_REGEX = /\/(\d{4})\/(\d{2})\//;
46
+ /**
47
+ * Build-time Rehype plugin that injects `loading="lazy"` and `decoding="async"`
48
+ * into markdown body `<img>` elements so static HTML does not trigger eager
49
+ * preload requests for every photo in an album post.
50
+ */
51
+ function rehypeAlbumLazyImages() {
52
+ function visitNode(node) {
53
+ if (!node || typeof node !== 'object') {
54
+ return;
55
+ }
56
+ if (node.type === 'element' && node.tagName === 'img') {
57
+ node.properties = node.properties ?? {};
58
+ if (!node.properties.loading) {
59
+ node.properties.loading = 'lazy';
60
+ }
61
+ if (!node.properties.decoding) {
62
+ node.properties.decoding = 'async';
63
+ }
64
+ }
65
+ if (Array.isArray(node.children)) {
66
+ for (const child of node.children) {
67
+ visitNode(child);
68
+ }
69
+ }
70
+ }
71
+ return (tree) => {
72
+ visitNode(tree);
73
+ };
74
+ }
75
+ /**
76
+ * Pre-parses year, month, and display label from permalink or post date.
77
+ */
78
+ function parsePostDate(permalink, date) {
79
+ if (typeof permalink === 'string') {
80
+ const permalinkMatch = permalink.match(PERMALINK_DATE_REGEX);
81
+ if (permalinkMatch) {
82
+ const year = permalinkMatch[1];
83
+ const month = String(Number(permalinkMatch[2]));
84
+ return { year, month, displayDate: `${year}年 ${month}月` };
85
+ }
86
+ }
87
+ if (date) {
88
+ const parsed = date instanceof Date ? date : new Date(date);
89
+ if (!Number.isNaN(parsed.getTime())) {
90
+ const year = `${parsed.getUTCFullYear()}`;
91
+ const month = `${parsed.getUTCMonth() + 1}`;
92
+ return { year, month, displayDate: `${year}年 ${month}月` };
93
+ }
94
+ }
95
+ return { year: '2026', month: '1', displayDate: '' };
96
+ }
97
+ function validateOptions({ validate, options = {}, }) {
98
+ const userRehypePlugins = Array.isArray(options.rehypePlugins) ? options.rehypePlugins : [];
99
+ const mergedOptions = {
100
+ id: 'album',
101
+ path: 'blog.album',
102
+ routeBasePath: 'album',
103
+ blogTitle: 'Album η›Έη°Ώ',
104
+ blogDescription: 'η”¨εΏ«ι–€θ¨˜ιŒ„ζ—…ι€”ι’¨ε…‰θˆ‡η”Ÿζ΄»ε‰ͺε½±οΌŒεœ¨ε…‰ε½±θˆ‡ζ™‚ι–“θ»ΈδΈ­ι‡ζΊ«ζ―δΈ€εˆ»ζ„Ÿε‹•γ€‚',
105
+ blogSidebarTitle: 'All albums',
106
+ blogSidebarCount: 'ALL',
107
+ postsPerPage: 'ALL',
108
+ blogListComponent: node_path_1.default.resolve(__dirname, 'theme/AlbumListPage'),
109
+ blogPostComponent: node_path_1.default.resolve(__dirname, 'theme/AlbumPostPage'),
110
+ onUntruncatedBlogPosts: 'ignore',
111
+ showReadingTime: true,
112
+ ...options,
113
+ rehypePlugins: [rehypeAlbumLazyImages, ...userRehypePlugins],
114
+ };
115
+ return (0, plugin_content_blog_1.validateOptions)({ validate, options: mergedOptions });
116
+ }
117
+ async function pluginAlbum(context, options) {
118
+ const blogPluginInstance = await (0, plugin_content_blog_1.default)(context, options);
119
+ return {
120
+ ...blogPluginInstance,
121
+ // Keep blogPluginInstance.name ('docusaurus-plugin-content-blog') because
122
+ // Docusaurus namespaces createData() by plugin.name and @docusaurus/plugin-content-blog
123
+ // hardcodes 'docusaurus-plugin-content-blog' in its MDX loader rules.
124
+ async contentLoaded({ content, actions }) {
125
+ const albumPosts = (content?.blogPosts ?? [])
126
+ .filter((post) => {
127
+ const fm = post?.metadata?.frontMatter;
128
+ return Boolean(fm?.cover);
129
+ })
130
+ .map((post) => {
131
+ const { permalink, title, date } = post.metadata;
132
+ const frontMatter = post.metadata.frontMatter;
133
+ const dateStr = date instanceof Date ? date.toISOString() : String(date);
134
+ const { year, month, displayDate } = parsePostDate(permalink, date);
135
+ return {
136
+ permalink,
137
+ title,
138
+ date: dateStr,
139
+ year,
140
+ month,
141
+ displayDate,
142
+ frontMatter: {
143
+ cover: frontMatter.cover,
144
+ cover_caption: frontMatter.cover_caption,
145
+ location: frontMatter.location,
146
+ album_series: frontMatter.album_series,
147
+ tags: Array.isArray(frontMatter.tags) ? frontMatter.tags : undefined,
148
+ },
149
+ };
150
+ });
151
+ const wrappedActions = {
152
+ ...actions,
153
+ addRoute(routeConfig) {
154
+ const isAlbumListRoute = routeConfig.component === options.blogListComponent ||
155
+ (typeof routeConfig.component === 'string' &&
156
+ routeConfig.component.endsWith('AlbumListPage'));
157
+ if (isAlbumListRoute) {
158
+ actions.addRoute({
159
+ ...routeConfig,
160
+ modules: {},
161
+ props: {
162
+ ...routeConfig.props,
163
+ posts: albumPosts,
164
+ },
165
+ });
166
+ return;
167
+ }
168
+ actions.addRoute(routeConfig);
169
+ },
170
+ };
171
+ if (blogPluginInstance.contentLoaded) {
172
+ await blogPluginInstance.contentLoaded({
173
+ content,
174
+ actions: wrappedActions,
175
+ });
176
+ }
177
+ },
178
+ getThemePath() {
179
+ return node_path_1.default.resolve(__dirname, 'theme');
180
+ },
181
+ getTypeScriptThemePath() {
182
+ return node_path_1.default.resolve(__dirname, '../src/theme');
183
+ },
184
+ };
185
+ }
186
+ Object.assign(pluginAlbum, { validateOptions });
@@ -0,0 +1,91 @@
1
+ import type React from 'react';
2
+ import { memo, useCallback, useMemo } from 'react';
3
+ import styles from './styles.module.css';
4
+
5
+ type Props = {
6
+ title: string;
7
+ date: string;
8
+ cover?: string;
9
+ coverCaption?: string;
10
+ location?: string;
11
+ albumSeries?: string;
12
+ onCoverClick?: () => void;
13
+ };
14
+
15
+ function AlbumHeroHeader({
16
+ title,
17
+ date,
18
+ cover,
19
+ coverCaption,
20
+ location,
21
+ albumSeries,
22
+ onCoverClick,
23
+ }: Props) {
24
+ const displayDate = useMemo(() => {
25
+ if (!date) {
26
+ return '';
27
+ }
28
+ const parsed = new Date(date);
29
+ if (!Number.isNaN(parsed.getTime())) {
30
+ return `${parsed.getUTCFullYear()}εΉ΄ ${parsed.getUTCMonth() + 1}月 ${parsed.getUTCDate()}ζ—₯`;
31
+ }
32
+ return date;
33
+ }, [date]);
34
+
35
+ const handleKeyDown = useCallback(
36
+ (e: React.KeyboardEvent<HTMLDivElement>) => {
37
+ if (!onCoverClick) {
38
+ return;
39
+ }
40
+ if (e.key === 'Enter' || e.key === ' ') {
41
+ e.preventDefault();
42
+ onCoverClick();
43
+ }
44
+ },
45
+ [onCoverClick],
46
+ );
47
+
48
+ return (
49
+ <header className={styles.postHero}>
50
+ {cover && (
51
+ // biome-ignore lint/a11y/useSemanticElements: div container wraps block-level caption and cover image
52
+ <div
53
+ className={styles.heroImageContainer}
54
+ onClick={onCoverClick}
55
+ onKeyDown={handleKeyDown}
56
+ title="ι»žζ“Šζͺ’θ¦–ε°ι’ε€§εœ–"
57
+ role="button"
58
+ tabIndex={0}
59
+ >
60
+ <img
61
+ src={cover}
62
+ alt={title}
63
+ className={styles.heroCoverImage}
64
+ loading="eager"
65
+ fetchPriority="high"
66
+ decoding="async"
67
+ />
68
+ {coverCaption && <div className={styles.heroCaption}>{coverCaption}</div>}
69
+ </div>
70
+ )}
71
+
72
+ <div className={styles.heroMetaBody}>
73
+ <h1 className={styles.heroTitle}>{title}</h1>
74
+
75
+ <div className={styles.heroBadgesRow}>
76
+ {location && (
77
+ <span className={`${styles.heroBadge} ${styles.locationBadge}`}>πŸ“ {location}</span>
78
+ )}
79
+ {albumSeries && (
80
+ <span className={`${styles.heroBadge} ${styles.seriesBadge}`}>πŸ“ {albumSeries}</span>
81
+ )}
82
+ {displayDate && (
83
+ <span className={`${styles.heroBadge} ${styles.heroDateBadge}`}>πŸ“… {displayDate}</span>
84
+ )}
85
+ </div>
86
+ </div>
87
+ </header>
88
+ );
89
+ }
90
+
91
+ export default memo(AlbumHeroHeader);
@@ -0,0 +1,107 @@
1
+ import { PageMetadata } from '@docusaurus/theme-common';
2
+ import Layout from '@theme/Layout';
3
+ import SearchMetadata from '@theme/SearchMetadata';
4
+ import { useCallback, useMemo, useRef, useState } from 'react';
5
+ import AlbumMasonryGrid, { type AlbumPostMeta, parseDate } from './AlbumMasonryGrid';
6
+ import styles from './styles.module.css';
7
+ import TimelineScrubber from './TimelineScrubber';
8
+
9
+ type Props = {
10
+ metadata: {
11
+ blogTitle: string;
12
+ blogDescription: string;
13
+ permalink: string;
14
+ [key: string]: unknown;
15
+ };
16
+ posts?: readonly AlbumPostMeta[];
17
+ items?: readonly {
18
+ content: {
19
+ metadata: AlbumPostMeta;
20
+ };
21
+ }[];
22
+ sidebar?: unknown;
23
+ };
24
+
25
+ const DEFAULT_TITLE = 'Album η›Έη°Ώ';
26
+ const DEFAULT_DESCRIPTION = 'η”¨εΏ«ι–€θ¨˜ιŒ„ζ—…ι€”ι’¨ε…‰θˆ‡η”Ÿζ΄»ε‰ͺε½±οΌŒεœ¨ε…‰ε½±θˆ‡ζ™‚ι–“θ»ΈδΈ­ι‡ζΊ«ζ―δΈ€εˆ»ζ„Ÿε‹•γ€‚';
27
+
28
+ export default function AlbumListPage(props: Props) {
29
+ const { metadata, posts: directPosts, items } = props;
30
+ const pageTitle = metadata.blogTitle || DEFAULT_TITLE;
31
+ const pageDescription = metadata.blogDescription || DEFAULT_DESCRIPTION;
32
+
33
+ const posts: AlbumPostMeta[] = useMemo(() => {
34
+ if (directPosts) {
35
+ return [...directPosts];
36
+ }
37
+ return (items ?? []).map(({ content }) => content.metadata);
38
+ }, [directPosts, items]);
39
+
40
+ // Extract unique years for TimelineScrubber in descending order
41
+ const years = useMemo(() => {
42
+ const yearSet = new Set<string>();
43
+ for (const post of posts) {
44
+ if (post.frontMatter?.cover) {
45
+ const year = post.year || parseDate(post).year;
46
+ if (year) {
47
+ yearSet.add(year);
48
+ }
49
+ }
50
+ }
51
+ return Array.from(yearSet).sort((a, b) => Number(b) - Number(a));
52
+ }, [posts]);
53
+
54
+ const [activeYear, setActiveYear] = useState<string>(() => years[0] || '2026');
55
+ const [activeMonth, setActiveMonth] = useState<string>('');
56
+ const [scrubberVisible, setScrubberVisible] = useState(false);
57
+ const scrollToYearRef = useRef<((year: string) => void) | null>(null);
58
+
59
+ const handleCurrentDateChange = useCallback((year: string, month: string) => {
60
+ setActiveYear(year);
61
+ setActiveMonth(month);
62
+ }, []);
63
+
64
+ const handleScrubberVisibleChange = useCallback((visible: boolean) => {
65
+ setScrubberVisible(visible);
66
+ }, []);
67
+
68
+ const handleSelectYear = useCallback((year: string) => {
69
+ if (scrollToYearRef.current) {
70
+ scrollToYearRef.current(year);
71
+ return;
72
+ }
73
+ const targetCard = document.querySelector<HTMLElement>(`[data-year="${year}"]`);
74
+ if (targetCard) {
75
+ targetCard.scrollIntoView({ behavior: 'smooth', block: 'center' });
76
+ }
77
+ }, []);
78
+
79
+ return (
80
+ <Layout title={pageTitle} description={pageDescription}>
81
+ <PageMetadata title={pageTitle} description={pageDescription} />
82
+ <SearchMetadata tag="blog_posts_list" />
83
+
84
+ <main className={styles.albumContainer}>
85
+ <header className={styles.albumHeader}>
86
+ <h1 className={styles.albumTitle}>{pageTitle}</h1>
87
+ <p className={styles.albumDescription}>{pageDescription}</p>
88
+ </header>
89
+
90
+ <AlbumMasonryGrid
91
+ posts={posts}
92
+ onCurrentDateChange={handleCurrentDateChange}
93
+ onScrubberVisibleChange={handleScrubberVisibleChange}
94
+ scrollToYearRef={scrollToYearRef}
95
+ />
96
+
97
+ <TimelineScrubber
98
+ years={years}
99
+ activeYear={activeYear}
100
+ activeMonth={activeMonth}
101
+ visible={scrubberVisible}
102
+ onSelectYear={handleSelectYear}
103
+ />
104
+ </main>
105
+ </Layout>
106
+ );
107
+ }