diff --git a/README.md b/README.md index 2e08c31..d0dd43b 100644 --- a/README.md +++ b/README.md @@ -28,19 +28,21 @@ - Generates HTML from Markdown using pandoc, commonmark, or markdown.pl (configurable) - Supports post metadata (title, date, tags) -- Supports `lastmod` timestamp in frontmatter for tracking content updates (used in sitemap, RSS feed, and optionally displayed on posts). +- Supports `lastmod` timestamp in frontmatter for tracking content updates (used in sitemap, RSS feed, and optionally displayed on posts) - Full date and time support with timezone awareness - Post descriptions/summaries for previews, OpenGraph, and RSS - Admin interface for managing posts and scheduling publications (planned for future release) - Standalone post editor with modern Ghost-like interface for visual content creation -- Creates tag index pages -- Related Posts: Automatically suggests related posts based on shared tags at the end of each post -- Author index pages with conditional navigation menu +- Creates tag index pages with optional tag RSS feeds +- Related Posts: automatically suggests related posts based on shared tags at the end of each post +- Author index pages with conditional navigation menu and optional author RSS feeds - Archives by year and month for chronological browsing - Dynamic menu generation based on available pages - Support for primary and secondary pages with automatic menu organization -- Generates sitemap.xml and RSS feed with timezone support -- Asset pre-compression: Can automatically create gzipped versions of text-based files (`.html`, `.css`, `.xml`, `.js`) during the build for servers that support serving pre-compressed content. +- Generates `sitemap.xml` and RSS feeds with timezone support +- Two build modes: `normal` (incremental, cache-backed) and `ram` (memory-first) +- RAM mode stage timing summary printed at the end of each RAM build +- Asset pre-compression with incremental and parallel gzip processing (`.html`, `.css`, `.xml`, `.js`) - Clean design - No JavaScript required (except for admin interface) - Works well without images @@ -52,11 +54,9 @@ - Supports static files (images, CSS, JS, etc.) - Configurable clean output directory option - Draft posts support -- Post scheduling system - Backup and restore functionality -- Incremental builds with file caching for improved performance -- Smart metadata caching system -- Parallel processing support using GNU parallel (if available) +- Incremental builds with file and metadata caching for improved performance +- Parallel processing with GNU parallel (if available) plus shell-worker fallbacks - File locking for safe concurrent operations - Automatic handling of different operating systems (Linux/macOS/BSDs) - Custom URL slugs with SEO-friendly permalinks @@ -207,20 +207,25 @@ BSSG/ ├── scripts/ # Supporting scripts │ ├── build/ # Modular build scripts │ │ ├── main.sh # Main build orchestrator -│ │ ├── utils.sh # Utility functions (colors, formatting, etc.) -│ │ ├── cli.sh # Command-line argument parsing -│ │ ├── config_loader.sh # Loads default and user configuration -│ │ ├── deps.sh # Dependency checking -│ │ ├── cache.sh # Cache management functions -│ │ ├── content_discovery.sh # Finds posts, pages, drafts -│ │ ├── markdown_processor.sh # Markdown conversion logic -│ │ ├── process_posts.sh # Processes individual posts -│ │ ├── process_pages.sh # Processes individual pages -│ │ ├── generate_indexes.sh # Creates index, tag, and archive pages -│ │ ├── generate_feeds.sh # Creates RSS feed and sitemap +│ │ ├── config_loader.sh # Loads defaults and local overrides +│ │ ├── deps.sh # Dependency checks +│ │ ├── cache.sh # Cache/config hash helpers +│ │ ├── content.sh # Metadata/excerpt/markdown helpers +│ │ ├── indexing.sh # File/tags/authors/archive index builders +│ │ ├── templates.sh # Template preload/menu generation +│ │ ├── generate_posts.sh # Post rendering +│ │ ├── generate_pages.sh # Static page rendering +│ │ ├── generate_index.sh # Homepage/pagination generation +│ │ ├── generate_tags.sh # Tag pages (+ optional tag RSS) +│ │ ├── generate_authors.sh # Author pages (+ optional author RSS) +│ │ ├── generate_archives.sh # Archive pages (year/month) +│ │ ├── generate_feeds.sh # Main RSS + sitemap │ │ ├── generate_secondary_pages.sh # Creates pages.html index -│ │ ├── copy_static.sh # Copies static files and theme assets -│ │ └── theme_utils.sh # Theme-related utilities +│ │ ├── related_posts.sh # Related-post indexing/render helpers +│ │ ├── post_process.sh # URL rewrite + permissions fixes +│ │ ├── assets.sh # Static copy + CSS/theme handling +│ │ ├── ram_mode.sh # RAM-mode preload/in-memory datasets +│ │ └── utils.sh # Shared helpers (time, URLs, parallel) │ ├── post.sh # Handles post creation │ ├── page.sh # Handles page creation │ ├── edit.sh # Handles post/page editing (updates lastmod) @@ -228,6 +233,8 @@ BSSG/ │ ├── list.sh # Lists posts, pages, drafts, tags │ ├── backup.sh # Backup functionality │ ├── restore.sh # Restore functionality +│ ├── benchmark.sh # Build benchmarking helper +│ ├── server.sh # Local development server implementation │ ├── theme.sh # Theme management and processing (legacy helper) │ ├── template.sh # Template processing utilities (legacy helper) │ └── css.sh # CSS generation utilities (legacy helper) @@ -261,58 +268,33 @@ BSSG/ ```bash cd BSSG -./bssg.sh [command] [options] +./bssg.sh [--config ] [command] [options] ``` ### Available Commands ``` -Usage: ./bssg.sh command [options] +Usage: ./bssg.sh [--config ] command [options] Commands: - post [-html] [draft_file] # Interactive: Create/edit post/draft, prompt for title, open editor. - # Rebuilds site afterwards if REBUILD_AFTER_POST=true in config. - # Use -html for HTML format. + post [-html] [draft_file] + Interactive: create/edit post or continue a draft. post -t [-T <tags>] [-s <slug>] [--html] [-d] {-c <content> | -f <file> | --stdin} [--build] - # Command-line: Create post non-interactively. - # -t: Title (required) - # -T: Tags (comma-sep) - # -s: Slug (optional) - # --html: HTML format (default: MD) - # -d: Save as draft - # -c: Content string - # -f: Content file - # --stdin: Content from stdin - # --build: Force rebuild (overrides REBUILD_AFTER_POST=false) - page [-html] [-s] [draft_file] Create a new page (in $PAGES_DIR or $DRAFTS_DIR/pages) - or continue editing a draft (in $DRAFTS_DIR/pages) - Use -html to edit in HTML instead of Markdown - Use -s to mark page as secondary (for menu) - edit [-n] <file> Edit an existing post/page/draft (updates lastmod) - File path should point to $SRC_DIR, $PAGES_DIR, $DRAFTS_DIR etc. - Use -n to rename based on title (posts/drafts only currently) - delete [-f] <file> Delete a post/page/draft - File path should point to $SRC_DIR, $PAGES_DIR, $DRAFTS_DIR etc. - Use -f to skip confirmation - list {posts|pages|drafts|tags [-n]} - List posts ($SRC_DIR), pages ($PAGES_DIR), - drafts ($DRAFTS_DIR and $DRAFTS_DIR/pages), or tags. - For tags, use -n to sort by count. - backup Create a backup of all posts, pages, drafts, and config - restore [backup_file|ID] Restore from a backup (all content by default) - Options: --no-content, --no-config - backups List all available backups - build [opts] Build the site using the modular build system in scripts/build/ - Options: -c|--clean-output, -f|--force-rebuild, - --config FILE, --theme NAME, - --site-url URL, --output DIR - init <target_directory> Initialize a new, empty site structure in the specified directory. - This is useful for separating your site content from the BSSG core scripts. - The script will preserve the path format you provide (relative, absolute, or tilde-prefixed) - in the generated site 'config.sh.local' for portability. - Note: If using '~' for your home directory, quote the path (e.g., '~/mysite' or "~/mysite") - to ensure the tilde is preserved in the generated config. - help Show this help message + Command-line: create post non-interactively. + page [-html] [-s] [draft_file] + Create a page or continue a page draft. + edit [-n] <file> Edit an existing post/page/draft (updates lastmod). + delete [-f] <file> Delete a post/page/draft. + list List all posts. + tags [-n] List all tags. Use -n to sort by post count. + drafts List all draft posts. + backup Create a backup of posts, pages, drafts, and config. + restore [backup_file|ID] Restore from a backup (options: --no-content, --no-config). + backups List all available backups. + build [options] Build the site (run './bssg.sh build --help' for full options). + server [options] Build and run local server (run './bssg.sh server --help'). + init <target_directory> Initialize a new site in the specified directory. + help Show help. ``` ### Creating Posts and Pages @@ -469,23 +451,39 @@ You can use these options with restore to selectively restore content: Usage: ./bssg.sh build [options] Options: - -c, --clean-output Empty the output directory before building + --src DIR Override source directory (from config: SRC_DIR) + --pages DIR Override pages directory (from config: PAGES_DIR) + --drafts DIR Override drafts directory (from config: DRAFTS_DIR) + --output DIR Override output directory (from config: OUTPUT_DIR) + --templates DIR Override templates directory (from config: TEMPLATES_DIR) + --themes-dir DIR Override themes directory (from config: THEMES_DIR) + --theme NAME Override theme for this build + --static DIR Override static directory (from config: STATIC_DIR) + --clean-output [bool] Clean output directory before build (default from config) -f, --force-rebuild Ignore cache and rebuild all files - --config FILE Use a specific configuration file (e.g., my_config.sh) - instead of the default config.sh - --src DIR Override the SRC_DIR specified in the config file - --pages DIR Override the PAGES_DIR specified in the config file - --drafts DIR Override the DRAFTS_DIR specified in the config file - --output DIR Build the site to a specific output directory - --templates DIR Override the TEMPLATES_DIR specified in the config file - --themes-dir DIR Override the THEMES_DIR specified in the config file - --theme NAME Override the theme specified in the config file for this build - --static DIR Override the STATIC_DIR specified in the config file - --site-url URL Override the SITE_URL specified in the config file for this build + --build-mode MODE Build mode: normal or ram + --site-title TITLE Override site title + --site-url URL Override site URL + --site-description DESC Override site description + --author-name NAME Override author name + --author-email EMAIL Override author email + --posts-per-page NUM Override pagination size --deploy Force deployment after successful build (overrides config) - --no-deploy Prevent deployment after build (overrides config) + --no-deploy Skip deployment after build (overrides config) + --help Show build help ``` +`--config <path>` is a global option and can be passed with any command (including `build`) to load a specific configuration file. + +Examples: + +```bash +./bssg.sh --config /path/to/site/config.sh.local build --build-mode ram +./bssg.sh build --output ./public --clean-output true +``` + +The option list above reflects the current `build --help` output. + ### Internationalization (i18n) BSSG supports generating the site in different languages. @@ -543,6 +541,7 @@ image_caption: Optional caption for the image description: A brief summary of your post that will appear in listings, social media shares, and RSS feeds. author_name: John Doe # Optional: Override default site author author_email: john@example.com # Optional: Override default site author email +fediverse_creator: @john@example.social # Optional: Override the fediverse:creator meta tag for this post --- Content goes here... @@ -586,6 +585,7 @@ BSSG supports multiple authors through optional frontmatter fields that can over - `author_name`: The name of the post author (optional) - `author_email`: The email address of the post author (optional) +- `fediverse_creator`: Explicit override for the post's `<meta name="fediverse:creator">` tag (optional) #### Fallback Behavior @@ -636,6 +636,41 @@ Author information is displayed and used in: - **Schema.org Metadata**: JSON-LD structured data for search engines - **Archive Pages**: Author information in post listings +### Fediverse Creator Tag + +BSSG can emit Mastodon's `fediverse:creator` metadata across generated pages so link previews can show and follow the author more easily. + +#### Fallback Order + +BSSG resolves the creator tag in this order: + +1. `fediverse_creator` in the post frontmatter +2. `AUTHOR_FEDIVERSE_CREATORS["Author Name"]` from config, matched against `author_name` +3. `FEDIVERSE_CREATOR` from config + +If none of those are set, no `fediverse:creator` meta tag is emitted. + +For non-post pages such as the homepage, tags, archives, authors, and static pages, BSSG uses the resolved site-level/default creator. Individual posts can still override that value with `fediverse_creator` in frontmatter. + +#### Configuration + +Add a site-wide default in `config.sh.local`: + +```bash +FEDIVERSE_CREATOR="@you@example.social" +``` + +For multi-author sites, you can optionally add exact-match per-author overrides: + +```bash +declare -A AUTHOR_FEDIVERSE_CREATORS=( + ["Jane Smith"]="@jane@example.social" + ["John Doe"]="@john@example.com" +) +``` + +If you customize `templates/header.html`, keep `{{fediverse_creator_meta}}` inside `<head>`. The bundled template already includes it, and BSSG also falls back to injecting the tag before `</head>` for older custom headers. + #### Examples **Post with custom author:** @@ -671,6 +706,34 @@ This feature is particularly useful for: - Maintaining author attribution when migrating content from other platforms - Creating author-focused content organization alongside tags and archives +### Fediverse Profile Verification + +BSSG can also emit one or more site-wide `<link rel="me">` tags in the document `<head>`, which is useful for Mastodon and compatible fediverse profile verification. + +Add this to `config.sh.local` for a single profile: + +```bash +REL_ME_URL="https://mastodon.example.com/@john" +``` + +Or use multiple links: + +```bash +REL_ME_URLS=( + "https://mastodon.example.com/@john" + "https://another-fedi.example/@john" +) +``` + +The default header.html now includes a `{{rel_me_link}}` placeholder, which expands to one or more tags such as: + +```html +<link rel="me" href="https://mastodon.example.com/@john"> +<link rel="me" href="https://another-fedi.example/@john"> +``` + +If both `REL_ME_URL` and `REL_ME_URLS` are set, BSSG emits all unique URLs from both. If neither is set, BSSG omits the tags. + ## Customization To customize the appearance of your site, you can edit: @@ -707,6 +770,16 @@ CLEAN_OUTPUT=false # If true, BSSG will always perform a full rebuild REBUILD_AFTER_POST=true # Build site automatically after creating a new post (scripts/post.sh) REBUILD_AFTER_EDIT=true # Build site automatically after editing a post (scripts/edit.sh) PRECOMPRESS_ASSETS="false" # Options: "true", "false". If true, compress text assets (HTML, CSS, XML, JS) with gzip during build. +BUILD_MODE="normal" # Options: "normal", "ram". RAM mode preloads inputs and keeps build indexes/data in memory. + +# Optional performance tunables (not required): +# RAM_MODE_MAX_JOBS=6 # Cap parallel workers in RAM mode (defaults to 6) +# RAM_MODE_VERBOSE=false # Extra RAM-mode debug/timing logs +# PRECOMPRESS_GZIP_LEVEL=9 # gzip level for precompression (1-9) +# PRECOMPRESS_MAX_JOBS=0 # 0=auto based on CPU/RAM mode cap +# PRECOMPRESS_VERBOSE=false # Verbose logs for precompression +# RAM_RSS_PREFILL_MIN_HITS=2 # RAM tag-RSS cache prefill threshold +# RAM_RSS_PREFILL_MAX_POSTS=24 # RAM tag-RSS prefill upper bound # Customization CUSTOM_CSS="" # Optional: Path to custom CSS file relative to output root (e.g., "/css/custom.css"). File should be placed in STATIC_DIR. @@ -717,6 +790,12 @@ SITE_DESCRIPTION="A complete SSG - written in bash" SITE_URL="http://localhost:8000" AUTHOR_NAME="Anonymous" AUTHOR_EMAIL="anonymous@example.com" +REL_ME_URL="" # Optional fediverse profile URL for <link rel="me"> verification +# REL_ME_URLS=( +# "https://mastodon.example.com/@john" +# "https://another-fedi.example/@john" +# ) +FEDIVERSE_CREATOR="" # Optional default fediverse:creator value for posts # Content configuration DATE_FORMAT="%Y-%m-%d %H:%M:%S %z" @@ -726,6 +805,7 @@ POSTS_PER_PAGE=10 RSS_ITEM_LIMIT=15 # Number of items to include in the RSS feed. RSS_INCLUDE_FULL_CONTENT="false" # Options: "true", "false". Include full post content in RSS feed. RSS_FILENAME="rss.xml" # The filename for the main RSS feed (e.g., feed.xml, rss.xml) +INDEX_SHOW_FULL_CONTENT="false" # Options: "true", "false". Show full post content on homepage instead of just description/excerpt. ENABLE_ARCHIVES=true # Enable or disable archive pages ENABLE_AUTHOR_PAGES=false # Enable or disable author pages (default: false) ENABLE_AUTHOR_RSS=false # Enable or disable author-specific RSS feeds (default: false) @@ -733,6 +813,11 @@ SHOW_AUTHORS_MENU_THRESHOLD=2 # Minimum authors to show menu (default: 2) URL_SLUG_FORMAT="Year/Month/Day/slug" # Format for post URLs. Available: Year, Month, Day, slug ENABLE_TAG_RSS=true # Enable or disable tag-specific RSS feed generation (default: true) +# Optional exact-match per-author fediverse overrides +# declare -A AUTHOR_FEDIVERSE_CREATORS=( +# ["Jane Smith"]="@jane@example.social" +# ) + # Archive Page Configuration ARCHIVES_LIST_ALL_POSTS="false" # Options: "true", "false". If true, list all posts on the main archive page. @@ -885,6 +970,7 @@ BSSG includes a variety of themes to customize the look of your site. Themes are - `dark` - Dark mode theme - `flat` - Microsoft Metro/Modern UI inspired flat design - `glassmorphism` - Modern frosted glass effect with blue/teal gradient +- `liquid-glass` - Fluid translucent surfaces with refractive highlights and layered depth - `material` - Material Design inspired theme - `art-deco` - Inspired by 1920s-30s Art Deco style with geometric patterns, elegant fonts, and gold/black/silver/jewel color palettes - `bauhaus` - Inspired by the Bauhaus school, focusing on functionality, primary geometric shapes, primary colors plus black and white, and clean sans-serif typography @@ -909,9 +995,12 @@ BSSG includes a variety of themes to customize the look of your site. Themes are #### Operating System Themes - `beos` - BeOS inspired theme +- `freebsd` - FreeBSD-inspired theme with the iconic red/black visual language and orb motif - `macclassic` - Classic Mac OS inspired theme - `macos9` - Mac OS 9 inspired theme +- `netbsd` - NetBSD-inspired theme with a navy/orange flag aesthetic and engineering-focused layout - `nextstep` - NeXTSTEP inspired theme +- `openbsd` - OpenBSD-inspired yellow/black Puffy-style theme with bold security-flavored styling - `osx` - macOS inspired theme - `win311` - Windows 3.11 inspired theme - `win95` - Windows 95 inspired theme @@ -929,6 +1018,9 @@ BSSG includes a variety of themes to customize the look of your site. Themes are - `docs` - A clean, structured theme ideal for technical documentation with excellent code formatting and clear navigation - `longform` - Optimized for reading long articles with highly readable typography, contained text width, and minimal distractions - `reader-mode` - Simulates browser reader mode with almost total emphasis on text, sepia background, very readable serif font, and minimal graphic elements +- `mynotes` - A warm, intimate, text-first journal theme designed for meditative long-form reading +- `museum-label` - Museum catalog style with refined serif typography, restrained metadata, and clean archival cards +- `field-journal` - Warm paper-inspired writing theme with natural tones and notebook-style presentation - `thoughtful` - A warm, accessible, and performant theme for personal reflection blogs and thoughtful writing - `text-only` - A step beyond minimalism using browser defaults with clean base typography for readability and lightning-fast loading @@ -936,6 +1028,7 @@ BSSG includes a variety of themes to customize the look of your site. Themes are - `brutalist` - Raw, minimalist concrete-inspired design - `newspaper` - Classic newspaper layout - `diary` - Personal diary/journal style +- `microfiche` - Monochrome archival projection aesthetic with scanline and microfilm-inspired styling - `random` - Selects a random theme (from the available themes) for each build To use a theme, specify it in your config file: @@ -968,11 +1061,22 @@ You can also specify a custom SITE_URL for the previews: ./generate_theme_previews.sh --site-url "https://example.com/blog" ``` -The script will use the SITE_URL from the following sources in order of precedence: -1. Command line argument (--site-url) -2. Local config file (config.sh.local) -3. Main config file (config.sh) -4. Default value (http://localhost) +You can also point the preview generator at a site-specific config file, just like `bssg.sh build`: + +```bash +./generate_theme_previews.sh --config /path/to/site/config.sh.local +``` + +BSSG configuration is resolved in this order: +1. Command line argument (`--config`) +2. `BSSG_LCONF` environment variable +3. Local config file (`config.sh.local`) +4. Main config file (`config.sh`) + +The preview `SITE_URL` is then chosen from: +1. Command line argument (`--site-url`) +2. The selected BSSG configuration +3. Default value (`http://localhost`) Each theme preview will be accessible at `SITE_URL/theme` (e.g., `https://example.com/blog/dark`). @@ -1129,13 +1233,25 @@ The system maintains a cache of extracted metadata from markdown files to reduce - File index information is stored in `.bssg_cache/file_index.txt` - Tags index information is stored in `.bssg_cache/tags_index.txt` +### RAM Build Mode + +BSSG supports a RAM-first build mode for faster full rebuilds and lower disk churn: + +- Set `BUILD_MODE="ram"` in `config.sh.local`, or run `./bssg.sh build --build-mode ram` +- Source/posts/pages/templates/locales are preloaded in memory +- Build indexes (file/tags/authors/archive, plus page lists) are kept in memory +- RAM mode intentionally skips cache persistence and always behaves like an in-memory full rebuild +- A stage timing summary is printed at the end of RAM-mode builds +- On low-end disk-bound hosts, RAM mode can significantly reduce build time by avoiding repeated disk reads + ### Parallel Processing -If GNU parallel is installed on your system, BSSG can process multiple files simultaneously: +BSSG uses multiple execution strategies to process files in parallel: - Automatically detects GNU parallel and enables it for builds with many files -- Uses 80% of available CPU cores for optimal performance -- Falls back to sequential processing if parallel is not available +- Falls back to internal shell workers when GNU parallel is unavailable or unsuitable for a stage +- Auto-detects CPU core count for worker sizing +- In RAM mode, worker count is capped by `RAM_MODE_MAX_JOBS` (default: `6`) to reduce memory pressure To take advantage of parallel processing, install GNU parallel: @@ -1150,6 +1266,10 @@ brew install parallel pkg install parallel ``` +### Real-World Result + +On a single-core OpenBSD server with spinning disks, the maintainer observed build time dropping to about one third of the previous release when building with `BUILD_MODE="ram"`. + ## Site Configuration Key configuration options: @@ -1167,12 +1287,26 @@ DATE_FORMAT="%Y-%m-%d %H:%M:%S %z" TIMEZONE="local" # Options: "local", "GMT", or a specific timezone SHOW_TIMEZONE="false" # Options: "true", "false". Determines if the timezone offset (e.g., +0200) is shown in displayed dates. POSTS_PER_PAGE=10 +BUILD_MODE="normal" # "normal" (incremental cache-backed) or "ram" (memory-first) ENABLE_ARCHIVES=true # Enable or disable archives by year/month URL_SLUG_FORMAT="Year/Month/Day/slug" # Format for post URLs RSS_ITEM_LIMIT=15 # Number of items to include in the RSS feed. RSS_INCLUDE_FULL_CONTENT="false" # Options: "true", "false". If set to "true", the full post content will be included in the RSS feed description instead of the excerpt. Useful for readers that consume entire posts via RSS. +INDEX_SHOW_FULL_CONTENT="false" # Options: "true", "false". If set to "true", the full post content will be displayed on the homepage and paginated index pages instead of just the description/excerpt. ENABLE_TAG_RSS=true # Options: "true", "false". If set to "true" (default), an additional RSS feed will be generated for each tag at `output/tags/<tag-slug>/rss.xml`. +# Precompression options +PRECOMPRESS_ASSETS="false" # Generate .gz siblings for changed text assets +# PRECOMPRESS_GZIP_LEVEL=9 +# PRECOMPRESS_MAX_JOBS=0 +# PRECOMPRESS_VERBOSE=false + +# RAM-mode tuning (optional) +# RAM_MODE_MAX_JOBS=6 +# RAM_MODE_VERBOSE=false +# RAM_RSS_PREFILL_MIN_HITS=2 +# RAM_RSS_PREFILL_MAX_POSTS=24 + # Related Posts configuration ENABLE_RELATED_POSTS=true # Options: "true", "false". If set to "true" (default), related posts based on shared tags will be shown at the end of each post. RELATED_POSTS_COUNT=3 # Number of related posts to display (default: 3, recommended maximum: 5). @@ -1282,4 +1416,3 @@ This project is licensed under the BSD 3-Clause License - see the LICENSE file f - **Themes**: Explore the available themes in the `themes` directory. - **Backup & Restore**: Use `./bssg.sh backup` and `./bssg.sh restore` to manage content backups. - **Development Blog**: Stay up-to-date with the latest release notes, development progress, and announcements on the official BSSG Dev Blog: [https://blog.bssg.dragas.net](https://blog.bssg.dragas.net) - diff --git a/bssg-editor.html b/bssg-editor.html index 7d1fb05..cfb871b 100644 --- a/bssg-editor.html +++ b/bssg-editor.html @@ -1136,6 +1136,12 @@ <div class="form-help">Author email for this post (optional)</div> </div> + <div class="form-group"> + <label class="form-label" for="fediverseCreator">Fediverse Creator</label> + <input type="text" id="fediverseCreator" class="form-input" placeholder="@you@example.social"> + <div class="form-help">Optional override for the fediverse:creator meta tag</div> + </div> + <hr style="margin: 1.5rem 0; border: none; border-top: 1px solid var(--border);"> <h3 style="font-size: 1rem; margin-bottom: 1rem; color: var(--text-secondary);">Settings</h3> @@ -1328,6 +1334,7 @@ imageCaption: '', authorName: '', authorEmail: '', + fediverseCreator: '', content: '' }, isDirty: false, @@ -1357,6 +1364,7 @@ imageCaption: document.getElementById('imageCaption'), authorName: document.getElementById('authorName'), authorEmail: document.getElementById('authorEmail'), + fediverseCreator: document.getElementById('fediverseCreator'), markdownEditor: document.getElementById('markdownEditor'), previewContent: document.getElementById('previewContent'), wordCount: document.getElementById('wordCount'), @@ -1519,6 +1527,10 @@ if (post.authorEmail) { frontmatter += `author_email: ${post.authorEmail}\n`; } + + if (post.fediverseCreator) { + frontmatter += `fediverse_creator: ${post.fediverseCreator}\n`; + } frontmatter += '---\n\n'; @@ -1818,6 +1830,7 @@ imageCaption: '', authorName: '', authorEmail: '', + fediverseCreator: '', content: '' }; state.currentArticleId = null; @@ -2025,6 +2038,7 @@ state.currentPost.imageCaption = elements.imageCaption.value; state.currentPost.authorName = elements.authorName.value; state.currentPost.authorEmail = elements.authorEmail.value; + state.currentPost.fediverseCreator = elements.fediverseCreator.value; state.currentPost.content = elements.markdownEditor.value; } @@ -2038,6 +2052,7 @@ elements.imageCaption.value = state.currentPost.imageCaption || ''; elements.authorName.value = state.currentPost.authorName || ''; elements.authorEmail.value = state.currentPost.authorEmail || ''; + elements.fediverseCreator.value = state.currentPost.fediverseCreator || ''; elements.markdownEditor.value = state.currentPost.content || ''; // Update tags @@ -2276,6 +2291,7 @@ imageCaption: parsed.image_caption || '', authorName: parsed.author_name || '', authorEmail: parsed.author_email || '', + fediverseCreator: parsed.fediverse_creator || '', content: markdownContent.trim() }; @@ -2502,6 +2518,11 @@ markDirty(); }); + elements.fediverseCreator.addEventListener('input', (e) => { + state.currentPost.fediverseCreator = e.target.value; + markDirty(); + }); + // Unsplash API key elements.unsplashKey.addEventListener('input', (e) => { const key = e.target.value.trim(); diff --git a/config.sh b/config.sh index 228c16d..0a615f0 100644 --- a/config.sh +++ b/config.sh @@ -28,6 +28,7 @@ CLEAN_OUTPUT=false # If true, BSSG will always perform a full rebuild REBUILD_AFTER_POST=true # Build site automatically after creating a new post (scripts/post.sh) REBUILD_AFTER_EDIT=true # Build site automatically after editing a post (scripts/edit.sh) PRECOMPRESS_ASSETS="false" # Options: "true", "false". If true, compress text assets (HTML, CSS, XML, JS) with gzip during build. +BUILD_MODE="ram" # Options: "normal", "ram". "ram" preloads inputs and keeps build state in memory (writes only output artifacts). # Customization CUSTOM_CSS="" # Optional: Path to custom CSS file relative to output root (e.g., "/css/custom.css"). File should be placed in STATIC_DIR. @@ -38,6 +39,19 @@ SITE_DESCRIPTION="A complete SSG - written in bash" SITE_URL="http://localhost:8000" AUTHOR_NAME="Anonymous" AUTHOR_EMAIL="anonymous@example.com" +REL_ME_URL="" # Optional fediverse profile URL for <link rel="me"> verification, e.g. "https://mastodon.example/@john" +# Optional additional rel="me" verification links. BSSG emits all unique values from +# REL_ME_URL and REL_ME_URLS. +# REL_ME_URLS=( +# "https://mastodon.example/@john" +# "https://another-fedi.example/@john" +# ) +FEDIVERSE_CREATOR="" # Optional default fediverse:creator value for posts, e.g. "@you@example.social" +# Optional per-author overrides matched against author_name exactly. +# declare -A AUTHOR_FEDIVERSE_CREATORS=( +# ["Jane Smith"]="@jane@example.social" +# ["John Doe"]="@john@example.com" +# ) # Content configuration DATE_FORMAT="%Y-%m-%d %H:%M:%S %z" @@ -47,6 +61,7 @@ POSTS_PER_PAGE=10 RSS_ITEM_LIMIT=15 # Number of items to include in the RSS feed. RSS_INCLUDE_FULL_CONTENT="false" # Options: "true", "false". Include full post content in RSS feed. RSS_FILENAME="rss.xml" # The filename for the main RSS feed (e.g., feed.xml, rss.xml) +INDEX_SHOW_FULL_CONTENT="false" # Options: "true", "false". Show full post content on homepage instead of just description/excerpt. ENABLE_ARCHIVES=true # Enable or disable archive pages ENABLE_AUTHOR_PAGES=false # Enable or disable author pages (default: false) ENABLE_AUTHOR_RSS=false # Enable or disable author-specific RSS feeds (default: false) diff --git a/generate_theme_previews.sh b/generate_theme_previews.sh index 056956e..ef22659 100755 --- a/generate_theme_previews.sh +++ b/generate_theme_previews.sh @@ -11,9 +11,13 @@ set -euo pipefail # Ensure BSSG_MAIN_SCRIPT points to the main bssg.sh in the project root # Ensure this script (generate_theme_previews.sh) is run from the project root. readonly BSSG_MAIN_SCRIPT="./bssg.sh" -readonly THEMES_DIR="./themes" +THEMES_DIR="./themes" +TEMPLATES_DIR="./templates" CONFIG_FILE="config.sh" # For reading default SITE_URL if not overridden LOCAL_CONFIG_FILE="config.sh.local" # For reading default SITE_URL if not overridden +CMD_LINE_CONFIG_FILE="" +FINAL_CONFIG_OVERRIDE="" +site_url_from_cli="" # Global variable for the dynamic example root directory EXAMPLE_ROOT_DIR_DYNAMIC="./example" # Default value, will be updated @@ -27,6 +31,8 @@ NC='\033[0m' # No Color # Default SITE_URL from config.sh if no other is specified by script's --site-url SITE_URL_BASE="http://localhost" +FULL_BUILD_MODE=false +SITE_URL_TOKEN="__BSSG_THEME_SITE_URL__" # --- Helper Functions --- info() { @@ -62,33 +68,51 @@ Generate preview sites for all available BSSG themes. Options: -h, --help Display this help message and exit + --config PATH Use a custom BSSG configuration file --site-url URL Set the base SITE_URL for theme previews (overrides config files) + --full-build Build each theme independently (slower fallback mode) Configuration: + BSSG configuration is selected in this order: + 1. Command line argument (--config) + 2. BSSG_LCONF environment variable + 3. Local config file ($LOCAL_CONFIG_FILE) + 4. Main config file ($CONFIG_FILE) + The script will use the SITE_URL from the following sources in order of precedence: 1. Command line argument (--site-url) - 2. Local config file ($LOCAL_CONFIG_FILE) - 3. Main config file ($CONFIG_FILE) - 4. Default value (http://localhost) + 2. Selected BSSG configuration + 3. Default value (http://localhost) Output: Theme previews will be generated in the '$EXAMPLE_ROOT_DIR_DYNAMIC' directory, with each theme in its own subdirectory. An index.html file will be created to navigate between themes. + +Performance: + By default, this script builds the site once and then clones it per theme, + replacing css/style.css and SITE_URL references. This is significantly faster. + Use --full-build to force one full BSSG build per theme. EOF exit 0 } # --- Parse Command Line Arguments (for this script) --- parse_args() { - site_url_from_cli="" # Made global for load_config - while [[ $# -gt 0 ]]; do case "$1" in -h|--help) print_help ;; + --config) + if [[ -n "${2:-}" && "$2" != -* ]]; then + CMD_LINE_CONFIG_FILE="$2" + shift 2 + else + error "--config requires a path to a BSSG configuration file" + fi + ;; --site-url) if [[ -n "${2:-}" ]]; then site_url_from_cli="$2" @@ -97,6 +121,10 @@ parse_args() { error "--site-url requires a value for the base URL of previews" fi ;; + --full-build) + FULL_BUILD_MODE=true + shift + ;; *) warn "Unknown option: $1 (ignored)" shift @@ -105,41 +133,50 @@ parse_args() { done } +resolve_config_override() { + if [ -n "$CMD_LINE_CONFIG_FILE" ]; then + FINAL_CONFIG_OVERRIDE="$CMD_LINE_CONFIG_FILE" + info "Using configuration file specified via --config: $FINAL_CONFIG_OVERRIDE" + elif [ -v BSSG_LCONF ] && [ -n "${BSSG_LCONF}" ]; then + FINAL_CONFIG_OVERRIDE="$BSSG_LCONF" + info "Using configuration file specified via BSSG_LCONF: $FINAL_CONFIG_OVERRIDE" + fi +} + +load_effective_bssg_configuration() { + local project_root_abs config_dump + local config_separator=$'\037' + + project_root_abs=$(pwd -P) + config_dump=$( + export BSSG_SCRIPT_DIR="$project_root_abs" + bash -c ' + source "$BSSG_SCRIPT_DIR/scripts/build/config_loader.sh" "$1" >/dev/null 2>&1 + printf "%s\037%s\037%s\037%s" "$SITE_URL" "$OUTPUT_DIR" "$THEMES_DIR" "$TEMPLATES_DIR" + ' bash "$FINAL_CONFIG_OVERRIDE" + ) || { + if [ -n "$FINAL_CONFIG_OVERRIDE" ]; then + error "Failed to load BSSG configuration from '$FINAL_CONFIG_OVERRIDE'." + fi + error "Failed to load the default BSSG configuration." + } + + IFS="$config_separator" read -r SITE_URL OUTPUT_DIR THEMES_DIR TEMPLATES_DIR <<< "$config_dump" +} + # --- Load Configuration (for this script's SITE_URL_BASE) --- load_config() { info "Loading base SITE_URL configuration for previews..." - - if [ -f "$CONFIG_FILE" ]; then - # Portable way to extract SITE_URL="value" - local main_conf_site_url - main_conf_site_url=$(awk -F'"' '/^SITE_URL=/ {print $2; exit}' "$CONFIG_FILE") - if [ -n "$main_conf_site_url" ]; then - SITE_URL_BASE="$main_conf_site_url" - info "Using SITE_URL_BASE='$SITE_URL_BASE' from $CONFIG_FILE as default" - fi - else - warn "Main configuration file '$CONFIG_FILE' not found, using default SITE_URL_BASE='$SITE_URL_BASE'." - fi - - if [ -f "$LOCAL_CONFIG_FILE" ]; then - local local_conf_site_url - # Check if SITE_URL is actually defined in the local config - if grep -q "^SITE_URL=" "$LOCAL_CONFIG_FILE" 2>/dev/null; then - local_conf_site_url=$(awk -F'"' '/^SITE_URL=/ {print $2; exit}' "$LOCAL_CONFIG_FILE") - if [ -n "$local_conf_site_url" ]; then - SITE_URL_BASE="$local_conf_site_url" - info "Overridden SITE_URL_BASE='$SITE_URL_BASE' from $LOCAL_CONFIG_FILE" - else - warn "Found $LOCAL_CONFIG_FILE but failed to extract SITE_URL, using current SITE_URL_BASE='$SITE_URL_BASE'" - fi - fi - fi - + + load_effective_bssg_configuration + SITE_URL_BASE="$SITE_URL" + info "Using SITE_URL_BASE='$SITE_URL_BASE' from the effective BSSG configuration" + if [ -n "$site_url_from_cli" ]; then SITE_URL_BASE="$site_url_from_cli" info "Using SITE_URL_BASE='$SITE_URL_BASE' from command line argument for previews" fi - + success "Configuration loaded. Using SITE_URL_BASE='$SITE_URL_BASE' for theme previews." } @@ -275,7 +312,41 @@ find_themes() { info "Found ${#themes[@]} themes: ${themes[*]}" } +run_bssg_build() { + local -a cmd=("$BSSG_MAIN_SCRIPT") + local formatted_cmd + + if [ -n "$FINAL_CONFIG_OVERRIDE" ]; then + cmd+=(--config "$FINAL_CONFIG_OVERRIDE") + fi + + cmd+=(build "$@") + formatted_cmd=$(printf '%q ' "${cmd[@]}") + info "Executing: ${formatted_cmd% }" + + "${cmd[@]}" +} + build_previews() { + prepare_example_directory + + if [ "$FULL_BUILD_MODE" = true ]; then + info "Using full-build mode (one BSSG build per theme)." + build_previews_full + return + fi + + if has_theme_specific_templates; then + warn "Theme-specific templates detected under templates/<theme>/. Falling back to full per-theme builds." + build_previews_full + return + fi + + info "Using fast preview mode: single build + clone + theme CSS swap." + build_previews_fast +} + +prepare_example_directory() { info "Clearing existing example directory: '$EXAMPLE_ROOT_DIR_DYNAMIC'" mkdir -p "$EXAMPLE_ROOT_DIR_DYNAMIC" # More robustly clear contents. Using find is safer for unusual filenames. @@ -289,9 +360,15 @@ build_previews() { # A safer alternative if `find` is available: # find "$EXAMPLE_ROOT_DIR_DYNAMIC" -mindepth 1 -delete success "Example directory cleared and ready." +} +build_previews_full() { info "Starting theme preview builds..." - info "Previews will use content from the BSSG site configured by your standard config.sh/config.sh.local files." + if [ -n "$FINAL_CONFIG_OVERRIDE" ]; then + info "Previews will use content from the BSSG site configured by '$FINAL_CONFIG_OVERRIDE'." + else + info "Previews will use content from the BSSG site configured by your standard config.sh/config.sh.local files." + fi for theme in "${themes[@]}"; do info "Building preview for theme: '$theme'" @@ -304,9 +381,7 @@ build_previews() { mkdir -p "$theme_output_path" - info "Executing: $BSSG_MAIN_SCRIPT build -f --theme \"$theme\" --site-url \"$theme_site_url\" --output \"$theme_output_path\"" - - if ! "$BSSG_MAIN_SCRIPT" build -f --theme "$theme" --site-url "$theme_site_url" --output "$theme_output_path"; then + if ! run_bssg_build -f --theme "$theme" --site-url "$theme_site_url" --output "$theme_output_path"; then error "Build failed for theme '$theme'. Check output above." fi success "Preview for theme '$theme' built successfully in '$theme_output_path'" @@ -315,6 +390,89 @@ build_previews() { success "All theme previews built." } +has_theme_specific_templates() { + local template_root="$TEMPLATES_DIR" + local theme + for theme in "${themes[@]}"; do + if [ -d "$template_root/$theme" ]; then + if [ -f "$template_root/$theme/header.html" ] || [ -f "$template_root/$theme/footer.html" ]; then + return 0 + fi + fi + done + return 1 +} + +replace_site_url_token_in_output() { + local output_dir="$1" + local replacement_url="$2" + local token="$3" + local escaped_replacement tmp_file file + + escaped_replacement=$(printf '%s' "$replacement_url" | sed -e 's/\\/\\\\/g' -e 's/&/\\&/g' -e 's/|/\\|/g') + + while IFS= read -r -d '' file; do + if LC_ALL=C grep -Fq "$token" "$file"; then + tmp_file="${file}.tmp.$$" + sed "s|${token}|${escaped_replacement}|g" "$file" > "$tmp_file" + mv "$tmp_file" "$file" + fi + done < <(find "$output_dir" -type f \( -name "*.html" -o -name "*.xml" -o -name "*.txt" -o -name "*.css" -o -name "*.json" -o -name "*.js" \) -print0) +} + +clone_base_site_to_theme() { + local base_output_path="$1" + local theme_output_path="$2" + + mkdir -p "$theme_output_path" + if command -v rsync >/dev/null 2>&1; then + rsync -a --delete --exclude='.DS_Store' "${base_output_path}/" "${theme_output_path}/" + else + cp -Rp "${base_output_path}/." "$theme_output_path/" + fi +} + +build_previews_fast() { + local base_theme="default" + local base_output_path="${EXAMPLE_ROOT_DIR_DYNAMIC}/.base-preview" + local theme theme_site_url theme_output_path + + if [ ! -f "${THEMES_DIR}/${base_theme}/style.css" ]; then + base_theme="${themes[0]}" + fi + + info "Building base preview once with theme '$base_theme' and SITE_URL token '$SITE_URL_TOKEN'..." + if ! run_bssg_build -f --theme "$base_theme" --site-url "$SITE_URL_TOKEN" --output "$base_output_path"; then + error "Base build failed in fast preview mode." + fi + + for theme in "${themes[@]}"; do + theme_site_url="${SITE_URL_BASE%/}/${theme}" + theme_output_path="${EXAMPLE_ROOT_DIR_DYNAMIC}/${theme}" + + info "Preparing fast preview for theme '$theme'" + info "Theme Site URL: $theme_site_url" + info "Theme Output Path: $theme_output_path" + + clone_base_site_to_theme "$base_output_path" "$theme_output_path" + + if [ ! -f "${THEMES_DIR}/${theme}/style.css" ]; then + error "style.css not found for theme '$theme' in '${THEMES_DIR}/${theme}'." + fi + cp "${THEMES_DIR}/${theme}/style.css" "${theme_output_path}/css/style.css" + + replace_site_url_token_in_output "$theme_output_path" "$theme_site_url" "$SITE_URL_TOKEN" + + # If precompressed assets were generated in base build, they are now stale after token replacement. + find "$theme_output_path" -type f -name "*.gz" -delete 2>/dev/null || true + + success "Fast preview for theme '$theme' prepared successfully." + done + + rm -rf "$base_output_path" + success "All fast theme previews built." +} + create_index_page() { local index_file="$EXAMPLE_ROOT_DIR_DYNAMIC/index.html" info "Generating index file at '$index_file'..." @@ -456,22 +614,17 @@ determine_example_root_dir() { # Portable way to get absolute path of current directory project_root_abs=$( (cd . && pwd -P) || { error "Could not determine project root."; exit 1; } ) - - local effective_output_dir - effective_output_dir=$(export BSSG_SCRIPT_DIR="$project_root_abs"; \ - bash -c 'source "$BSSG_SCRIPT_DIR/scripts/build/config_loader.sh" "" &>/dev/null; echo "$OUTPUT_DIR"') - - if [ -z "$effective_output_dir" ]; then + if [ -z "${OUTPUT_DIR:-}" ]; then warn "Could not determine effective OUTPUT_DIR from BSSG configuration. Defaulting EXAMPLE_ROOT_DIR_DYNAMIC to '$EXAMPLE_ROOT_DIR_DYNAMIC'." return fi - info "Effective OUTPUT_DIR from BSSG configuration: '$effective_output_dir'" + info "Effective OUTPUT_DIR from BSSG configuration: '$OUTPUT_DIR'" local effective_output_dir_abs_unnormalized - if [[ "$effective_output_dir" == /* ]]; then - effective_output_dir_abs_unnormalized="$effective_output_dir" + if [[ "$OUTPUT_DIR" == /* ]]; then + effective_output_dir_abs_unnormalized="$OUTPUT_DIR" else - effective_output_dir_abs_unnormalized="$project_root_abs/$effective_output_dir" + effective_output_dir_abs_unnormalized="$project_root_abs/$OUTPUT_DIR" fi # Normalize the path using our helper (handles ., .., and non-existent paths) @@ -489,7 +642,7 @@ determine_example_root_dir() { fi - if [[ "$site_root_candidate" != "$project_root_abs" && "$effective_output_dir" == /* ]]; then + if [[ "$site_root_candidate" != "$project_root_abs" && "$OUTPUT_DIR" == /* ]]; then info "Detected external site configuration. Previews will be generated in '$site_root_candidate/example'." EXAMPLE_ROOT_DIR_DYNAMIC="$site_root_candidate/example" else @@ -506,6 +659,7 @@ main() { declare -a themes parse_args "$@" + resolve_config_override load_config check_dependencies determine_example_root_dir @@ -518,4 +672,4 @@ main() { info "Open '$EXAMPLE_ROOT_DIR_DYNAMIC/index.html' in your browser to view them." } -main "$@" \ No newline at end of file +main "$@" diff --git a/locales/nl.sh b/locales/nl.sh new file mode 100644 index 0000000..aee094f --- /dev/null +++ b/locales/nl.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +# Dutch Locale for BSSG + +export MSG_HOME="Home" +export MSG_TAGS="Tags" +export MSG_AUTHORS="Autheurs" +export MSG_ARCHIVES="Archieven" +export MSG_RSS="RSS" +export MSG_PAGES="Paginas" +export MSG_SUBSCRIBE_RSS="Volg via RSS" +export MSG_PUBLISHED_ON="Gepubliceerd op" +export MSG_BY="door" +export MSG_POSTS_BY="Posts door" +export MSG_TAG_PAGE_TITLE="Posts getagd met" +export MSG_ALL_TAGS="All Tags" +export MSG_ALL_AUTHORS="Alle Authors" +export MSG_ALL_PAGES="All Paginas" +export MSG_ARCHIVES_FOR="Archiven voor" +export MSG_BACK_TO="Terug naar" +export MSG_POSTS_FROM="Posts van" +export MSG_OLDER_POSTS="Oudere Posts" +export MSG_NEWER_POSTS="Nieuwere Posts" +export MSG_PAGE_INFO_TEMPLATE="Pagina %d van %d" +export MSG_PAGE_TITLE_PREFIX="Pagina" +export MSG_RSS_FEED_TITLE="${SITE_TITLE} - RSS Feed" +export MSG_RSS_FEED_DESCRIPTION="${SITE_DESCRIPTION}" +export MSG_RSS_FEED="RSS Feed" +export MSG_ALL_RIGHTS_RESERVED="Alle rechten reserved." +export MSG_GENERATED_WITH="Deze site was gegenereerd met" +export MSG_LATEST_POSTS="Laatste Posts" +export MSG_GENERATOR_DESCRIPTION="." +export MSG_POSTS="posts" +export MSG_READ_MORE="Lees meer" +export MSG_MONTH_01="Januari" +export MSG_MONTH_02="Februari" +export MSG_MONTH_03="Maart" +export MSG_MONTH_04="April" +export MSG_MONTH_05="Mei" +export MSG_MONTH_06="Juni" +export MSG_MONTH_07="Juli" +export MSG_MONTH_08="Augustus" +export MSG_MONTH_09="September" +export MSG_MONTH_10="October" +export MSG_MONTH_11="November" +export MSG_MONTH_12="December" +export MSG_READING_TIME_TEMPLATE="%d min read" +export MSG_MINUTE="minuut" +export MSG_MINUTES="minuten" +export MSG_UPDATED_ON="Bijgewerkt op" +export MSG_BACK_TO_TOP="Terug naar Boven" +export MSG_RELATED_POSTS="Gerelateede Posts" \ No newline at end of file diff --git a/scripts/bssg.sh b/scripts/bssg.sh index cbc8ddb..4f38a52 100755 --- a/scripts/bssg.sh +++ b/scripts/bssg.sh @@ -99,17 +99,24 @@ fi # Terminal colors (still needed here if config_loader doesn't export them, though it should) # These are now primarily set and exported by config_loader.sh based on config files. # The ':-' syntax provides a fallback if they somehow aren't set, using tput. -RED="${RED:-$(tput setaf 1)}" -GREEN="${GREEN:-$(tput setaf 2)}" -YELLOW="${YELLOW:-$(tput setaf 3)}" -NC="${NC:-$(tput sgr0)}" # Reset color +if [[ -t 1 ]] && command -v tput > /dev/null 2>&1 && tput setaf 1 > /dev/null 2>&1; then + RED="${RED:-$(tput setaf 1)}" + GREEN="${GREEN:-$(tput setaf 2)}" + YELLOW="${YELLOW:-$(tput setaf 3)}" + NC="${NC:-$(tput sgr0)}" # Reset color +else + RED="${RED:-}" + GREEN="${GREEN:-}" + YELLOW="${YELLOW:-}" + NC="${NC:-}" +fi # Make sure all scripts are executable chmod +x scripts/*.sh 2>/dev/null || true # Function to display help information show_help() { - echo "BSSG - Bash Static Site Generator (v0.32)" + echo "BSSG - Bash Static Site Generator (v0.33)" echo "=========================================" echo "" echo "Usage: $0 [--config <path>] command [options]" @@ -163,6 +170,7 @@ show_build_help() { echo " --static DIR Override Static directory (from config: ${STATIC_DIR:-static})" echo " --clean-output [bool] Clean output directory before building (default from config: ${CLEAN_OUTPUT:-false})" echo " --force-rebuild, -f Force rebuild of all files regardless of modification time" + echo " --build-mode MODE Build mode: normal or ram (default from config: ${BUILD_MODE:-normal})" echo " --site-title TITLE Override Site title" echo " --site-url URL Override Site URL" echo " --site-description DESC Override Site description" @@ -210,6 +218,9 @@ main() { command="$1" shift # Consume the command itself + # expand variables such as POSTS_DIR, PAGES_DIR embedded in the command-line + set -- $(eval echo "$@") + case "$command" in post) scripts/post.sh "$@" @@ -298,6 +309,22 @@ main() { export FORCE_REBUILD=true shift 1 ;; + --build-mode) + if [[ -z "$2" || "$2" == -* ]]; then + echo -e "${RED}Error: --build-mode requires a value (normal|ram).${NC}" >&2 + exit 1 + fi + case "$2" in + normal|ram) + export BUILD_MODE="$2" + ;; + *) + echo -e "${RED}Error: Invalid --build-mode '$2'. Use 'normal' or 'ram'.${NC}" >&2 + exit 1 + ;; + esac + shift 2 + ;; --site-title) export SITE_TITLE="$2" shift 2 diff --git a/scripts/build/config_loader.sh b/scripts/build/config_loader.sh index 233741f..244c6b2 100755 --- a/scripts/build/config_loader.sh +++ b/scripts/build/config_loader.sh @@ -24,6 +24,11 @@ SITE_DESCRIPTION="${SITE_DESCRIPTION:-A personal journal and introspective newsp SITE_URL="${SITE_URL:-http://localhost}" AUTHOR_NAME="${AUTHOR_NAME:-Anonymous}" AUTHOR_EMAIL="${AUTHOR_EMAIL:-anonymous@example.com}" +REL_ME_URL="${REL_ME_URL:-}" +REL_ME_URLS_SERIALIZED="${REL_ME_URLS_SERIALIZED:-}" +FEDIVERSE_CREATOR="${FEDIVERSE_CREATOR:-}" +AUTHOR_FEDIVERSE_CREATORS_SERIALIZED="${AUTHOR_FEDIVERSE_CREATORS_SERIALIZED:-}" +SITE_FEDIVERSE_CREATOR_META_TAG="${SITE_FEDIVERSE_CREATOR_META_TAG:-}" DATE_FORMAT="${DATE_FORMAT:-%Y-%m-%d %H:%M:%S}" TIMEZONE="${TIMEZONE:-local}" SHOW_TIMEZONE="${SHOW_TIMEZONE:-false}" @@ -31,8 +36,10 @@ POSTS_PER_PAGE="${POSTS_PER_PAGE:-10}" RSS_ITEM_LIMIT="${RSS_ITEM_LIMIT:-15}" # Default RSS item limit RSS_INCLUDE_FULL_CONTENT="${RSS_INCLUDE_FULL_CONTENT:-false}" # Default RSS full content RSS_FILENAME="${RSS_FILENAME:-rss.xml}" # Default RSS filename +INDEX_SHOW_FULL_CONTENT="${INDEX_SHOW_FULL_CONTENT:-false}" # Default: show excerpt on homepage CLEAN_OUTPUT="${CLEAN_OUTPUT:-false}" FORCE_REBUILD="${FORCE_REBUILD:-false}" +BUILD_MODE="${BUILD_MODE:-normal}" # Build mode: normal or ram SITE_LANG="${SITE_LANG:-en}" LOCALE_DIR="${LOCALE_DIR:-locales}" PAGES_DIR="${PAGES_DIR:-pages}" @@ -61,11 +68,19 @@ BSSG_SERVER_HOST_DEFAULT="${BSSG_SERVER_HOST_DEFAULT:-localhost}" CUSTOM_CSS="${CUSTOM_CSS:-}" # Default to empty string # Define default colors here so utils.sh can use them if not overridden by config -RED="${RED:-$(tput setaf 1)}" -GREEN="${GREEN:-$(tput setaf 2)}" -YELLOW="${YELLOW:-$(tput setaf 3)}" -BLUE="${BLUE:-$(tput setaf 4)}" # Added Blue for print_info, using tput -NC="${NC:-$(tput sgr0)}" # No Color, using tput +if [[ -t 1 ]] && command -v tput > /dev/null 2>&1 && tput setaf 1 > /dev/null 2>&1; then + RED="${RED:-$(tput setaf 1)}" + GREEN="${GREEN:-$(tput setaf 2)}" + YELLOW="${YELLOW:-$(tput setaf 3)}" + BLUE="${BLUE:-$(tput setaf 4)}" + NC="${NC:-$(tput sgr0)}" +else + RED="${RED:-}" + GREEN="${GREEN:-}" + YELLOW="${YELLOW:-}" + BLUE="${BLUE:-}" + NC="${NC:-}" +fi # --- Default Configuration Variables --- END --- @@ -208,6 +223,68 @@ done # --- Expand Tilde in Path Variables --- END --- +# --- Derived Configuration --- START --- +serialize_author_fediverse_creators() { + local serialized="" + local author_name + + if ! declare -p AUTHOR_FEDIVERSE_CREATORS >/dev/null 2>&1; then + printf '%s' "$serialized" + return 0 + fi + + if [[ "$(declare -p AUTHOR_FEDIVERSE_CREATORS 2>/dev/null)" != "declare -A"* ]]; then + print_warning "AUTHOR_FEDIVERSE_CREATORS is set but is not an associative array. Ignoring it." + printf '%s' "$serialized" + return 0 + fi + + while IFS= read -r author_name; do + serialized+="${author_name}"$'\t'"${AUTHOR_FEDIVERSE_CREATORS[$author_name]}"$'\n' + done < <(printf '%s\n' "${!AUTHOR_FEDIVERSE_CREATORS[@]}" | LC_ALL=C sort) + + printf '%s' "$serialized" +} + +serialize_rel_me_urls() { + local serialized="" + local rel_me_url="" + + rel_me_url=$(printf '%s' "$REL_ME_URL" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//') + if [ -n "$rel_me_url" ]; then + serialized+="${rel_me_url}"$'\n' + fi + + if declare -p REL_ME_URLS >/dev/null 2>&1; then + local rel_me_decl + rel_me_decl="$(declare -p REL_ME_URLS 2>/dev/null)" + if [[ "$rel_me_decl" == "declare -a"* ]]; then + local rel_me_entry + for rel_me_entry in "${REL_ME_URLS[@]}"; do + rel_me_entry=$(printf '%s' "$rel_me_entry" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//') + if [ -n "$rel_me_entry" ]; then + serialized+="${rel_me_entry}"$'\n' + fi + done + else + print_warning "REL_ME_URLS is set but is not a standard array. Ignoring it." + fi + fi + + if [ -z "$serialized" ]; then + printf '%s' "" + return 0 + fi + + printf '%s' "$serialized" | awk 'NF && !seen[$0]++' +} + +REL_ME_URLS_SERIALIZED="$(serialize_rel_me_urls)" +AUTHOR_FEDIVERSE_CREATORS_SERIALIZED="$(serialize_author_fediverse_creators)" +SITE_FEDIVERSE_CREATOR_META_TAG="$(build_fediverse_creator_meta_tag "${AUTHOR_NAME:-Anonymous}" "")" +# --- Derived Configuration --- END --- + + # --- Export All Variables --- START --- # Define the list of configuration variables relevant for hashing/exporting @@ -215,15 +292,17 @@ done # and that should trigger a cache rebuild if changed. BSSG_CONFIG_VARS_ARRAY=( CONFIG_FILE SRC_DIR OUTPUT_DIR TEMPLATES_DIR THEMES_DIR STATIC_DIR THEME - SITE_TITLE SITE_DESCRIPTION SITE_URL AUTHOR_NAME AUTHOR_EMAIL + SITE_TITLE SITE_DESCRIPTION SITE_URL AUTHOR_NAME AUTHOR_EMAIL REL_ME_URL REL_ME_URLS_SERIALIZED + FEDIVERSE_CREATOR AUTHOR_FEDIVERSE_CREATORS_SERIALIZED SITE_FEDIVERSE_CREATOR_META_TAG DATE_FORMAT TIMEZONE SHOW_TIMEZONE POSTS_PER_PAGE RSS_ITEM_LIMIT RSS_INCLUDE_FULL_CONTENT RSS_FILENAME - CLEAN_OUTPUT FORCE_REBUILD SITE_LANG LOCALE_DIR PAGES_DIR MARKDOWN_PROCESSOR + INDEX_SHOW_FULL_CONTENT + CLEAN_OUTPUT FORCE_REBUILD BUILD_MODE SITE_LANG LOCALE_DIR PAGES_DIR MARKDOWN_PROCESSOR MARKDOWN_PL_PATH ENABLE_ARCHIVES URL_SLUG_FORMAT PAGE_URL_FORMAT DRAFTS_DIR REBUILD_AFTER_POST REBUILD_AFTER_EDIT CUSTOM_CSS ENABLE_TAG_RSS ENABLE_AUTHOR_PAGES ENABLE_AUTHOR_RSS SHOW_AUTHORS_MENU_THRESHOLD - BACKUP_DIR CACHE_DIR - DEPLOY_AFTER_BUILD DEPLOY_SCRIPT + BACKUP_DIR CACHE_DIR + DEPLOY_AFTER_BUILD DEPLOY_SCRIPT ARCHIVES_LIST_ALL_POSTS ENABLE_RELATED_POSTS RELATED_POSTS_COUNT PRECOMPRESS_ASSETS @@ -249,6 +328,11 @@ export SITE_DESCRIPTION export SITE_URL export AUTHOR_NAME export AUTHOR_EMAIL +export REL_ME_URL +export REL_ME_URLS_SERIALIZED +export FEDIVERSE_CREATOR +export AUTHOR_FEDIVERSE_CREATORS_SERIALIZED +export SITE_FEDIVERSE_CREATOR_META_TAG export DATE_FORMAT export TIMEZONE export SHOW_TIMEZONE @@ -256,8 +340,10 @@ export POSTS_PER_PAGE export RSS_ITEM_LIMIT export RSS_INCLUDE_FULL_CONTENT export RSS_FILENAME +export INDEX_SHOW_FULL_CONTENT export CLEAN_OUTPUT export FORCE_REBUILD +export BUILD_MODE export SITE_LANG export LOCALE_DIR export PAGES_DIR @@ -313,4 +399,4 @@ export MSG_MONTH_09 MSG_MONTH_10 MSG_MONTH_11 MSG_MONTH_12 # --- Final Path Adjustments (after all sourcing) --- START --- # Ensure relevant directory paths are exported if not already absolute. # ... existing code ... -# --- Final Path Adjustments (after all sourcing) --- END --- \ No newline at end of file +# --- Final Path Adjustments (after all sourcing) --- END --- diff --git a/scripts/build/content.sh b/scripts/build/content.sh index b2e0b12..458c6e9 100755 --- a/scripts/build/content.sh +++ b/scripts/build/content.sh @@ -14,10 +14,35 @@ source "$(dirname "$0")/utils.sh" || { echo >&2 "Error: Failed to source utils.s parse_metadata() { local file="$1" local field="$2" + local value="" + + # Ignore empty or directory inputs so callers can safely scan optional lists. + if [[ -z "$file" || -d "$file" ]]; then + echo "$value" + return 0 + fi + + # RAM mode: parse directly from preloaded content to avoid disk/cache I/O. + if [ "${BSSG_RAM_MODE:-false}" = true ] && declare -F ram_mode_has_file > /dev/null && ram_mode_has_file "$file"; then + local file_content frontmatter + file_content=$(ram_mode_get_content "$file") + frontmatter=$(printf '%s\n' "$file_content" | awk ' + BEGIN { in_fm = 0; found_fm = 0; } + /^---$/ { + if (!in_fm && !found_fm) { in_fm = 1; found_fm = 1; next; } + if (in_fm) { exit; } + } + in_fm { print; } + ') + if [ -n "$frontmatter" ]; then + value=$(printf '%s\n' "$frontmatter" | grep -m 1 "^$field:[[:space:]]*" | cut -d ':' -f 2- | sed 's/^[[:space:]]*//;s/[[:space:]]*$//') + fi + echo "$value" + return 0 + fi # IMPORTANT: Assumes CACHE_DIR is exported/available local cache_file="${CACHE_DIR:-.bssg_cache}/meta/$(basename "$file")" - local value="" # Get locks for cache access # IMPORTANT: Assumes lock_file/unlock_file are sourced/available @@ -68,11 +93,20 @@ parse_metadata() { # Extract metadata from markdown file (builds cache) extract_metadata() { local file="$1" + if [[ -z "$file" || -d "$file" ]]; then + echo "ERROR_FILE_NOT_FOUND" + return 1 + fi + local metadata_cache_file="${CACHE_DIR:-.bssg_cache}/meta/$(basename "$file")" local frontmatter_changes_marker="${CACHE_DIR:-.bssg_cache}/frontmatter_changes_marker" + local ram_mode_active=false + if [ "${BSSG_RAM_MODE:-false}" = true ] && declare -F ram_mode_has_file > /dev/null && ram_mode_has_file "$file"; then + ram_mode_active=true + fi # Check if file exists - if [ ! -f "$file" ]; then + if ! $ram_mode_active && [ ! -f "$file" ]; then echo "ERROR_FILE_NOT_FOUND" return 1 fi @@ -81,7 +115,7 @@ extract_metadata() { local frontmatter_changed=false # Check if cache exists and is newer than the source file - if [ "${FORCE_REBUILD:-false}" = false ] && [ -f "$metadata_cache_file" ] && [ "$metadata_cache_file" -nt "$file" ]; then + if ! $ram_mode_active && [ "${FORCE_REBUILD:-false}" = false ] && [ -f "$metadata_cache_file" ] && [ "$metadata_cache_file" -nt "$file" ]; then # Read from cache file (optimized - read once) echo "$(cat "$metadata_cache_file")" return 0 @@ -98,25 +132,39 @@ extract_metadata() { # Parse <meta> tags for HTML files # Use grep -m 1 for efficiency, handle missing tags gracefully # Note: This is basic parsing, assumes simple meta tag structure. - title=$(grep -m 1 -o '<title>[^<]*' "$file" 2>/dev/null | sed -e 's///' -e 's/<\/title>//') - date=$(grep -m 1 -o 'name="date" content="[^"]*"' "$file" 2>/dev/null | sed 's/.*content="\([^"]*\)".*/\1/') - lastmod=$(grep -m 1 -o 'name="lastmod" content="[^"]*"' "$file" 2>/dev/null | sed 's/.*content="\([^"]*\)".*/\1/') - tags=$(grep -m 1 -o 'name="tags" content="[^"]*"' "$file" 2>/dev/null | sed 's/.*content="\([^"]*\)".*/\1/') - slug=$(grep -m 1 -o 'name="slug" content="[^"]*"' "$file" 2>/dev/null | sed 's/.*content="\([^"]*\)".*/\1/') - image=$(grep -m 1 -o 'name="image" content="[^"]*"' "$file" 2>/dev/null | sed 's/.*content="\([^"]*\)".*/\1/') - image_caption=$(grep -m 1 -o 'name="image_caption" content="[^"]*"' "$file" 2>/dev/null | sed 's/.*content="\([^"]*\)".*/\1/') - description=$(grep -m 1 -o 'name="description" content="[^"]*"' "$file" 2>/dev/null | sed 's/.*content="\([^"]*\)".*/\1/') - author_name=$(grep -m 1 -o 'name="author_name" content="[^"]*"' "$file" 2>/dev/null | sed 's/.*content="\([^"]*\)".*/\1/') - author_email=$(grep -m 1 -o 'name="author_email" content="[^"]*"' "$file" 2>/dev/null | sed 's/.*content="\([^"]*\)".*/\1/') + local html_source="" + if $ram_mode_active; then + html_source=$(ram_mode_get_content "$file") + title=$(printf '%s\n' "$html_source" | grep -m 1 -o '<title>[^<]*' 2>/dev/null | sed -e 's///' -e 's/<\/title>//') + date=$(printf '%s\n' "$html_source" | grep -m 1 -o 'name="date" content="[^"]*"' 2>/dev/null | sed 's/.*content="\([^"]*\)".*/\1/') + lastmod=$(printf '%s\n' "$html_source" | grep -m 1 -o 'name="lastmod" content="[^"]*"' 2>/dev/null | sed 's/.*content="\([^"]*\)".*/\1/') + tags=$(printf '%s\n' "$html_source" | grep -m 1 -o 'name="tags" content="[^"]*"' 2>/dev/null | sed 's/.*content="\([^"]*\)".*/\1/') + slug=$(printf '%s\n' "$html_source" | grep -m 1 -o 'name="slug" content="[^"]*"' 2>/dev/null | sed 's/.*content="\([^"]*\)".*/\1/') + image=$(printf '%s\n' "$html_source" | grep -m 1 -o 'name="image" content="[^"]*"' 2>/dev/null | sed 's/.*content="\([^"]*\)".*/\1/') + image_caption=$(printf '%s\n' "$html_source" | grep -m 1 -o 'name="image_caption" content="[^"]*"' 2>/dev/null | sed 's/.*content="\([^"]*\)".*/\1/') + description=$(printf '%s\n' "$html_source" | grep -m 1 -o 'name="description" content="[^"]*"' 2>/dev/null | sed 's/.*content="\([^"]*\)".*/\1/') + author_name=$(printf '%s\n' "$html_source" | grep -m 1 -o 'name="author_name" content="[^"]*"' 2>/dev/null | sed 's/.*content="\([^"]*\)".*/\1/') + author_email=$(printf '%s\n' "$html_source" | grep -m 1 -o 'name="author_email" content="[^"]*"' 2>/dev/null | sed 's/.*content="\([^"]*\)".*/\1/') + else + title=$(grep -m 1 -o '<title>[^<]*' "$file" 2>/dev/null | sed -e 's///' -e 's/<\/title>//') + date=$(grep -m 1 -o 'name="date" content="[^"]*"' "$file" 2>/dev/null | sed 's/.*content="\([^"]*\)".*/\1/') + lastmod=$(grep -m 1 -o 'name="lastmod" content="[^"]*"' "$file" 2>/dev/null | sed 's/.*content="\([^"]*\)".*/\1/') + tags=$(grep -m 1 -o 'name="tags" content="[^"]*"' "$file" 2>/dev/null | sed 's/.*content="\([^"]*\)".*/\1/') + slug=$(grep -m 1 -o 'name="slug" content="[^"]*"' "$file" 2>/dev/null | sed 's/.*content="\([^"]*\)".*/\1/') + image=$(grep -m 1 -o 'name="image" content="[^"]*"' "$file" 2>/dev/null | sed 's/.*content="\([^"]*\)".*/\1/') + image_caption=$(grep -m 1 -o 'name="image_caption" content="[^"]*"' "$file" 2>/dev/null | sed 's/.*content="\([^"]*\)".*/\1/') + description=$(grep -m 1 -o 'name="description" content="[^"]*"' "$file" 2>/dev/null | sed 's/.*content="\([^"]*\)".*/\1/') + author_name=$(grep -m 1 -o 'name="author_name" content="[^"]*"' "$file" 2>/dev/null | sed 's/.*content="\([^"]*\)".*/\1/') + author_email=$(grep -m 1 -o 'name="author_email" content="[^"]*"' "$file" 2>/dev/null | sed 's/.*content="\([^"]*\)".*/\1/') + fi # Note: Excerpt generation (fallback for description) might not work well for HTML elif [[ "$file" == *.md ]]; then # Parse YAML frontmatter for Markdown files - # Use awk with a here document for reliable script passing - - # Run awk and read results + # Use a shared awk parser for both disk and RAM paths. local parsed_data - parsed_data=$(awk -f - "$file" <<'EOF' + local awk_frontmatter_parser + awk_frontmatter_parser=$(cat <<'EOF' BEGIN { in_fm = 0; found_fm = 0; @@ -162,6 +210,12 @@ extract_metadata() { } EOF ) + + if $ram_mode_active; then + parsed_data=$(printf '%s\n' "$(ram_mode_get_content "$file")" | awk "$awk_frontmatter_parser") + else + parsed_data=$(awk "$awk_frontmatter_parser" "$file") + fi IFS='|' read -r title date lastmod tags slug image image_caption description author_name author_email <<< "$parsed_data" @@ -207,7 +261,7 @@ EOF local new_metadata="$title|$date|$lastmod|$tags|$slug|$image|$image_caption|$description|$author_name|$author_email" # Check if there was a previous metadata file and compare - if [ -f "$metadata_cache_file" ]; then + if ! $ram_mode_active && [ -f "$metadata_cache_file" ]; then local old_metadata=$(cat "$metadata_cache_file") if [ "$old_metadata" != "$new_metadata" ]; then frontmatter_changed=true @@ -215,13 +269,15 @@ EOF fi # Store all metadata in one write operation - lock_file "$metadata_cache_file" - mkdir -p "$(dirname "$metadata_cache_file")" - echo "$new_metadata" > "$metadata_cache_file" - unlock_file "$metadata_cache_file" + if ! $ram_mode_active; then + lock_file "$metadata_cache_file" + mkdir -p "$(dirname "$metadata_cache_file")" + echo "$new_metadata" > "$metadata_cache_file" + unlock_file "$metadata_cache_file" + fi # If frontmatter has changed, update the marker file's timestamp - if $frontmatter_changed; then + if ! $ram_mode_active && $frontmatter_changed; then touch "$frontmatter_changes_marker" fi @@ -234,17 +290,30 @@ generate_excerpt() { local file="$1" local max_length="${2:-160}" # Default to 160 characters - # Extract content after frontmatter - local start_line=$(grep -n "^---$" "$file" | head -1 | cut -d: -f1) - local end_line=$(grep -n "^---$" "$file" | head -n 2 | tail -1 | cut -d: -f1) - local raw_content_stream - if [[ -n "$start_line" && -n "$end_line" && $start_line -lt $end_line ]]; then - # Stream content after frontmatter - raw_content_stream=$(tail -n +$((end_line + 1)) "$file") + if [ "${BSSG_RAM_MODE:-false}" = true ] && declare -F ram_mode_has_file > /dev/null && ram_mode_has_file "$file"; then + # Remove frontmatter directly from preloaded content + raw_content_stream=$(printf '%s\n' "$(ram_mode_get_content "$file")" | awk ' + BEGIN { in_fm = 0; found_fm = 0; } + /^---$/ { + if (!in_fm && !found_fm) { in_fm = 1; found_fm = 1; next; } + if (in_fm) { in_fm = 0; next; } + } + { if (!in_fm) print; } + ') else - # No valid frontmatter, stream the whole file - raw_content_stream=$(cat "$file") + # Extract content after frontmatter + local start_line end_line + start_line=$(grep -n "^---$" "$file" | head -1 | cut -d: -f1) + end_line=$(grep -n "^---$" "$file" | head -n 2 | tail -1 | cut -d: -f1) + + if [[ -n "$start_line" && -n "$end_line" && $start_line -lt $end_line ]]; then + # Stream content after frontmatter + raw_content_stream=$(tail -n +$((end_line + 1)) "$file") + else + # No valid frontmatter, stream the whole file + raw_content_stream=$(cat "$file") + fi fi # Sanitize and extract the first non-empty paragraph/line @@ -324,26 +393,19 @@ convert_markdown_to_html() { elif [ "$MARKDOWN_PROCESSOR" = "markdown.pl" ]; then # Preprocess content to handle fenced code blocks for markdown.pl local preprocessed_content="$content" - local temp_file - temp_file=$(mktemp) - # Use printf to avoid issues with content starting with - - printf '%s' "$preprocessed_content" > "$temp_file" - # Handle fenced code blocks (``` and ~~~) -> indented # Requires awk if command -v awk &> /dev/null; then - preprocessed_content=$(awk ' + preprocessed_content=$(printf '%s' "$preprocessed_content" | awk ' BEGIN { in_code = 0; } /^```[a-zA-Z0-9]*$/ || /^~~~[a-zA-Z0-9]*$/ { if (!in_code) { in_code = 1; print ""; next; } } /^```$/ || /^~~~$/ { if (in_code) { in_code = 0; print ""; next; } } { if (in_code) { print " " $0; } else { print $0; } } - ' "$temp_file") - rm "$temp_file" + ') else echo -e "${YELLOW}Warning: awk not found, markdown.pl fenced code block conversion skipped.${NC}" >&2 # Content remains as original if awk fails - preprocessed_content=$(cat "$temp_file") - rm "$temp_file" + preprocessed_content="$content" fi # Ensure MARKDOWN_PL_PATH is set and executable @@ -366,4 +428,4 @@ convert_markdown_to_html() { return 0 } -# --- Content Functions --- END --- \ No newline at end of file +# --- Content Functions --- END --- diff --git a/scripts/build/deps.sh b/scripts/build/deps.sh old mode 100755 new mode 100644 index 7166ad7..4968a9f --- a/scripts/build/deps.sh +++ b/scripts/build/deps.sh @@ -96,7 +96,7 @@ check_dependencies() { if [[ "$(uname)" == "NetBSD" ]]; then echo -e "${YELLOW}Parallel processing is unreliable on NetBSD. Using sequential processing.${NC}" export HAS_PARALLEL=false - elif command -v parallel > /dev/null 2>&1; then + elif command -v parallel > /dev/null 2>&1 && { read -r _version < <(parallel -V 2>/dev/null ) && [[ "${_version:0:3}" = "GNU" ]]; }; then echo -e "${GREEN}GNU parallel found! Using parallel processing.${NC}" export HAS_PARALLEL=true else diff --git a/scripts/build/generate_archives.sh b/scripts/build/generate_archives.sh index 701ea41..7cfe8f0 100755 --- a/scripts/build/generate_archives.sh +++ b/scripts/build/generate_archives.sh @@ -14,6 +14,315 @@ source "$(dirname "$0")/cache.sh" || { echo >&2 "Error: Failed to source cache.s # Helper Functions for Archive Generation # ============================================================================== +_generate_ram_year_archive_page() { + local year="$1" + [ -z "$year" ] && return 0 + + local year_index_page="$OUTPUT_DIR/archives/$year/index.html" + mkdir -p "$(dirname "$year_index_page")" + + local year_header="$HEADER_TEMPLATE" + local year_footer="$FOOTER_TEMPLATE" + local year_page_title="${MSG_ARCHIVES_FOR:-"Archives for"} $year" + local year_archive_rel_url="/archives/$year/" + year_header=${year_header//\{\{site_title\}\}/"$SITE_TITLE"} + year_header=${year_header//\{\{page_title\}\}/"$year_page_title"} + year_header=${year_header//\{\{site_description\}\}/"$SITE_DESCRIPTION"} + year_header=${year_header//\{\{og_description\}\}/"$SITE_DESCRIPTION"} + year_header=${year_header//\{\{twitter_description\}\}/"$SITE_DESCRIPTION"} + year_header=${year_header//\{\{og_type\}\}/"website"} + year_header=${year_header//\{\{page_url\}\}/"$year_archive_rel_url"} + year_header=${year_header//\{\{site_url\}\}/"$SITE_URL"} + year_header=${year_header//\{\{og_image\}\}/""} + year_header=${year_header//\{\{twitter_image\}\}/""} + year_header=${year_header//\{\{fediverse_creator_meta\}\}/"${SITE_FEDIVERSE_CREATOR_META_TAG}"} + local year_schema_json + year_schema_json='<script type="application/ld+json">{"@context": "https://schema.org","@type": "CollectionPage","name": "'"$year_page_title"'","description": "Archive of posts from '"$year"'","url": "'"$SITE_URL$year_archive_rel_url"'","isPartOf": {"@type": "WebSite","name": "'"$SITE_TITLE"'","url": "'"$SITE_URL"'"}}</script>' + year_header=${year_header//\{\{schema_json_ld\}\}/"$year_schema_json"} + year_footer=${year_footer//\{\{current_year\}\}/$(date +%Y)} + year_footer=${year_footer//\{\{author_name\}\}/"$AUTHOR_NAME"} + + { + echo "$year_header" + echo "<h1>$year_page_title</h1>" + echo "<ul class=\"month-list\">" + local month_key + for month_key in $(printf '%s\n' "${!month_posts[@]}" | awk -F'|' -v y="$year" '$1 == y { print $0 }' | sort -t'|' -k2,2nr); do + local month_num="${month_key#*|}" + local month_name="${month_name_map[$month_key]}" + local month_post_count + month_post_count=$(printf '%s\n' "${month_posts[$month_key]}" | awk 'NF { c++ } END { print c+0 }') + local month_idx_formatted + month_idx_formatted=$(printf "%02d" "$((10#$month_num))") + local month_var_name="MSG_MONTH_${month_idx_formatted}" + local current_month_name="${!month_var_name:-$month_name}" + local month_url + month_url=$(fix_url "/archives/$year/$month_idx_formatted/") + echo "<li><a href=\"$month_url\">$current_month_name ($month_post_count)</a></li>" + done + echo "</ul>" + echo "$year_footer" + } > "$year_index_page" +} + +_generate_ram_month_archive_page() { + local month_key="$1" + [ -z "$month_key" ] && return 0 + + local year="${month_key%|*}" + local month_num="${month_key#*|}" + local month_idx_formatted + month_idx_formatted=$(printf "%02d" "$((10#$month_num))") + local month_index_page="$OUTPUT_DIR/archives/$year/$month_idx_formatted/index.html" + mkdir -p "$(dirname "$month_index_page")" + + local month_name_var="MSG_MONTH_${month_idx_formatted}" + local month_name="${!month_name_var:-${month_name_map[$month_key]}}" + [ -z "$month_name" ] && month_name="Month $month_idx_formatted" + + local month_header="$HEADER_TEMPLATE" + local month_footer="$FOOTER_TEMPLATE" + local month_page_title="${MSG_ARCHIVES_FOR:-"Archives for"} $month_name $year" + local month_archive_rel_url="/archives/$year/$month_idx_formatted/" + month_header=${month_header//\{\{site_title\}\}/"$SITE_TITLE"} + month_header=${month_header//\{\{page_title\}\}/"$month_page_title"} + month_header=${month_header//\{\{site_description\}\}/"$SITE_DESCRIPTION"} + month_header=${month_header//\{\{og_description\}\}/"$SITE_DESCRIPTION"} + month_header=${month_header//\{\{twitter_description\}\}/"$SITE_DESCRIPTION"} + month_header=${month_header//\{\{og_type\}\}/"website"} + month_header=${month_header//\{\{page_url\}\}/"$month_archive_rel_url"} + month_header=${month_header//\{\{site_url\}\}/"$SITE_URL"} + month_header=${month_header//\{\{og_image\}\}/""} + month_header=${month_header//\{\{twitter_image\}\}/""} + month_header=${month_header//\{\{fediverse_creator_meta\}\}/"${SITE_FEDIVERSE_CREATOR_META_TAG}"} + local month_schema_json + month_schema_json='<script type="application/ld+json">{"@context": "https://schema.org","@type": "CollectionPage","name": "'"$month_page_title"'","description": "Archive of posts from '"$month_name $year"'","url": "'"$SITE_URL$month_archive_rel_url"'","isPartOf": {"@type": "WebSite","name": "'"$SITE_TITLE"'","url": "'"$SITE_URL"'"}}</script>' + month_header=${month_header//\{\{schema_json_ld\}\}/"$month_schema_json"} + month_footer=${month_footer//\{\{current_year\}\}/$(date +%Y)} + month_footer=${month_footer//\{\{author_name\}\}/"$AUTHOR_NAME"} + + { + echo "$month_header" + echo "<h1>$month_page_title</h1>" + echo "<div class=\"posts-list\">" + while IFS='|' read -r _ _ _ title date lastmod filename slug image image_caption description author_name author_email; do + [ -z "$title" ] && continue + local post_year post_month post_day + if [[ "$date" =~ ^([0-9]{4})-([0-9]{1,2})-([0-9]{1,2}) ]]; then + post_year="${BASH_REMATCH[1]}" + post_month=$(printf "%02d" "$((10#${BASH_REMATCH[2]}))") + post_day=$(printf "%02d" "$((10#${BASH_REMATCH[3]}))") + else + post_year=$(date +%Y); post_month=$(date +%m); post_day=$(date +%d) + fi + local url_path="${URL_SLUG_FORMAT:-Year/Month/Day/slug}" + url_path="${url_path//Year/$post_year}" + url_path="${url_path//Month/$post_month}" + url_path="${url_path//Day/$post_day}" + url_path="${url_path//slug/$slug}" + local post_url="/$(echo "$url_path" | sed 's|^/||; s|/*$|/|')" + post_url="${SITE_URL}${post_url}" + + local display_date_format="$DATE_FORMAT" + if [ "${SHOW_TIMEZONE:-false}" = false ]; then + display_date_format=$(echo "$display_date_format" | sed -e 's/%[zZ]//g' -e 's/[[:space:]]*$//') + fi + local formatted_date + formatted_date=$(format_date "$date" "$display_date_format") + local display_author_name="${author_name:-${AUTHOR_NAME:-Anonymous}}" + + cat << EOF + <article> + <h2><a href="${post_url}">$title</a></h2> + <div class="meta">${MSG_PUBLISHED_ON:-\"Published on\"} $formatted_date ${MSG_BY:-\"by\"} <strong>$display_author_name</strong></div> +EOF + if [ -n "$image" ]; then + local image_url + image_url=$(fix_url "$image") + local alt_text="${image_caption:-$title}" + local figcaption_content="${image_caption:-$title}" + cat << EOF + <figure class="featured-image tag-image"> + <a href="${post_url}"> + <img src="$image_url" alt="$alt_text" /> + </a> + <figcaption>$figcaption_content</figcaption> + </figure> +EOF + fi + if [ -n "$description" ]; then + cat << EOF + <div class="summary"> + $description + </div> +EOF + fi + cat << EOF + </article> +EOF + done < <(printf '%s\n' "${month_posts[$month_key]}" | awk 'NF' | sort -t'|' -k5,5r) + echo "</div>" + echo "$month_footer" + } > "$month_index_page" +} + +_generate_archive_pages_ram() { + echo -e "${YELLOW}Processing archive pages...${NC}" + + local archive_index_data + archive_index_data=$(ram_mode_get_dataset "archive_index") + if [ -z "$archive_index_data" ]; then + echo -e "${YELLOW}Warning: No archive index data in RAM. Skipping archive generation.${NC}" + return 0 + fi + + declare -A month_posts=() + declare -A month_name_map=() + declare -A year_map=() + + local line + while IFS= read -r line; do + [ -z "$line" ] && continue + local year month month_name + IFS='|' read -r year month month_name _ <<< "$line" + [ -z "$year" ] && continue + [ -z "$month" ] && continue + local month_key="${year}|${month}" + month_posts["$month_key"]+="$line"$'\n' + month_name_map["$month_key"]="$month_name" + year_map["$year"]=1 + done <<< "$archive_index_data" + + local header_content="$HEADER_TEMPLATE" + local footer_content="$FOOTER_TEMPLATE" + header_content=${header_content//\{\{site_title\}\}/"$SITE_TITLE"} + header_content=${header_content//\{\{page_title\}\}/"${MSG_ARCHIVES:-"Archives"}"} + header_content=${header_content//\{\{site_description\}\}/"$SITE_DESCRIPTION"} + header_content=${header_content//\{\{og_description\}\}/"$SITE_DESCRIPTION"} + header_content=${header_content//\{\{twitter_description\}\}/"$SITE_DESCRIPTION"} + header_content=${header_content//\{\{og_type\}\}/"website"} + header_content=${header_content//\{\{page_url\}\}/"/archives/"} + header_content=${header_content//\{\{site_url\}\}/"$SITE_URL"} + header_content=${header_content//\{\{og_image\}\}/""} + header_content=${header_content//\{\{twitter_image\}\}/""} + header_content=${header_content//\{\{fediverse_creator_meta\}\}/"${SITE_FEDIVERSE_CREATOR_META_TAG}"} + local schema_json_ld + schema_json_ld='<script type="application/ld+json">{"@context": "https://schema.org","@type": "CollectionPage","name": "Archives","description": "'"$SITE_DESCRIPTION"'","url": "'"$SITE_URL"'/archives/","isPartOf": {"@type": "WebSite","name": "'"$SITE_TITLE"'","url": "'"$SITE_URL"'"}}</script>' + header_content=${header_content//\{\{schema_json_ld\}\}/"$schema_json_ld"} + footer_content=${footer_content//\{\{current_year\}\}/$(date +%Y)} + footer_content=${footer_content//\{\{author_name\}\}/"$AUTHOR_NAME"} + + local archives_index_page="$OUTPUT_DIR/archives/index.html" + mkdir -p "$(dirname "$archives_index_page")" + { + echo "$header_content" + echo "<h1>${MSG_ARCHIVES:-"Archives"}</h1>" + echo "<div class=\"archives-list year-list\">" + + local year + for year in $(printf '%s\n' "${!year_map[@]}" | sort -nr); do + [ -z "$year" ] && continue + local year_url + year_url=$(fix_url "/archives/$year/") + echo " <h2><a href=\"$year_url\">$year</a></h2>" + echo " <ul class=\"month-list-detailed\">" + + local month_key + for month_key in $(printf '%s\n' "${!month_posts[@]}" | awk -F'|' -v y="$year" '$1 == y { print $0 }' | sort -t'|' -k2,2nr); do + local month_num="${month_key#*|}" + local month_name="${month_name_map[$month_key]}" + local month_idx_formatted + month_idx_formatted=$(printf "%02d" "$((10#$month_num))") + local month_var_name="MSG_MONTH_${month_idx_formatted}" + local current_month_name="${!month_var_name:-$month_name}" + local month_url + month_url=$(fix_url "/archives/$year/$month_idx_formatted/") + local month_post_count + month_post_count=$(printf '%s\n' "${month_posts[$month_key]}" | awk 'NF { c++ } END { print c+0 }') + + echo " <li>" + echo " <a href=\"$month_url\">$current_month_name ($month_post_count)</a>" + + if [ "${ARCHIVES_LIST_ALL_POSTS:-false}" = true ] && [ "$month_post_count" -gt 0 ]; then + echo " <ul class=\"post-list-condensed-inline\">" + while IFS='|' read -r _ _ _ title date _ filename slug _ _ _ author_name author_email; do + [ -z "$title" ] && continue + local post_year post_month post_day + if [[ "$date" =~ ^([0-9]{4})-([0-9]{1,2})-([0-9]{1,2}) ]]; then + post_year="${BASH_REMATCH[1]}" + post_month=$(printf "%02d" "$((10#${BASH_REMATCH[2]}))") + post_day=$(printf "%02d" "$((10#${BASH_REMATCH[3]}))") + else + post_year=$(date +%Y); post_month=$(date +%m); post_day=$(date +%d) + fi + local url_path="${URL_SLUG_FORMAT:-Year/Month/Day/slug}" + url_path="${url_path//Year/$post_year}" + url_path="${url_path//Month/$post_month}" + url_path="${url_path//Day/$post_day}" + url_path="${url_path//slug/$slug}" + local post_url="/$(echo "$url_path" | sed 's|^/||; s|/*$|/|')" + post_url=$(fix_url "$post_url") + local display_date + display_date=$(echo "$date" | cut -d' ' -f1) + echo " <li><a href=\"$post_url\">[$display_date] $title</a></li>" + done < <(printf '%s\n' "${month_posts[$month_key]}" | awk 'NF' | sort -t'|' -k5,5r) + echo " </ul>" + fi + echo " </li>" + done + echo " </ul>" + done + + echo "</div>" + echo "$footer_content" + } > "$archives_index_page" + + local year_count=${#year_map[@]} + local month_count=${#month_posts[@]} + local year_jobs month_jobs max_workers + max_workers=$(get_parallel_jobs) + year_jobs="$max_workers" + month_jobs="$max_workers" + if [ "$year_jobs" -gt "$year_count" ]; then + year_jobs="$year_count" + fi + if [ "$month_jobs" -gt "$month_count" ]; then + month_jobs="$month_count" + fi + + if [ "$year_jobs" -gt 1 ] && [ "$year_count" -gt 1 ]; then + echo -e "${GREEN}Using shell parallel workers for ${year_count} RAM-mode year archive pages${NC}" + run_parallel "$year_jobs" < <( + while IFS= read -r year; do + [ -z "$year" ] && continue + printf "_generate_ram_year_archive_page '%s'\n" "$year" + done < <(printf '%s\n' "${!year_map[@]}" | sort -nr) + ) || return 1 + else + local year + for year in $(printf '%s\n' "${!year_map[@]}" | sort -nr); do + _generate_ram_year_archive_page "$year" + done + fi + + if [ "$month_jobs" -gt 1 ] && [ "$month_count" -gt 1 ]; then + echo -e "${GREEN}Using shell parallel workers for ${month_count} RAM-mode monthly archive pages${NC}" + run_parallel "$month_jobs" < <( + while IFS= read -r month_key; do + [ -z "$month_key" ] && continue + printf "_generate_ram_month_archive_page '%s'\n" "$month_key" + done < <(printf '%s\n' "${!month_posts[@]}" | sort -t'|' -k1,1nr -k2,2nr) + ) || return 1 + else + local month_key + for month_key in $(printf '%s\n' "${!month_posts[@]}" | sort -t'|' -k1,1nr -k2,2nr); do + _generate_ram_month_archive_page "$month_key" + done + fi + + echo -e "${GREEN}Archive page processing complete.${NC}" +} + # Check if the main archive index page needs rebuilding _check_archive_index_rebuild_needed() { local archive_index_file="$CACHE_DIR/archive_index.txt" @@ -93,10 +402,11 @@ _generate_main_archive_index() { header_content=${header_content//\{\{og_description\}\}/"$SITE_DESCRIPTION"} header_content=${header_content//\{\{twitter_description\}\}/"$SITE_DESCRIPTION"} header_content=${header_content//\{\{og_type\}\}/"website"} - header_content=${header_content//\{\{page_url\}\}/"archives/"} # Relative URL for header placeholder + header_content=${header_content//\{\{page_url\}\}/"/archives/"} # Relative URL for header placeholder header_content=${header_content//\{\{site_url\}\}/"$SITE_URL"} header_content=${header_content//\{\{og_image\}\}/""} header_content=${header_content//\{\{twitter_image\}\}/""} + header_content=${header_content//\{\{fediverse_creator_meta\}\}/"${SITE_FEDIVERSE_CREATOR_META_TAG}"} # Add schema local schema_json_ld='<script type="application/ld+json">{"@context": "https://schema.org","@type": "CollectionPage","name": "Archives","description": "'"$SITE_DESCRIPTION"'","url": "'"$SITE_URL"'/archives/","isPartOf": {"@type": "WebSite","name": "'"$SITE_TITLE"'","url": "'"$SITE_URL"'"}}</script>' header_content=${header_content//\{\{schema_json_ld\}\}/"$schema_json_ld"} @@ -231,6 +541,7 @@ _generate_year_index() { header_content=${header_content//\{\{site_url\}\}/"$SITE_URL"} header_content=${header_content//\{\{og_image\}\}/""} header_content=${header_content//\{\{twitter_image\}\}/""} + header_content=${header_content//\{\{fediverse_creator_meta\}\}/"${SITE_FEDIVERSE_CREATOR_META_TAG}"} # Add schema local schema_json_ld='<script type="application/ld+json">{"@context": "https://schema.org","@type": "CollectionPage","name": "'"$year_page_title"'","description": "Archive of posts from '"$year"'","url": "'"$SITE_URL$year_archive_rel_url"'","isPartOf": {"@type": "WebSite","name": "'"$SITE_TITLE"'","url": "'"$SITE_URL"'"}}</script>' header_content=${header_content//\{\{schema_json_ld\}\}/"$schema_json_ld"} @@ -307,7 +618,7 @@ process_single_month() { # Generate header local header_content="$HEADER_TEMPLATE" - local month_page_title="${MSG_ARCHIVES_FOR:-\"Archives for\"} $month_name $year" + local month_page_title="${MSG_ARCHIVES_FOR:-"Archives for"} $month_name $year" local month_archive_rel_url="/archives/$year/$month_num/" header_content=${header_content//\{\{site_title\}\}/"$SITE_TITLE"} header_content=${header_content//\{\{page_title\}\}/"$month_page_title"} @@ -319,6 +630,7 @@ process_single_month() { header_content=${header_content//\{\{site_url\}\}/"$SITE_URL"} header_content=${header_content//\{\{og_image\}\}/""} header_content=${header_content//\{\{twitter_image\}\}/""} + header_content=${header_content//\{\{fediverse_creator_meta\}\}/"${SITE_FEDIVERSE_CREATOR_META_TAG}"} # Add schema local schema_json_ld='<script type="application/ld+json">{"@context": "https://schema.org","@type": "CollectionPage","name": "'"$month_page_title"'","description": "Archive of posts from '"$month_name $year"'","url": "'"$SITE_URL$month_archive_rel_url"'","isPartOf": {"@type": "WebSite","name": "'"$SITE_TITLE"'","url": "'"$SITE_URL"'"}}</script>' header_content=${header_content//\{\{schema_json_ld\}\}/"$schema_json_ld"} @@ -373,7 +685,7 @@ process_single_month() { # Use cat heredoc for multi-line article structure cat << EOF <article> - <h3><a href="${post_url}">$title</a></h3> + <h2><a href="${post_url}">$title</a></h2> <div class="meta">${MSG_PUBLISHED_ON:-\"Published on\"} $formatted_date ${MSG_BY:-\"by\"} <strong>$display_author_name</strong></div> EOF @@ -432,6 +744,11 @@ _process_single_month_parallel_wrapper() { # Main Archive Generation Orchestrator # ============================================================================== generate_archive_pages() { + if [ "${BSSG_RAM_MODE:-false}" = true ]; then + _generate_archive_pages_ram + return $? + fi + echo -e "${YELLOW}Processing archive pages...${NC}" local archive_index_file="$CACHE_DIR/archive_index.txt" @@ -549,4 +866,4 @@ generate_archive_pages() { } # Make the function available for sourcing -export -f generate_archive_pages \ No newline at end of file +export -f generate_archive_pages diff --git a/scripts/build/generate_authors.sh b/scripts/build/generate_authors.sh index 9c1b649..e8c87c8 100644 --- a/scripts/build/generate_authors.sh +++ b/scripts/build/generate_authors.sh @@ -13,8 +13,206 @@ source "$(dirname "$0")/cache.sh" || { echo >&2 "Error: Failed to source cache.s # shellcheck source=generate_feeds.sh disable=SC1091 source "$(dirname "$0")/generate_feeds.sh" || { echo >&2 "Error: Failed to source generate_feeds.sh from generate_authors.sh"; exit 1; } +_generate_author_pages_ram() { + echo -e "${YELLOW}Processing author pages${NC}${ENABLE_AUTHOR_RSS:+" and RSS feeds"}...${NC}" + + local authors_index_data + authors_index_data=$(ram_mode_get_dataset "authors_index") + local main_authors_index_output="$OUTPUT_DIR/authors/index.html" + + mkdir -p "$OUTPUT_DIR/authors" + + if [ -z "$authors_index_data" ]; then + echo -e "${YELLOW}No authors found in RAM index. Skipping author page generation.${NC}" + return 0 + fi + + declare -A author_posts_by_slug=() + declare -A author_name_by_slug=() + declare -A author_email_by_slug=() + local line author author_slug author_email + while IFS= read -r line; do + [ -z "$line" ] && continue + IFS='|' read -r author author_slug author_email _ <<< "$line" + [ -z "$author" ] && continue + [ -z "$author_slug" ] && continue + if [[ -z "${author_name_by_slug[$author_slug]+_}" ]]; then + author_name_by_slug["$author_slug"]="$author" + author_email_by_slug["$author_slug"]="$author_email" + fi + author_posts_by_slug["$author_slug"]+="$line"$'\n' + done <<< "$authors_index_data" + + local author_slug_key + for author_slug_key in $(printf '%s\n' "${!author_name_by_slug[@]}" | sort); do + author="${author_name_by_slug[$author_slug_key]}" + local author_data="${author_posts_by_slug[$author_slug_key]}" + local author_page_html_file="$OUTPUT_DIR/authors/$author_slug_key/index.html" + local author_rss_file="$OUTPUT_DIR/authors/$author_slug_key/${RSS_FILENAME:-rss.xml}" + local author_page_rel_url="/authors/${author_slug_key}/" + local author_rss_rel_url="/authors/${author_slug_key}/${RSS_FILENAME:-rss.xml}" + local post_count + post_count=$(printf '%s\n' "$author_data" | awk 'NF { c++ } END { print c+0 }') + + mkdir -p "$(dirname "$author_page_html_file")" + + local author_page_content="" + author_page_content+="<h1>${MSG_POSTS_BY:-Posts by} $author</h1>"$'\n' + if [ "${ENABLE_AUTHOR_RSS:-false}" = true ]; then + author_page_content+="<p><a href=\"$author_rss_rel_url\">${MSG_RSS_FEED:-RSS Feed}</a></p>"$'\n' + fi + author_page_content+="<div class=\"posts-list\">"$'\n' + + while IFS='|' read -r author_name_inner author_slug_inner author_email_inner post_title post_date post_lastmod post_filename post_slug post_image post_image_caption post_description; do + [ -z "$post_title" ] && continue + + local post_url + if [ -n "$post_date" ] && [[ "$post_date" =~ ^([0-9]{4})-([0-9]{1,2})-([0-9]{1,2}) ]]; then + local year month day url_path + year="${BASH_REMATCH[1]}" + month=$(printf "%02d" "$((10#${BASH_REMATCH[2]}))") + day=$(printf "%02d" "$((10#${BASH_REMATCH[3]}))") + url_path="${URL_SLUG_FORMAT:-Year/Month/Day/slug}" + url_path="${url_path//Year/$year}" + url_path="${url_path//Month/$month}" + url_path="${url_path//Day/$day}" + url_path="${url_path//slug/$post_slug}" + post_url="/$(echo "$url_path" | sed 's|^/||; s|/*$|/|')" + else + post_url="/$(echo "$post_slug" | sed 's|^/||; s|/*$|/|')" + fi + post_url="${BASE_URL}${post_url}" + local formatted_date + formatted_date=$(format_date "$post_date") + + author_page_content+="<article>"$'\n' + author_page_content+=" <h2><a href=\"$post_url\">$post_title</a></h2>"$'\n' + author_page_content+=" <div class=\"meta\">"$'\n' + author_page_content+=" <time datetime=\"$post_date\">$formatted_date</time>"$'\n' + author_page_content+=" </div>"$'\n' + if [ -n "$post_description" ]; then + author_page_content+=" <p class=\"summary\">$post_description</p>"$'\n' + fi + if [ -n "$post_image" ]; then + author_page_content+=" <div class=\"author-image\">"$'\n' + author_page_content+=" <img src=\"$post_image\" alt=\"$post_image_caption\" loading=\"lazy\">"$'\n' + author_page_content+=" </div>"$'\n' + fi + author_page_content+="</article>"$'\n' + done < <(printf '%s\n' "$author_data" | awk 'NF' | sort -t'|' -k5,5r) + + author_page_content+="</div>"$'\n' + + local page_title="${MSG_POSTS_BY:-Posts by} $author" + local page_description="${MSG_POSTS_BY:-Posts by} $author - $post_count ${MSG_POSTS:-posts}" + local header_content="$HEADER_TEMPLATE" + local footer_content="$FOOTER_TEMPLATE" + header_content=${header_content//\{\{site_title\}\}/"$SITE_TITLE"} + header_content=${header_content//\{\{page_title\}\}/"$page_title"} + header_content=${header_content//\{\{site_description\}\}/"$SITE_DESCRIPTION"} + header_content=${header_content//\{\{og_description\}\}/"$page_description"} + header_content=${header_content//\{\{twitter_description\}\}/"$page_description"} + header_content=${header_content//\{\{og_type\}\}/"website"} + header_content=${header_content//\{\{page_url\}\}/"$author_page_rel_url"} + header_content=${header_content//\{\{site_url\}\}/"$SITE_URL"} + header_content=${header_content//\{\{og_image\}\}/} + header_content=${header_content//\{\{twitter_image\}\}/} + header_content=${header_content//\{\{fediverse_creator_meta\}\}/"${SITE_FEDIVERSE_CREATOR_META_TAG}"} + header_content=${header_content//<!-- bssg:tag_rss_link -->/} + if [ "${ENABLE_AUTHOR_RSS:-false}" = true ]; then + local author_rss_link="<link rel=\"alternate\" type=\"application/rss+xml\" title=\"$author RSS Feed\" href=\"$SITE_URL$author_rss_rel_url\">" + header_content=${header_content//<!-- bssg:tag_rss_link -->/$author_rss_link} + fi + local schema_json + schema_json="{\"@context\": \"https://schema.org\",\"@type\": \"CollectionPage\",\"name\": \"$page_title\",\"description\": \"$page_description\",\"url\": \"$SITE_URL$author_page_rel_url\",\"isPartOf\": {\"@type\": \"WebSite\",\"name\": \"$SITE_TITLE\",\"url\": \"$SITE_URL\"}}" + header_content=${header_content//\{\{schema_json_ld\}\}/"<script type=\"application/ld+json\">$schema_json</script>"} + + local current_year + current_year=$(date +%Y) + footer_content=${footer_content//\{\{current_year\}\}/"$current_year"} + footer_content=${footer_content//\{\{author_name\}\}/"$AUTHOR_NAME"} + footer_content=${footer_content//\{\{all_rights_reserved\}\}/"${MSG_ALL_RIGHTS_RESERVED:-All rights reserved.}"} + + { + echo "$header_content" + echo "$author_page_content" + echo "$footer_content" + } > "$author_page_html_file" + + if [ "${ENABLE_AUTHOR_RSS:-false}" = true ]; then + local author_post_data + author_post_data=$(printf '%s\n' "$author_data" | awk 'NF' | sort -t'|' -k5,5r | awk -F'|' '{ + author_name = $1 + author_email = $3 + title = $4 + date = $5 + lastmod = $6 + filename = $7 + post_slug = $8 + image = $9 + image_caption = $10 + description = $11 + printf "%s|%s|%s|%s|%s||%s|%s|%s|%s|%s|%s\n", filename, filename, title, date, lastmod, post_slug, image, image_caption, description, author_name, author_email + }') + _generate_rss_feed "$author_rss_file" "$SITE_TITLE - ${MSG_POSTS_BY:-Posts by} $author" "${MSG_POSTS_BY:-Posts by} $author" "$author_page_rel_url" "$author_rss_rel_url" "$author_post_data" + fi + done + + local page_title="${MSG_ALL_AUTHORS:-All Authors}" + local page_description="${MSG_ALL_AUTHORS:-All Authors} - $SITE_DESCRIPTION" + local header_content="$HEADER_TEMPLATE" + local footer_content="$FOOTER_TEMPLATE" + local main_content="" + header_content=${header_content//\{\{site_title\}\}/"$SITE_TITLE"} + header_content=${header_content//\{\{page_title\}\}/"$page_title"} + header_content=${header_content//\{\{site_description\}\}/"$SITE_DESCRIPTION"} + header_content=${header_content//\{\{og_description\}\}/"$page_description"} + header_content=${header_content//\{\{twitter_description\}\}/"$page_description"} + header_content=${header_content//\{\{og_type\}\}/"website"} + header_content=${header_content//\{\{page_url\}\}/"/authors/"} + header_content=${header_content//\{\{site_url\}\}/"$SITE_URL"} + header_content=${header_content//\{\{og_image\}\}/} + header_content=${header_content//\{\{twitter_image\}\}/} + header_content=${header_content//\{\{fediverse_creator_meta\}\}/"${SITE_FEDIVERSE_CREATOR_META_TAG}"} + header_content=${header_content//<!-- bssg:tag_rss_link -->/} + local schema_json + schema_json="{\"@context\": \"https://schema.org\",\"@type\": \"CollectionPage\",\"name\": \"$page_title\",\"description\": \"List of all authors on $SITE_TITLE\",\"url\": \"$SITE_URL/authors/\",\"isPartOf\": {\"@type\": \"WebSite\",\"name\": \"$SITE_TITLE\",\"url\": \"$SITE_URL\"}}" + header_content=${header_content//\{\{schema_json_ld\}\}/"<script type=\"application/ld+json\">$schema_json</script>"} + local current_year + current_year=$(date +%Y) + footer_content=${footer_content//\{\{current_year\}\}/"$current_year"} + footer_content=${footer_content//\{\{author_name\}\}/"$AUTHOR_NAME"} + footer_content=${footer_content//\{\{all_rights_reserved\}\}/"${MSG_ALL_RIGHTS_RESERVED:-All rights reserved.}"} + + main_content+="<h1>${MSG_ALL_AUTHORS:-All Authors}</h1>"$'\n' + main_content+="<div class=\"tags-list\">"$'\n' + for author_slug_key in $(printf '%s\n' "${!author_name_by_slug[@]}" | sort); do + author="${author_name_by_slug[$author_slug_key]}" + local post_count + post_count=$(printf '%s\n' "${author_posts_by_slug[$author_slug_key]}" | awk 'NF { c++ } END { print c+0 }') + if [ "$post_count" -gt 0 ]; then + main_content+=" <a href=\"$BASE_URL/authors/$author_slug_key/\">$author <span class=\"tag-count\">($post_count)</span></a>"$'\n' + fi + done + main_content+="</div>"$'\n' + + { + echo "$header_content" + echo "$main_content" + echo "$footer_content" + } > "$main_authors_index_output" + + echo -e "${GREEN}Author pages processed!${NC}" + echo -e "${GREEN}Generated author list pages.${NC}" +} + # Generate author pages generate_author_pages() { + if [ "${BSSG_RAM_MODE:-false}" = true ]; then + _generate_author_pages_ram + return $? + fi + echo -e "${YELLOW}Processing author pages${NC}${ENABLE_AUTHOR_RSS:+" and RSS feeds"}...${NC}" local authors_index_file="$CACHE_DIR/authors_index.txt" @@ -166,7 +364,7 @@ generate_author_pages() { if [ -n "$author" ]; then local author_page_html_file="$OUTPUT_DIR/authors/$author_slug/index.html" local author_rss_file="$OUTPUT_DIR/authors/$author_slug/${RSS_FILENAME:-rss.xml}" - local author_page_rel_url="authors/${author_slug}/" + local author_page_rel_url="/authors/${author_slug}/" local author_rss_rel_url="/authors/${author_slug}/${RSS_FILENAME:-rss.xml}" local rebuild_html=false local rebuild_rss=false @@ -300,6 +498,7 @@ generate_author_pages() { # Remove unprocessed image placeholders header_content=${header_content//\{\{og_image\}\}/} header_content=${header_content//\{\{twitter_image\}\}/} + header_content=${header_content//\{\{fediverse_creator_meta\}\}/"${SITE_FEDIVERSE_CREATOR_META_TAG}"} # Remove the placeholder for the tag-specific RSS feed link header_content=${header_content//<!-- bssg:tag_rss_link -->/} @@ -421,7 +620,7 @@ generate_author_pages() { # Generate full HTML page for main authors index local page_title="${MSG_ALL_AUTHORS:-All Authors}" local page_description="${MSG_ALL_AUTHORS:-All Authors} - $SITE_DESCRIPTION" - local authors_index_rel_url="authors/" + local authors_index_rel_url="/authors/" # Process templates with placeholder replacement (following tags generator pattern) local header_content="$HEADER_TEMPLATE" @@ -440,6 +639,7 @@ generate_author_pages() { # Remove unprocessed image placeholders header_content=${header_content//\{\{og_image\}\}/} header_content=${header_content//\{\{twitter_image\}\}/} + header_content=${header_content//\{\{fediverse_creator_meta\}\}/"${SITE_FEDIVERSE_CREATOR_META_TAG}"} # Remove the placeholder for the tag-specific RSS feed link in the main authors index header_content=${header_content//<!-- bssg:tag_rss_link -->/} @@ -473,4 +673,4 @@ generate_author_pages() { echo -e "${GREEN}Author pages processed!${NC}" echo -e "${GREEN}Generated author list pages.${NC}" -} \ No newline at end of file +} diff --git a/scripts/build/generate_feeds.sh b/scripts/build/generate_feeds.sh index 9d13d7c..19035c8 100755 --- a/scripts/build/generate_feeds.sh +++ b/scripts/build/generate_feeds.sh @@ -14,6 +14,180 @@ source "$(dirname "$0")/cache.sh" || { echo >&2 "Error: Failed to source cache.s source "$(dirname "$0")/content.sh" || { echo >&2 "Error: Failed to source content.sh from generate_feeds.sh"; exit 1; } # Note: Needs access to primary_pages and SECONDARY_PAGES which should be exported by templates.sh +declare -gA BSSG_RAM_RSS_FULL_CONTENT_CACHE=() +declare -g BSSG_RAM_RSS_FULL_CONTENT_CACHE_READY=false +declare -gA BSSG_RAM_RSS_PUBDATE_CACHE=() +declare -gA BSSG_RAM_RSS_UPDATED_ISO_CACHE=() +declare -gA BSSG_RAM_RSS_URL_CACHE=() +declare -gA BSSG_RAM_RSS_ITEM_XML_CACHE=() +declare -g BSSG_RAM_RSS_METADATA_CACHE_READY=false + +_normalize_relative_url_path() { + local path="$1" + while [[ "$path" == */ ]]; do + path="${path%/}" + done + path="${path#/}" + if [ -z "$path" ]; then + printf '/' + else + printf '/%s/' "$path" + fi +} + +_ram_strip_frontmatter_for_rss() { + awk ' + BEGIN { in_fm = 0; found_fm = 0; } + /^---$/ { + if (!in_fm && !found_fm) { in_fm = 1; found_fm = 1; next; } + if (in_fm) { in_fm = 0; next; } + } + { if (!in_fm) print; } + ' +} + +_ram_cache_full_content_for_file() { + local file="$1" + local resolved="$file" + + if declare -F ram_mode_resolve_key > /dev/null; then + resolved=$(ram_mode_resolve_key "$file") + fi + + if [[ -z "$resolved" ]]; then + return 1 + fi + + if [[ -n "${BSSG_RAM_RSS_FULL_CONTENT_CACHE[$resolved]+_}" ]]; then + return 0 + fi + + if ! declare -F ram_mode_has_file > /dev/null || ! ram_mode_has_file "$resolved"; then + return 1 + fi + + local raw_content + raw_content=$(ram_mode_get_content "$resolved") + + local stripped_content + stripped_content=$(printf '%s\n' "$raw_content" | _ram_strip_frontmatter_for_rss) + + local converted_html + converted_html=$(convert_markdown_to_html "$stripped_content" "$resolved") + local convert_status=$? + if [ $convert_status -ne 0 ] || [ -z "$converted_html" ]; then + return 1 + fi + + BSSG_RAM_RSS_FULL_CONTENT_CACHE["$resolved"]="$converted_html" + return 0 +} + +prepare_ram_rss_full_content_cache() { + if [ "${BSSG_RAM_MODE:-false}" != true ] || [ "${RSS_INCLUDE_FULL_CONTENT:-false}" != true ]; then + return 0 + fi + + if [ "$BSSG_RAM_RSS_FULL_CONTENT_CACHE_READY" = true ]; then + return 0 + fi + + local file_index_data + file_index_data=$(ram_mode_get_dataset "file_index") + if [ -z "$file_index_data" ]; then + BSSG_RAM_RSS_FULL_CONTENT_CACHE_READY=true + return 0 + fi + + local file filename title date lastmod tags slug image image_caption description author_name author_email + while IFS='|' read -r file filename title date lastmod tags slug image image_caption description author_name author_email; do + [ -z "$file" ] && continue + _ram_cache_full_content_for_file "$file" > /dev/null || true + done <<< "$file_index_data" + + BSSG_RAM_RSS_FULL_CONTENT_CACHE_READY=true +} + +_ram_prime_rss_metadata_entry() { + local date="$1" + local lastmod="$2" + local slug="$3" + local rss_date_fmt="$4" + local build_timestamp_iso="$5" + local source_file="$6" + + if [ -n "$date" ] && [[ -z "${BSSG_RAM_RSS_PUBDATE_CACHE[$date]+_}" ]]; then + BSSG_RAM_RSS_PUBDATE_CACHE["$date"]=$(format_date "$date" "$rss_date_fmt") + fi + + if [ -n "$lastmod" ] && [[ -z "${BSSG_RAM_RSS_UPDATED_ISO_CACHE[$lastmod]+_}" ]]; then + local updated_date_iso + updated_date_iso=$(format_date "$lastmod" "%Y-%m-%dT%H:%M:%S%z") + if [[ "$updated_date_iso" =~ ([+-][0-9]{2})([0-9]{2})$ ]]; then + updated_date_iso="${updated_date_iso::${#updated_date_iso}-2}:${BASH_REMATCH[2]}" + fi + [ -z "$updated_date_iso" ] && updated_date_iso="$build_timestamp_iso" + BSSG_RAM_RSS_UPDATED_ISO_CACHE["$lastmod"]="$updated_date_iso" + fi + + if [ -n "$date" ] && [ -n "$slug" ]; then + local url_key="${date}|${slug}" + if [[ -z "${BSSG_RAM_RSS_URL_CACHE[$url_key]+_}" ]]; then + local year month day formatted_path item_url + if [[ "$date" =~ ^([0-9]{4})-([0-9]{1,2})-([0-9]{1,2}) ]]; then + year="${BASH_REMATCH[1]}" + month=$(printf "%02d" "$((10#${BASH_REMATCH[2]}))") + day=$(printf "%02d" "$((10#${BASH_REMATCH[3]}))") + else + if [ "${RAM_MODE_VERBOSE:-false}" = true ]; then + echo "Warning: Invalid date format '$date' for file $source_file, cannot precompute RSS URL." >&2 + fi + return 1 + fi + formatted_path="${URL_SLUG_FORMAT//Year/$year}" + formatted_path="${formatted_path//Month/$month}" + formatted_path="${formatted_path//Day/$day}" + formatted_path="${formatted_path//slug/$slug}" + item_url=$(_normalize_relative_url_path "$formatted_path") + BSSG_RAM_RSS_URL_CACHE["$url_key"]=$(fix_url "$item_url") + fi + fi + + return 0 +} + +prepare_ram_rss_metadata_cache() { + if [ "${BSSG_RAM_MODE:-false}" != true ]; then + return 0 + fi + + if [ "$BSSG_RAM_RSS_METADATA_CACHE_READY" = true ]; then + return 0 + fi + + local file_index_data + file_index_data=$(ram_mode_get_dataset "file_index") + if [ -z "$file_index_data" ]; then + BSSG_RAM_RSS_METADATA_CACHE_READY=true + return 0 + fi + + local rss_date_fmt="%a, %d %b %Y %H:%M:%S %z" + local build_timestamp_iso + build_timestamp_iso=$(format_date "now" "%Y-%m-%dT%H:%M:%S%z") + if [[ "$build_timestamp_iso" =~ ([+-][0-9]{2})([0-9]{2})$ ]]; then + build_timestamp_iso="${build_timestamp_iso::${#build_timestamp_iso}-2}:${BASH_REMATCH[2]}" + fi + + local file filename title date lastmod tags slug image image_caption description author_name author_email + while IFS='|' read -r file filename title date lastmod tags slug image image_caption description author_name author_email; do + [ -z "$file" ] && continue + _ram_prime_rss_metadata_entry "$date" "$lastmod" "$slug" "$rss_date_fmt" "$build_timestamp_iso" "$file" >/dev/null || true + done <<< "$file_index_data" + + BSSG_RAM_RSS_METADATA_CACHE_READY=true +} + # Function to get the latest lastmod date from a file index, optionally filtered # Usage: get_latest_mod_date <index_file> [field_index] [filter_pattern] [date_format] # Example: get_latest_mod_date "$file_index" 5 "" "%Y-%m-%d" # Latest overall post @@ -53,6 +227,212 @@ get_latest_mod_date() { fi } +# Fast path for RAM datasets: pick max YYYY-MM-DD from a given field without external sort/head. +_ram_latest_date_from_dataset() { + local dataset="$1" + local field_index="$2" + local date_format="${3:-%Y-%m-%d}" + + local latest_date_str + latest_date_str=$(printf '%s\n' "$dataset" | awk -F'|' -v field_index="$field_index" ' + NF { + value = substr($field_index, 1, 10) + if (value != "" && value > max_date) { + max_date = value + } + } + END { + if (max_date != "") { + print max_date + } + } + ') + + if [ -n "$latest_date_str" ]; then + printf '%s\n' "$latest_date_str" + else + format_date "now" "$date_format" + fi +} + +_generate_sitemap_with_awk_inputs() { + local sitemap="$1" + local file_index_input="$2" + local primary_pages_input="$3" + local secondary_pages_input="$4" + local tags_index_input="$5" + local authors_index_input="$6" + local latest_post_mod_date="$7" + local latest_tag_page_mod_date="$8" + local latest_author_page_mod_date="$9" + local sitemap_date_fmt="${10:-%Y-%m-%d}" + + # Determine the best awk command locally to avoid potential scoping issues with AWK_CMD. + local effective_awk_cmd="awk" + if command -v gawk > /dev/null 2>&1; then + effective_awk_cmd="gawk" + fi + + "$effective_awk_cmd" -v site_url="$SITE_URL" \ + -v url_slug_format="$URL_SLUG_FORMAT" \ + -v latest_post_mod_date="$latest_post_mod_date" \ + -v latest_tag_page_mod_date="$latest_tag_page_mod_date" \ + -v latest_author_page_mod_date="$latest_author_page_mod_date" \ + -v enable_author_pages="${ENABLE_AUTHOR_PAGES:-true}" \ + -v sitemap_date_fmt="$sitemap_date_fmt" \ + -F'|' \ + -f - \ + "$file_index_input" "$primary_pages_input" "$secondary_pages_input" "$tags_index_input" "$authors_index_input" <<'AWK_EOF' > "$sitemap" +# AWK script for sitemap generation. +BEGIN { + OFS = "" + print "<?xml version=\"1.0\" encoding=\"UTF-8\"?>" + print "<urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">" + + # Homepage + print " <url>" + print " <loc>" fix_url_awk("/", site_url) "</loc>" + print " <lastmod>" latest_post_mod_date "</lastmod>" + print " <changefreq>daily</changefreq>" + print " <priority>1.0</priority>" + print " </url>" +} + +function fix_url_awk(path, base_url) { + if (substr(path, 1, 1) == "/") { + sub(/\/$/, "", base_url) + sub(/^\/+/, "/", path) + sub(/\/index\.html$/, "/", path) + if (substr(path, length(path), 1) != "/") { + path = path "/" + } + if (base_url == "" || base_url ~ /^http:\/\/localhost(:[0-9]+)?$/) { + return path + } else { + return base_url path + } + } else { + return path + } +} + +# Process file_index (posts). +FILENAME == ARGV[1] { + file = $1 + date = $4 + lastmod = $5 + slug = $7 + if (length(file) == 0 || length(date) == 0 || length(lastmod) == 0 || length(slug) == 0) next + + year = substr(date, 1, 4) + month = substr(date, 6, 2) + day = substr(date, 9, 2) + if (year ~ /^[0-9]{4}$/ && month ~ /^[0-9]{2}$/ && day ~ /^[0-9]{2}$/) { + formatted_path = url_slug_format + gsub(/Year/, year, formatted_path) + gsub(/Month/, month, formatted_path) + gsub(/Day/, day, formatted_path) + gsub(/slug/, slug, formatted_path) + item_url = "/" formatted_path + sub(/\/+$/, "/", item_url) + + mod_time = substr(lastmod, 1, 10) + if (mod_time == "") next + + print " <url>" + print " <loc>" fix_url_awk(item_url, site_url) "</loc>" + print " <lastmod>" mod_time "</lastmod>" + print " <changefreq>weekly</changefreq>" + print " <priority>0.8</priority>" + print " </url>" + } +} + +# Process primary pages. +FILENAME == ARGV[2] { + url = $2 + date = $3 + if (length(url) == 0 || length(date) == 0) next + sitemap_url = url + sub(/index\.html$/, "", sitemap_url) + sub(/\/+$/, "/", sitemap_url) + mod_time = substr(date, 1, 10) + if (mod_time == "") next + print " <url>" + print " <loc>" fix_url_awk(sitemap_url, site_url) "</loc>" + print " <lastmod>" mod_time "</lastmod>" + print " <changefreq>monthly</changefreq>" + print " <priority>0.7</priority>" + print " </url>" +} + +# Process secondary pages. +FILENAME == ARGV[3] { + url = $2 + date = $3 + if (length(url) == 0 || length(date) == 0) next + sitemap_url = url + sub(/index\.html$/, "", sitemap_url) + sub(/\/+$/, "/", sitemap_url) + mod_time = substr(date, 1, 10) + if (mod_time == "") next + print " <url>" + print " <loc>" fix_url_awk(sitemap_url, site_url) "</loc>" + print " <lastmod>" mod_time "</lastmod>" + print " <changefreq>monthly</changefreq>" + print " <priority>0.6</priority>" + print " </url>" +} + +# Process tags index. +FILENAME == ARGV[4] { + tag_slug = $2 + if (length(tag_slug) == 0) next + if (!(tag_slug in processed_tags)) { + processed_tags[tag_slug] = 1 + item_url = "/tags/" tag_slug "/" + print " <url>" + print " <loc>" fix_url_awk(item_url, site_url) "</loc>" + print " <lastmod>" latest_tag_page_mod_date "</lastmod>" + print " <changefreq>weekly</changefreq>" + print " <priority>0.5</priority>" + print " </url>" + } +} + +# Process authors index. +FILENAME == ARGV[5] && enable_author_pages == "true" { + author_slug = $2 + if (length(author_slug) == 0) next + if (!(author_slug in processed_authors)) { + processed_authors[author_slug] = 1 + + if (!authors_index_added) { + authors_index_added = 1 + print " <url>" + print " <loc>" fix_url_awk("/authors/", site_url) "</loc>" + print " <lastmod>" latest_author_page_mod_date "</lastmod>" + print " <changefreq>weekly</changefreq>" + print " <priority>0.6</priority>" + print " </url>" + } + + item_url = "/authors/" author_slug "/" + print " <url>" + print " <loc>" fix_url_awk(item_url, site_url) "</loc>" + print " <lastmod>" latest_author_page_mod_date "</lastmod>" + print " <changefreq>weekly</changefreq>" + print " <priority>0.5</priority>" + print " </url>" + } +} + +END { + print "</urlset>" +} +AWK_EOF +} + # Core RSS generation function # Usage: _generate_rss_feed <output_file> <feed_title> <feed_description> <feed_link_rel> <feed_atom_link_rel> <post_data_input> # <post_data_input> should be a string containing the filtered, sorted, and limited post data, @@ -80,67 +460,95 @@ _generate_rss_feed() { # Ensure output directory exists mkdir -p "$(dirname "$output_file")" - # Create the RSS feed header - cat > "$output_file" << EOF -<?xml version="1.0" encoding="UTF-8" ?> -<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/"> -<channel> - <title>$(html_escape "$feed_title") - $(fix_url "$feed_link_rel") - $(html_escape "$feed_description") - ${SITE_LANG:-en} - $(format_date "now" "$rss_date_fmt") - -EOF + local escaped_feed_title escaped_feed_description feed_link feed_atom_link channel_last_build_date + escaped_feed_title=$(html_escape "$feed_title") + escaped_feed_description=$(html_escape "$feed_description") + feed_link=$(fix_url "$feed_link_rel") + feed_atom_link=$(fix_url "$feed_atom_link_rel") + channel_last_build_date=$(format_date "now" "$rss_date_fmt") + + exec 4> "$output_file" || return 1 + printf '%s\n' \ + '' \ + '' \ + '' \ + " ${escaped_feed_title}" \ + " ${feed_link}" \ + " ${escaped_feed_description}" \ + " ${SITE_LANG:-en}" \ + " ${channel_last_build_date}" \ + " " >&4 # Process the provided post data - echo "$post_data_input" | while IFS='|' read -r file filename title date lastmod tags slug image image_caption description author_name author_email; do + while IFS='|' read -r file filename title date lastmod tags slug image image_caption description author_name author_email; do + # Ignore blank trailing lines from callers. + if [ -z "$file" ] && [ -z "$filename" ] && [ -z "$title" ] && [ -z "$date" ] && [ -z "$lastmod" ] && [ -z "$tags" ] && [ -z "$slug" ] && [ -z "$image" ] && [ -z "$image_caption" ] && [ -z "$description" ] && [ -z "$author_name" ] && [ -z "$author_email" ]; then + continue + fi # Skip if essential fields are missing (robustness) if [ -z "$file" ] || [ -z "$title" ] || [ -z "$date" ] || [ -z "$lastmod" ] || [ -z "$slug" ]; then echo "Warning: Skipping RSS item due to missing fields in input line: file=$file, title=$title, date=$date, lastmod=$lastmod, slug=$slug" >&2 continue fi - # Format dates for RSS - local pub_date=$(format_date "$date" "$rss_date_fmt") - local updated_date_iso=$(format_date "$lastmod" "%Y-%m-%dT%H:%M:%S%z") - # Convert timezone format again if needed - if [[ "$updated_date_iso" =~ ([+-][0-9]{2})([0-9]{2})$ ]]; then - updated_date_iso="${updated_date_iso::${#updated_date_iso}-2}:${BASH_REMATCH[2]}" + local rss_item_cache_key="" + if [ "${BSSG_RAM_MODE:-false}" = true ]; then + rss_item_cache_key="${RSS_INCLUDE_FULL_CONTENT:-false}|${file}|${date}|${lastmod}|${slug}|${title}" + if [[ -n "${BSSG_RAM_RSS_ITEM_XML_CACHE[$rss_item_cache_key]+_}" ]]; then + printf '%s' "${BSSG_RAM_RSS_ITEM_XML_CACHE[$rss_item_cache_key]}" >&4 + continue + fi fi - # Fallback for updated_date_iso - [ -z "$updated_date_iso" ] && updated_date_iso="$build_timestamp_iso" - # Construct post URL based on URL_SLUG_FORMAT - local year month day formatted_path item_url - if [[ "$date" =~ ^([0-9]{4})-([0-9]{1,2})-([0-9]{1,2}) ]]; then - year="${BASH_REMATCH[1]}" - month=$(printf "%02d" "$((10#${BASH_REMATCH[2]}))") - day=$(printf "%02d" "$((10#${BASH_REMATCH[3]}))") + # Format dates and URL (RAM mode caches repeated values across many tag feeds). + local pub_date updated_date_iso full_url + if [ "${BSSG_RAM_MODE:-false}" = true ]; then + _ram_prime_rss_metadata_entry "$date" "$lastmod" "$slug" "$rss_date_fmt" "$build_timestamp_iso" "$file" || { + echo "Warning: Invalid date format '$date' for file $file, cannot generate URL." >&2 + continue + } + pub_date="${BSSG_RAM_RSS_PUBDATE_CACHE[$date]}" + updated_date_iso="${BSSG_RAM_RSS_UPDATED_ISO_CACHE[$lastmod]}" + full_url="${BSSG_RAM_RSS_URL_CACHE[${date}|${slug}]}" else - echo "Warning: Invalid date format '$date' for file $file, cannot generate URL." >&2 - continue # Skip item if URL cannot be generated - fi - formatted_path="${URL_SLUG_FORMAT//Year/$year}" - formatted_path="${formatted_path//Month/$month}" - formatted_path="${formatted_path//Day/$day}" - formatted_path="${formatted_path//slug/$slug}" - item_url="/$(echo "$formatted_path" | sed 's|/*$|/|')" # Ensure trailing slash + pub_date=$(format_date "$date" "$rss_date_fmt") + updated_date_iso=$(format_date "$lastmod" "%Y-%m-%dT%H:%M:%S%z") + if [[ "$updated_date_iso" =~ ([+-][0-9]{2})([0-9]{2})$ ]]; then + updated_date_iso="${updated_date_iso::${#updated_date_iso}-2}:${BASH_REMATCH[2]}" + fi + [ -z "$updated_date_iso" ] && updated_date_iso="$build_timestamp_iso" - local full_url=$(fix_url "$item_url") # Use fix_url to prepend SITE_URL + local year month day formatted_path item_url + if [[ "$date" =~ ^([0-9]{4})-([0-9]{1,2})-([0-9]{1,2}) ]]; then + year="${BASH_REMATCH[1]}" + month=$(printf "%02d" "$((10#${BASH_REMATCH[2]}))") + day=$(printf "%02d" "$((10#${BASH_REMATCH[3]}))") + else + echo "Warning: Invalid date format '$date' for file $file, cannot generate URL." >&2 + continue + fi + formatted_path="${URL_SLUG_FORMAT//Year/$year}" + formatted_path="${formatted_path//Month/$month}" + formatted_path="${formatted_path//Day/$day}" + formatted_path="${formatted_path//slug/$slug}" + item_url=$(_normalize_relative_url_path "$formatted_path") + full_url=$(fix_url "$item_url") + fi # --- RSS Item Description Enhancement --- local item_description_content="" local figure_part="" local caption_part="" local content_part="" + local escaped_title + escaped_title=$(html_escape "$title") # Build figure part if [ -n "$image" ]; then local img_src [[ "$image" =~ ^https?:// ]] && img_src="$image" || img_src=$(fix_url "$image") # Escape alt/title attributes safely using html_escape from utils.sh - local img_alt=$(html_escape "$title") + local img_alt="$escaped_title" local img_title=$(html_escape "$image_caption") [ -z "$img_title" ] && img_title="$img_alt" # Use alt if title is empty @@ -155,8 +563,24 @@ EOF # Build content part (excerpt or full) if [ "${RSS_INCLUDE_FULL_CONTENT:-false}" = true ]; then - local raw_content_cache_file="${CACHE_DIR:-.bssg_cache}/content/$(basename "$file")" - if [ -f "$raw_content_cache_file" ]; then + if [ "${BSSG_RAM_MODE:-false}" = true ]; then + local resolved_file="$file" + if declare -F ram_mode_resolve_key > /dev/null; then + resolved_file=$(ram_mode_resolve_key "$file") + fi + + if _ram_cache_full_content_for_file "$resolved_file"; then + content_part="${BSSG_RAM_RSS_FULL_CONTENT_CACHE[$resolved_file]}" + else + # RAM mode is memory-only: never fall back to disk cache reads. + if [ "${RAM_MODE_VERBOSE:-false}" = true ]; then + echo "Warning: RAM content not available for RSS item ($file). Falling back to excerpt." >&2 + fi + content_part="$description" + fi + else + local raw_content_cache_file="${CACHE_DIR:-.bssg_cache}/content/$(basename "$file")" + if [ -f "$raw_content_cache_file" ]; then local raw_content=$(cat "$raw_content_cache_file") local converted_html=$(convert_markdown_to_html "$raw_content" "$file") local convert_status=$? @@ -166,9 +590,10 @@ EOF echo "Warning: Failed to convert markdown to HTML for RSS item ($file, status: $convert_status). Falling back to excerpt." >&2 content_part="$description" fi - else - echo "Warning: Cached raw markdown content file '$raw_content_cache_file' not found for RSS item ($file). Falling back to excerpt." >&2 - content_part="$description" + else + echo "Warning: Cached raw markdown content file '$raw_content_cache_file' not found for RSS item ($file). Falling back to excerpt." >&2 + content_part="$description" + fi fi else content_part="$description" @@ -194,26 +619,35 @@ EOF fi fi - cat >> "$output_file" << EOF - - $(html_escape "$title") + local rss_item_xml + rss_item_xml=" + ${escaped_title} ${full_url} - ${full_url} + ${full_url} ${pub_date} ${updated_date_iso} ${final_description} -${author_element} - -EOF - done +" + if [ -n "$author_element" ]; then + rss_item_xml+="${author_element}"$'\n' + fi + rss_item_xml+=" +" + + printf '%s' "$rss_item_xml" >&4 + + if [ "${BSSG_RAM_MODE:-false}" = true ]; then + BSSG_RAM_RSS_ITEM_XML_CACHE["$rss_item_cache_key"]="$rss_item_xml" + fi + done <<< "$post_data_input" # Close the RSS feed - cat >> "$output_file" << EOF - - -EOF + printf '%s\n' '' '' >&4 + exec 4>&- - echo -e "${GREEN}RSS feed generated at $output_file${NC}" + if [ "${BSSG_RAM_MODE:-false}" != true ] || [ "${RAM_MODE_VERBOSE:-false}" = true ]; then + echo -e "${GREEN}RSS feed generated at $output_file${NC}" + fi } export -f _generate_rss_feed # Export for potential parallel use or sourcing @@ -221,6 +655,28 @@ export -f _generate_rss_feed # Export for potential parallel use or sourcing generate_rss() { echo -e "${YELLOW}Generating main RSS feed...${NC}" + if [ "${BSSG_RAM_MODE:-false}" = true ]; then + local file_index_data + file_index_data=$(ram_mode_get_dataset "file_index") + if [ -z "$file_index_data" ]; then + echo -e "${YELLOW}No file index data in RAM. Skipping RSS generation.${NC}" + return 0 + fi + + prepare_ram_rss_metadata_cache >/dev/null || true + + local rss="$OUTPUT_DIR/${RSS_FILENAME:-rss.xml}" + local feed_title="${MSG_RSS_FEED_TITLE:-${SITE_TITLE} - RSS Feed}" + local feed_desc="${MSG_RSS_FEED_DESCRIPTION:-${SITE_DESCRIPTION}}" + local feed_link_rel="/" + local feed_atom_link_rel="/${RSS_FILENAME:-rss.xml}" + local rss_item_limit=${RSS_ITEM_LIMIT:-15} + local sorted_posts + sorted_posts=$(printf '%s\n' "$file_index_data" | awk 'NF' | sort -t'|' -k4,4r -k5,5r | head -n "$rss_item_limit") + _generate_rss_feed "$rss" "$feed_title" "$feed_desc" "$feed_link_rel" "$feed_atom_link_rel" "$sorted_posts" + return 0 + fi + # Ensure needed functions/vars are available if ! command -v convert_markdown_to_html &> /dev/null; then echo -e "${RED}Error: convert_markdown_to_html function not found.${NC}" >&2; return 1; fi @@ -296,6 +752,39 @@ export -f generate_rss generate_sitemap() { echo -e "${YELLOW}Generating sitemap.xml...${NC}" + if [ "${BSSG_RAM_MODE:-false}" = true ]; then + local sitemap="$OUTPUT_DIR/sitemap.xml" + local file_index_data tags_index_data authors_index_data primary_pages_data secondary_pages_data + file_index_data=$(ram_mode_get_dataset "file_index") + tags_index_data=$(ram_mode_get_dataset "tags_index") + authors_index_data=$(ram_mode_get_dataset "authors_index") + primary_pages_data=$(ram_mode_get_dataset "primary_pages") + secondary_pages_data=$(ram_mode_get_dataset "secondary_pages") + + local latest_post_mod_date latest_tag_page_mod_date latest_author_page_mod_date + latest_post_mod_date=$(_ram_latest_date_from_dataset "$file_index_data" 5 "%Y-%m-%d") + latest_tag_page_mod_date=$(_ram_latest_date_from_dataset "$tags_index_data" 5 "%Y-%m-%d") + latest_author_page_mod_date=$(_ram_latest_date_from_dataset "$authors_index_data" 6 "%Y-%m-%d") + + [ -z "$latest_tag_page_mod_date" ] && latest_tag_page_mod_date="$latest_post_mod_date" + [ -z "$latest_author_page_mod_date" ] && latest_author_page_mod_date="$latest_post_mod_date" + + _generate_sitemap_with_awk_inputs \ + "$sitemap" \ + <(printf '%s\n' "$file_index_data") \ + <(printf '%s\n' "$primary_pages_data") \ + <(printf '%s\n' "$secondary_pages_data") \ + <(printf '%s\n' "$tags_index_data") \ + <(printf '%s\n' "$authors_index_data") \ + "$latest_post_mod_date" \ + "$latest_tag_page_mod_date" \ + "$latest_author_page_mod_date" \ + "%Y-%m-%d" + + echo -e "${GREEN}Sitemap generated!${NC}" + return 0 + fi + local sitemap="$OUTPUT_DIR/sitemap.xml" local file_index="$CACHE_DIR/file_index.txt" local tags_index="$CACHE_DIR/tags_index.txt" @@ -342,196 +831,23 @@ generate_sitemap() { local latest_tag_page_mod_date=$(get_latest_mod_date "$tags_index" 5 "" "$sitemap_date_fmt") # Assumes lastmod is relevant field in tags_index local latest_author_page_mod_date=$(get_latest_mod_date "$authors_index" 6 "" "$sitemap_date_fmt") # Field 6 is lastmod in authors_index - # --- Generate Sitemap using AWK --- START --- echo "Generating sitemap content using awk..." - - # Determine the best awk command locally to avoid potential scoping issues with AWK_CMD - local effective_awk_cmd="awk" # Default to standard awk - if command -v gawk > /dev/null 2>&1; then - effective_awk_cmd="gawk" # Prefer gawk if available - fi - - # Use awk with a here-doc for the script for cleaner quoting - # Use the locally determined effective_awk_cmd - "$effective_awk_cmd" -v site_url="$SITE_URL" \ - -v url_slug_format="$URL_SLUG_FORMAT" \ - -v latest_post_mod_date="$latest_post_mod_date" \ - -v latest_tag_page_mod_date="$latest_tag_page_mod_date" \ - -v latest_author_page_mod_date="$latest_author_page_mod_date" \ - -v enable_author_pages="${ENABLE_AUTHOR_PAGES:-true}" \ - -v sitemap_date_fmt="$sitemap_date_fmt" \ - -F'|' \ - -f - \ - "$file_index" "$primary_pages_cache" "$secondary_pages_cache" "$tags_index" "$authors_index" <<'AWK_EOF' > "$sitemap" -# AWK script for sitemap generation (fed via here-doc) -BEGIN { - OFS=""; # No output field separator needed for XML - print ""; - print ""; - - # Homepage - print " "; - print " " fix_url_awk("/", site_url) ""; - print " " latest_post_mod_date ""; - print " daily"; - print " 1.0"; - print " "; -} - -# Custom function to replicate fix_url shell function logic -function fix_url_awk(path, base_url) { - if (substr(path, 1, 1) == "/") { - # Remove trailing slash from base_url if present - sub(/\/$/, "", base_url); - # Ensure path doesnt start with // - sub(/^\/+/, "/", path); - # Remove index.html if present - sub(/\/index\.html$/, "/", path); - # Ensure trailing slash - if (substr(path, length(path), 1) != "/") { - path = path "/"; - } - # Handle case where base_url is empty or just http://localhost* - skip prepending - if (base_url == "" || base_url ~ /^http:\/\/localhost(:[0-9]+)?$/) { - return path - } else { - return base_url path; - } - } else { - return path; # Should not happen for sitemap paths? - } -} - -# Process file_index.txt (Posts) -FILENAME == ARGV[1] { - file=$1; filename=$2; title=$3; date=$4; lastmod=$5; tags=$6; slug=$7; - if (length(file) == 0 || length(date) == 0 || length(lastmod) == 0 || length(slug) == 0) next; - - year=substr(date, 1, 4); - month=substr(date, 6, 2); - day=substr(date, 9, 2); - # Ensure valid numbers? Basic check: - if (year ~ /^[0-9]{4}$/ && month ~ /^[0-9]{2}$/ && day ~ /^[0-9]{2}$/) { - formatted_path = url_slug_format; - gsub(/Year/, year, formatted_path); - gsub(/Month/, month, formatted_path); - gsub(/Day/, day, formatted_path); - gsub(/slug/, slug, formatted_path); - item_url = "/" formatted_path; - # Clean URL logic from shell script - sub(/\/+$/, "/", item_url); - - mod_time = substr(lastmod, 1, 10); # Extract YYYY-MM-DD from lastmod ($5) - if (mod_time == "") next; # Skip if date is invalid/empty - - print " "; - print " " fix_url_awk(item_url, site_url) ""; - print " " mod_time ""; - print " weekly"; - print " 0.8"; - print " "; - } -} - -# Process primary_pages.tmp -FILENAME == ARGV[2] { - url=$2; date=$3; # $1=_, $4=source_file - if (length(url) == 0 || length(date) == 0) next; - sitemap_url = url; - sub(/index\.html$/, "", sitemap_url); # Remove index.html - sub(/\/+$/, "/", sitemap_url); # Ensure trailing slash - mod_time = substr(date, 1, 10); # Extract YYYY-MM-DD from date ($3) - if (mod_time == "") next; # Skip if date is invalid/empty - print " "; - print " " fix_url_awk(sitemap_url, site_url) ""; - print " " mod_time ""; - print " monthly"; - print " 0.7"; - print " "; -} - -# Process secondary_pages.tmp -FILENAME == ARGV[3] { - url=$2; date=$3; # $1=_, $4=source_file - if (length(url) == 0 || length(date) == 0) next; - sitemap_url = url; - sub(/index\.html$/, "", sitemap_url); - sub(/\/+$/, "/", sitemap_url); - mod_time = substr(date, 1, 10); # Extract YYYY-MM-DD from date ($3) - if (mod_time == "") next; # Skip if date is invalid/empty - print " "; - print " " fix_url_awk(sitemap_url, site_url) ""; - print " " mod_time ""; - print " monthly"; - print " 0.6"; # Lower priority for secondary? - print " "; -} - -# Process tags_index.txt (Tag Pages) -FILENAME == ARGV[4] { - tag=$1; tag_slug=$2; # $5 = lastmod for posts with this tag - if (length(tag_slug) == 0) next; - # Check if tag slug already processed - if ( !(tag_slug in processed_tags) ) { - processed_tags[tag_slug] = 1; # Mark as processed - item_url = "/tags/" tag_slug "/"; - # Use the overall latest tag mod date for all tag pages? - mod_time = latest_tag_page_mod_date; - print " "; - print " " fix_url_awk(item_url, site_url) ""; - print " " mod_time ""; - print " weekly"; - print " 0.5"; - print " "; - } -} - -# Process authors_index.txt (Author Pages) - only if author pages are enabled -FILENAME == ARGV[5] && enable_author_pages == "true" { - author_name=$1; author_slug=$2; # $6 = lastmod for posts with this author - if (length(author_slug) == 0) next; - # Check if author slug already processed - if ( !(author_slug in processed_authors) ) { - processed_authors[author_slug] = 1; # Mark as processed - - # Add main authors index page (only once) - if (!authors_index_added) { - authors_index_added = 1; - print " "; - print " " fix_url_awk("/authors/", site_url) ""; - print " " latest_author_page_mod_date ""; - print " weekly"; - print " 0.6"; - print " "; - } - - # Add individual author page - item_url = "/authors/" author_slug "/"; - mod_time = latest_author_page_mod_date; - print " "; - print " " fix_url_awk(item_url, site_url) ""; - print " " mod_time ""; - print " weekly"; - print " 0.5"; - print " "; - } -} - -END { - print ""; -} -AWK_EOF - # awk exit status check - optional - # local awk_status=$? - # if [ $awk_status -ne 0 ]; then - # echo -e "${RED}Error: awk script for sitemap generation failed with status $awk_status${NC}" >&2 - # # Decide whether to return 1 or continue - # fi - - # --- Generate Sitemap using AWK --- END --- + _generate_sitemap_with_awk_inputs \ + "$sitemap" \ + "$file_index" \ + "$primary_pages_cache" \ + "$secondary_pages_cache" \ + "$tags_index" \ + "$authors_index" \ + "$latest_post_mod_date" \ + "$latest_tag_page_mod_date" \ + "$latest_author_page_mod_date" \ + "$sitemap_date_fmt" echo -e "${GREEN}Sitemap generated!${NC}" } # Export public functions -export -f generate_sitemap generate_rss \ No newline at end of file +export -f _normalize_relative_url_path +export -f _ram_strip_frontmatter_for_rss _ram_cache_full_content_for_file prepare_ram_rss_full_content_cache +export -f generate_sitemap generate_rss diff --git a/scripts/build/generate_index.sh b/scripts/build/generate_index.sh index 455d1af..2886665 100755 --- a/scripts/build/generate_index.sh +++ b/scripts/build/generate_index.sh @@ -10,8 +10,298 @@ source "$(dirname "$0")/utils.sh" || { echo >&2 "Error: Failed to source utils.s # shellcheck source=cache.sh disable=SC1091 source "$(dirname "$0")/cache.sh" || { echo >&2 "Error: Failed to source cache.sh from generate_index.sh"; exit 1; } +_generate_index_ram() { + echo -e "${YELLOW}Generating index pages...${NC}" + + local file_index_data + file_index_data=$(ram_mode_get_dataset "file_index") + if [ -z "$file_index_data" ]; then + echo -e "${YELLOW}No posts found in RAM file index. Skipping index generation.${NC}" + return 0 + fi + + local total_posts_orig + total_posts_orig=$(printf '%s\n' "$file_index_data" | awk 'NF { c++ } END { print c+0 }') + local total_pages=$(( (total_posts_orig + POSTS_PER_PAGE - 1) / POSTS_PER_PAGE )) + [ "$total_pages" -eq 0 ] && total_pages=1 + + mapfile -t file_index_lines < <(printf '%s\n' "$file_index_data" | awk 'NF') + echo -e "Generating ${GREEN}$total_pages${NC} index pages for ${GREEN}$total_posts_orig${NC} posts" + + local current_page + for (( current_page = 1; current_page <= total_pages; current_page++ )); do + local output_file + if [ "$current_page" -eq 1 ]; then + output_file="$OUTPUT_DIR/index.html" + else + output_file="$OUTPUT_DIR/page/$current_page/index.html" + mkdir -p "$(dirname "$output_file")" + fi + + local page_header="$HEADER_TEMPLATE" + if [ "$current_page" -eq 1 ]; then + page_header=${page_header//\{\{site_title\}\}/"$SITE_TITLE"} + page_header=${page_header//\{\{page_title\}\}/"${MSG_HOME:-"Home"}"} + page_header=${page_header//\{\{og_type\}\}/"website"} + page_header=${page_header//\{\{page_url\}\}/"/"} + page_header=${page_header//\{\{site_url\}\}/"$SITE_URL"} + local home_schema + home_schema=$(cat < +{ + "@context": "https://schema.org", + "@type": "WebSite", + "name": "$SITE_TITLE", + "description": "$SITE_DESCRIPTION", + "url": "$SITE_URL/", + "potentialAction": { + "@type": "SearchAction", + "target": "$SITE_URL/search?q={search_term_string}", + "query-input": "required name=search_term_string" + }, + "publisher": { + "@type": "Organization", + "name": "$SITE_TITLE", + "url": "$SITE_URL" + } +} + +EOF +) + page_header=${page_header//\{\{schema_json_ld\}\}/"$home_schema"} + else + local pag_title + pag_title=$(printf "${MSG_PAGINATION_TITLE:-"%s - Page %d"}" "$SITE_TITLE" "$current_page") + page_header=${page_header//\{\{site_title\}\}/"$SITE_TITLE"} + page_header=${page_header//\{\{page_title\}\}/"$pag_title"} + page_header=${page_header//\{\{og_type\}\}/"website"} + local paginated_rel_url="/page/$current_page/" + page_header=${page_header//\{\{page_url\}\}/"$paginated_rel_url"} + page_header=${page_header//\{\{site_url\}\}/"$SITE_URL"} + local collection_schema + collection_schema=$(cat < +{ + "@context": "https://schema.org", + "@type": "CollectionPage", + "name": "$pag_title", + "description": "$SITE_DESCRIPTION", + "url": "$SITE_URL${paginated_rel_url}", + "isPartOf": { + "@type": "WebSite", + "name": "$SITE_TITLE", + "url": "$SITE_URL" + } +} + +EOF +) + page_header=${page_header//\{\{schema_json_ld\}\}/"$collection_schema"} + fi + page_header=${page_header//\{\{site_description\}\}/"$SITE_DESCRIPTION"} + page_header=${page_header//\{\{og_description\}\}/"$SITE_DESCRIPTION"} + page_header=${page_header//\{\{twitter_description\}\}/"$SITE_DESCRIPTION"} + page_header=${page_header//\{\{og_image\}\}/""} + page_header=${page_header//\{\{twitter_image\}\}/""} + page_header=${page_header//\{\{fediverse_creator_meta\}\}/"${SITE_FEDIVERSE_CREATOR_META_TAG}"} + + local page_footer="$FOOTER_TEMPLATE" + page_footer=${page_footer//\{\{current_year\}\}/$(date +%Y)} + page_footer=${page_footer//\{\{author_name\}\}/"$AUTHOR_NAME"} + + cat > "$output_file" < /dev/null && ram_mode_has_file "$index_file"; then + has_custom_index=true + elif [ -f "$index_file" ]; then + has_custom_index=true + fi + + if [ "$current_page" -eq 1 ] && [ "$has_custom_index" = true ]; then + local content="" html_content="" in_frontmatter=false found_frontmatter=false source_stream="" + if [ "${BSSG_RAM_MODE:-false}" = true ] && ram_mode_has_file "$index_file"; then + source_stream=$(ram_mode_get_content "$index_file") + else + source_stream=$(cat "$index_file") + fi + while IFS= read -r line; do + if [[ "$line" == "---" ]]; then + if ! $in_frontmatter && ! $found_frontmatter; then + in_frontmatter=true + found_frontmatter=true + continue + elif $in_frontmatter; then + in_frontmatter=false + continue + fi + fi + if ! $in_frontmatter && $found_frontmatter; then + content+="$line"$'\n' + fi + done <<< "$source_stream" + if ! $found_frontmatter; then + content="$source_stream" + fi + html_content=$(convert_markdown_to_html "$content") + echo "$html_content" >> "$output_file" + cat >> "$output_file" <> "$output_file" <${MSG_LATEST_POSTS:-"Latest Posts"} +
+EOF + local start_index=$(( (current_page - 1) * POSTS_PER_PAGE )) + local end_index=$(( start_index + POSTS_PER_PAGE - 1 )) + local i + for (( i = start_index; i <= end_index && i < total_posts_orig; i++ )); do + local file filename title date lastmod tags slug image image_caption description author_name author_email + IFS='|' read -r file filename title date lastmod tags slug image image_caption description author_name author_email <<< "${file_index_lines[$i]}" + [ -z "$file" ] && continue + [ -z "$title" ] && continue + [ -z "$date" ] && continue + + local post_year post_month post_day + if [[ "$date" =~ ^([0-9]{4})-([0-9]{1,2})-([0-9]{1,2}) ]]; then + post_year="${BASH_REMATCH[1]}" + post_month=$(printf "%02d" "$((10#${BASH_REMATCH[2]}))") + post_day=$(printf "%02d" "$((10#${BASH_REMATCH[3]}))") + else + post_year=$(date +%Y); post_month=$(date +%m); post_day=$(date +%d) + fi + local formatted_path="${URL_SLUG_FORMAT//Year/$post_year}" + formatted_path="${formatted_path//Month/$post_month}" + formatted_path="${formatted_path//Day/$post_day}" + formatted_path="${formatted_path//slug/$slug}" + local post_link="/$formatted_path/" + + local display_date_format="$DATE_FORMAT" + if [ "${SHOW_TIMEZONE:-false}" = false ]; then + display_date_format=$(echo "$display_date_format" | sed -e 's/%[zZ]//g' -e 's/[[:space:]]*$//') + fi + local formatted_date + formatted_date=$(format_date "$date" "$display_date_format") + + cat >> "$output_file" < +

$title

+
${MSG_PUBLISHED_ON:-"Published on"} $formatted_date${author_name:+" ${MSG_BY:-"by"} ${author_name:-$AUTHOR_NAME}"}
+EOF + + if [ -n "$image" ]; then + local image_url="$image" + if [[ "$image" == /* ]]; then + image_url="${SITE_URL}${image}" + fi + cat >> "$output_file" < + + ${image_caption:-$title} + +
+EOF + fi + + if [ "${INDEX_SHOW_FULL_CONTENT:-false}" = "true" ]; then + local post_content="" html_content="" + if [ "${BSSG_RAM_MODE:-false}" = true ] && ram_mode_has_file "$file"; then + local source_stream + source_stream=$(ram_mode_get_content "$file") + post_content=$(printf '%s\n' "$source_stream" | awk ' + BEGIN { in_fm = 0; found_fm = 0; } + /^---$/ { + if (!in_fm && !found_fm) { in_fm = 1; found_fm = 1; next; } + if (in_fm) { in_fm = 0; next; } + } + { if (!in_fm) print; } + ') + fi + if [ -n "$post_content" ]; then + if [[ "$file" == *.md ]]; then + html_content=$(convert_markdown_to_html "$post_content") + else + html_content="$post_content" + fi + fi + if [ -n "$html_content" ]; then + cat >> "$output_file" < + $html_content + +EOF + fi + elif [ -n "$description" ]; then + cat >> "$output_file" < + $description + +EOF + fi + + cat >> "$output_file" < +EOF + done + + cat >> "$output_file" < +EOF + + if [ "$total_pages" -gt 1 ]; then + cat >> "$output_file" < + EOF fi - if [ -n "$description" ]; then + # Show either full content or just description based on config + if [ "${INDEX_SHOW_FULL_CONTENT:-false}" = "true" ]; then + # Show full post content + local post_content="" + local content_cache_file="${CACHE_DIR:-.bssg_cache}/content/$(basename "$file")" + + # Try RAM preload first + if [ "${BSSG_RAM_MODE:-false}" = true ] && declare -F ram_mode_has_file > /dev/null && ram_mode_has_file "$file"; then + local source_stream + source_stream=$(ram_mode_get_content "$file") + post_content=$(printf '%s\n' "$source_stream" | awk ' + BEGIN { in_fm = 0; found_fm = 0; } + /^---$/ { + if (!in_fm && !found_fm) { in_fm = 1; found_fm = 1; next; } + if (in_fm) { in_fm = 0; next; } + } + { if (!in_fm) print; } + ') + # Try to get content from cache first + elif [ -f "$content_cache_file" ]; then + post_content=$(cat "$content_cache_file") + else + # Extract content from source file if cache doesn't exist + local in_frontmatter=false + local found_frontmatter=false + { + while IFS= read -r line; do + if [[ "$line" == "---" ]]; then + if ! $in_frontmatter && ! $found_frontmatter; then + in_frontmatter=true + found_frontmatter=true + continue + elif $in_frontmatter; then + in_frontmatter=false + continue + fi + fi + if ! $in_frontmatter && $found_frontmatter; then + post_content+="$line"$'\n' + fi + done + } < "$file" + + # If no frontmatter was found, use the whole file + if ! $found_frontmatter; then + post_content=$(cat "$file") + fi + fi + + # Convert to HTML if it's a markdown file + local html_content="" + if [[ "$file" == *.md ]]; then + html_content=$(convert_markdown_to_html "$post_content") + elif [[ "$file" == *.html ]]; then + # For HTML files, content is already HTML + html_content=$(sed -n '//,/<\/body>/p' "$file" | sed '1d;$d') + # If body extraction failed, use content as-is + if [ -z "$html_content" ]; then + html_content="$post_content" + fi + else + html_content="$post_content" + fi + + if [ -n "$html_content" ]; then + cat >> "$output_file" << EOF +
+ $html_content +
+EOF + fi + elif [ -n "$description" ]; then + # Show just the description/excerpt (default behavior) cat >> "$output_file" << EOF
$description @@ -294,9 +657,8 @@ EOF # Use GNU parallel if available and beneficial if [ "${HAS_PARALLEL:-false}" = true ] && [ "$total_pages" -gt 2 ] ; then echo -e "${GREEN}Using GNU parallel to process index pages${NC}" - local cores=1 - if command -v nproc > /dev/null 2>&1; then cores=$(nproc); - elif command -v sysctl > /dev/null 2>&1; then cores=$(sysctl -n hw.ncpu 2>/dev/null || echo 1); fi + local cores + cores=$(get_parallel_jobs) # Use all detected cores local jobs=$cores @@ -305,10 +667,11 @@ EOF export OUTPUT_DIR URL_SLUG_FORMAT POSTS_PER_PAGE CACHE_DIR export SITE_TITLE SITE_DESCRIPTION AUTHOR_NAME DATE_FORMAT SITE_URL export FORCE_REBUILD HEADER_TEMPLATE FOOTER_TEMPLATE SHOW_TIMEZONE - export MSG_LATEST_POSTS MSG_HOME MSG_PAGINATION_TITLE MSG_PUBLISHED_ON MSG_BY + export INDEX_SHOW_FULL_CONTENT + export MSG_LATEST_POSTS MSG_HOME MSG_PAGINATION_TITLE MSG_PUBLISHED_ON MSG_BY export MSG_NEWER_POSTS MSG_OLDER_POSTS MSG_PAGE_INFO_TEMPLATE # Note: total_posts_orig is NOT exported, passed as argument now - export -f process_index_page file_needs_rebuild get_file_mtime format_date generate_slug fix_url + export -f process_index_page file_needs_rebuild get_file_mtime format_date generate_slug fix_url convert_markdown_to_html # Ensure templates are exported if [ -z "$HEADER_TEMPLATE" ] || [ -z "$FOOTER_TEMPLATE" ]; then @@ -332,4 +695,4 @@ EOF } # Make the function available for sourcing -export -f generate_index \ No newline at end of file +export -f generate_index diff --git a/scripts/build/generate_pages.sh b/scripts/build/generate_pages.sh index cb4599a..2e4295f 100755 --- a/scripts/build/generate_pages.sh +++ b/scripts/build/generate_pages.sh @@ -24,9 +24,13 @@ convert_page() { # IMPORTANT: Assumes CACHE_DIR, FORCE_REBUILD, PAGES_DIR, SITE_TITLE, SITE_DESCRIPTION, SITE_URL, AUTHOR_NAME are exported/available local output_html_file="$output_base_path/index.html" + local ram_mode_active=false + if [ "${BSSG_RAM_MODE:-false}" = true ] && declare -F ram_mode_has_file > /dev/null && ram_mode_has_file "$input_file"; then + ram_mode_active=true + fi # Check if the source file exists - if [ ! -f "$input_file" ]; then + if ! $ram_mode_active && [ ! -f "$input_file" ]; then echo -e "${RED}Error: Source page '$input_file' not found${NC}" >&2 return 1 fi @@ -45,21 +49,31 @@ convert_page() { if [[ "$input_file" == *.html ]]; then # For HTML files, extract content between tags (simple approach) - html_content=$(sed -n '//,/<\/body>/p' "$input_file" | sed '1d;$d') + local html_source="" + if $ram_mode_active; then + html_source=$(ram_mode_get_content "$input_file") + else + html_source=$(cat "$input_file") + fi + html_content=$(printf '%s\n' "$html_source" | sed -n '//,/<\/body>/p' | sed '1d;$d') # We might not have raw content for reading time easily here content=$(echo "$html_content" | sed 's/<[^>]*>//g') # Basic text extraction for reading time else # For markdown files, extract content after frontmatter - local start_line=$(grep -n "^---$" "$input_file" | head -1 | cut -d: -f1) - local end_line=$(grep -n "^---$" "$input_file" | head -2 | tail -1 | cut -d: -f1) - - if [[ -z "$start_line" || -z "$end_line" || ! $start_line -lt $end_line ]]; then - # No valid frontmatter found, use the whole file - content=$(cat "$input_file") + local source_stream="" + if $ram_mode_active; then + source_stream=$(ram_mode_get_content "$input_file") else - # Extract content after the second --- line - content=$(tail -n +$((end_line + 1)) "$input_file") + source_stream=$(cat "$input_file") fi + content=$(printf '%s\n' "$source_stream" | awk ' + BEGIN { in_fm = 0; found_fm = 0; } + /^---$/ { + if (!in_fm && !found_fm) { in_fm = 1; found_fm = 1; next; } + if (in_fm) { in_fm = 0; next; } + } + { if (!in_fm) print; } + ') # --- MODIFIED PART --- START --- # Convert markdown content to HTML using the function from content.sh @@ -111,6 +125,7 @@ convert_page() { # Handle image placeholders (remove for pages as they don't have featured images) header_content=${header_content//\{\{og_image\}\}/} header_content=${header_content//\{\{twitter_image\}\}/} + header_content=${header_content//\{\{fediverse_creator_meta\}\}/"${SITE_FEDIVERSE_CREATOR_META_TAG}"} # Assemble the final HTML local final_html="${header_content}" @@ -178,10 +193,13 @@ process_all_pages() { return 0 fi - echo -e "Checking ${GREEN}${#page_files[@]}${NC} pages for changes" - # Use mapfile -t to read sorted files into array (newline-separated, trailing newline stripped) - mapfile -t page_files < <(find "${PAGES_DIR:-pages}" -type f \( -name "*.md" -o -name "*.html" \) -not -path "*/.*" | sort) + local page_files=() + if [ "${BSSG_RAM_MODE:-false}" = true ] && declare -F ram_mode_list_page_files > /dev/null; then + mapfile -t page_files < <(ram_mode_list_page_files) + else + mapfile -t page_files < <(find "${PAGES_DIR:-pages}" -type f \( -name "*.md" -o -name "*.html" \) -not -path "*/.*" | sort) + fi local num_pages=${#page_files[@]} if [ "$num_pages" -eq 0 ]; then @@ -190,14 +208,40 @@ process_all_pages() { fi echo -e "Found ${GREEN}$num_pages${NC} potential pages." + local ram_mode_active=false + if [ "${BSSG_RAM_MODE:-false}" = true ]; then + ram_mode_active=true + fi + + # RAM mode keeps source content only in-process (bash arrays). + # GNU parallel spawns fresh shells that cannot access those arrays. + if $ram_mode_active; then + if [ "$num_pages" -gt 1 ]; then + echo -e "${YELLOW}Using shell parallel workers for $num_pages RAM-mode pages${NC}" + local cores + cores=$(get_parallel_jobs) + + { + local file quoted_file + for file in "${page_files[@]}"; do + [[ -z "$file" ]] && continue + printf -v quoted_file '%q' "$file" + echo "process_single_page_file $quoted_file" + done + } | run_parallel "$cores" + else + echo -e "${YELLOW}Using sequential processing for RAM-mode pages${NC}" + if [[ -n "${page_files[0]}" ]]; then + process_single_page_file "${page_files[0]}" + fi + fi # Use GNU parallel if available, otherwise fallback # IMPORTANT: Assumes HAS_PARALLEL is exported/available - if [ "${HAS_PARALLEL:-false}" = true ]; then + elif [ "${HAS_PARALLEL:-false}" = true ]; then echo -e "${GREEN}Using GNU parallel to generate pages${NC}" # Determine number of cores - local cores=1 - if command -v nproc > /dev/null 2>&1; then cores=$(nproc); - elif command -v sysctl > /dev/null 2>&1; then cores=$(sysctl -n hw.ncpu 2>/dev/null || echo 1); fi + local cores + cores=$(get_parallel_jobs) # Export functions needed by the parallel process and its children export -f convert_page process_single_page_file @@ -216,6 +260,7 @@ process_all_pages() { echo -e "${YELLOW}Using sequential processing for pages${NC}" local file for file in "${page_files[@]}"; do + [[ -z "$file" ]] && continue process_single_page_file "$file" done fi @@ -223,4 +268,4 @@ process_all_pages() { echo -e "${GREEN}Static page processing complete!${NC}" } -# --- Page Generation Functions --- END --- \ No newline at end of file +# --- Page Generation Functions --- END --- diff --git a/scripts/build/generate_posts.sh b/scripts/build/generate_posts.sh index 146a7a9..2c962fb 100755 --- a/scripts/build/generate_posts.sh +++ b/scripts/build/generate_posts.sh @@ -16,6 +16,63 @@ source "$(dirname "$0")/related_posts.sh" || { echo >&2 "Error: Failed to source # --- Post Generation Functions --- START --- +declare -gA BSSG_POST_ISO8601_CACHE=() + +format_iso8601_post_date() { + local input_dt="$1" + local iso_dt="" + + if [ -z "$input_dt" ]; then + echo "" + return + fi + + local cache_key="${TIMEZONE:-local}|${input_dt}" + if [[ "$(declare -p BSSG_POST_ISO8601_CACHE 2>/dev/null || true)" != "declare -A"* ]]; then + unset BSSG_POST_ISO8601_CACHE 2>/dev/null || true + declare -gA BSSG_POST_ISO8601_CACHE=() + fi + if [[ -n "${BSSG_POST_ISO8601_CACHE[$cache_key]+_}" ]]; then + echo "${BSSG_POST_ISO8601_CACHE[$cache_key]}" + return + fi + + # Handle "now" separately + if [ "$input_dt" = "now" ]; then + iso_dt=$(LC_ALL=C date +"%Y-%m-%dT%H:%M:%S%z" 2>/dev/null) + else + # Try parsing different formats based on OS + if [[ "$OSTYPE" == "darwin"* ]] || [[ "$OSTYPE" == *"bsd"* ]]; then + # Format 1: YYYY-MM-DD HH:MM:SS ZZZZ (e.g., +0200) + iso_dt=$(LC_ALL=C date -j -f "%Y-%m-%d %H:%M:%S %z" "$input_dt" +"%Y-%m-%dT%H:%M:%S%z" 2>/dev/null) + # Format 2: YYYY-MM-DD HH:MM:SS + [ -z "$iso_dt" ] && iso_dt=$(LC_ALL=C date -j -f "%Y-%m-%d %H:%M:%S" "$input_dt" +"%Y-%m-%dT%H:%M:%S%z" 2>/dev/null) + # Format 3: YYYY-MM-DD (assume T00:00:00) + [ -z "$iso_dt" ] && iso_dt=$(LC_ALL=C date -j -f "%Y-%m-%d" "$input_dt" +"%Y-%m-%dT00:00:00%z" 2>/dev/null) + # Format 4: RFC 2822 subset (e.g., 07 Sep 2023 08:10:00 +0200) + [ -z "$iso_dt" ] && iso_dt=$(LC_ALL=C date -j -f "%d %b %Y %H:%M:%S %z" "$input_dt" +"%Y-%m-%dT%H:%M:%S%z" 2>/dev/null) + else + # GNU date -d handles many formats. + iso_dt=$(LC_ALL=C date -d "$input_dt" +"%Y-%m-%dT%H:%M:%S%z" 2>/dev/null) + fi + fi + + # Normalize timezone from +0000 to Z and +hhmm to +hh:mm. + if [ -n "$iso_dt" ] && [[ "$iso_dt" =~ ([+-][0-9]{2})([0-9]{2})$ ]]; then + local tz_offset="${BASH_REMATCH[0]}" + local tz_hh="${BASH_REMATCH[1]}" + local tz_mm="${BASH_REMATCH[2]}" + if [ "$tz_hh" = "+00" ] && [ "$tz_mm" = "00" ]; then + iso_dt="${iso_dt%$tz_offset}Z" + else + iso_dt="${iso_dt%$tz_offset}${tz_hh}:${tz_mm}" + fi + fi + + BSSG_POST_ISO8601_CACHE["$cache_key"]="$iso_dt" + echo "$iso_dt" +} + # Convert markdown to HTML convert_markdown() { local input_file="$1" @@ -30,61 +87,83 @@ convert_markdown() { local description="${10}" local author_name="${11}" local author_email="${12}" + local skip_rebuild_check="${13:-false}" local content_cache_file="${CACHE_DIR:-.bssg_cache}/content/$(basename "$input_file")" local output_html_file="$output_base_path/index.html" + local ram_mode_active=false + if [ "${BSSG_RAM_MODE:-false}" = true ] && declare -F ram_mode_has_file > /dev/null && ram_mode_has_file "$input_file"; then + ram_mode_active=true + fi # Check if the source file exists - if [ ! -f "$input_file" ]; then + if ! $ram_mode_active && [ ! -f "$input_file" ]; then echo -e "${RED}Error: Source file '$input_file' not found${NC}" >&2 return 1 fi - # Skip if output file is newer than input file and no force rebuild - if ! file_needs_rebuild "$input_file" "$output_html_file"; then - echo -e "Skipping unchanged file: ${YELLOW}$(basename "$input_file")${NC}" - return 0 + # Skip if output file is newer than input file and no force rebuild. + # When callers already prefiltered rebuild candidates, this check can be skipped. + if [ "$skip_rebuild_check" != true ]; then + if ! file_needs_rebuild "$input_file" "$output_html_file"; then + echo -e "Skipping unchanged file: ${YELLOW}$(basename "$input_file")${NC}" + return 0 + fi fi - echo -e "Processing post: ${GREEN}$(basename "$input_file")${NC}" + if [ "${BSSG_RAM_MODE:-false}" != true ] || [ "${RAM_MODE_VERBOSE:-false}" = true ]; then + echo -e "Processing post: ${GREEN}$(basename "$input_file")${NC}" + fi - # IMPORTANT: Assumes lock_file/unlock_file are sourced/available - lock_file "$content_cache_file" - - # Try to get content from cache or file + # Extract body content (without frontmatter) in one awk pass. + # This is materially faster than line-by-line bash parsing on large markdown files. local content="" - local in_frontmatter=false - local found_frontmatter=false - { - while IFS= read -r line; do - if [[ "$line" == "---" ]]; then - if ! $in_frontmatter && ! $found_frontmatter; then - in_frontmatter=true - found_frontmatter=true - continue - elif $in_frontmatter; then - in_frontmatter=false - continue # Skip the closing --- line itself - fi - fi - if ! $in_frontmatter && $found_frontmatter; then - content+="$line"$'\n' - fi - done - } < "$input_file" - - # If no frontmatter was found, use the whole file as content - if ! $found_frontmatter; then - content=$(cat "$input_file") + local source_stream="" + local fediverse_creator_override="" + if $ram_mode_active; then + source_stream=$(ram_mode_get_content "$input_file") + else + source_stream=$(cat "$input_file") fi + if [[ "$input_file" == *.html ]]; then + fediverse_creator_override=$(printf '%s\n' "$source_stream" | grep -m 1 -o 'name="fediverse_creator" content="[^"]*"' 2>/dev/null | sed 's/.*content="\([^"]*\)".*/\1/') + if [ -z "$fediverse_creator_override" ]; then + fediverse_creator_override=$(printf '%s\n' "$source_stream" | grep -m 1 -o 'name="fediverse:creator" content="[^"]*"' 2>/dev/null | sed 's/.*content="\([^"]*\)".*/\1/') + fi + else + fediverse_creator_override=$(parse_metadata "$input_file" "fediverse_creator") + fi + content=$(printf '%s' "$source_stream" | awk ' + NR == 1 { + if ($0 == "---") { + has_frontmatter = 1 + in_frontmatter = 1 + next + } + } + + { + if (has_frontmatter) { + if (in_frontmatter) { + if ($0 == "---") { + in_frontmatter = 0 + } + next + } + print + } else { + print + } + } + ') # Cache the markdown content *without frontmatter* for potential use in RSS full content - if [ -n "$CACHE_DIR" ] && [ -d "${CACHE_DIR}/content" ]; then + if ! $ram_mode_active && [ -n "$CACHE_DIR" ] && [ -d "${CACHE_DIR}/content" ]; then # Write the $content variable (which has frontmatter removed) to the cache file + lock_file "$content_cache_file" printf '%s' "$content" > "$content_cache_file" + unlock_file "$content_cache_file" fi - - unlock_file "$content_cache_file" # Calculate reading time local reading_time @@ -122,7 +201,7 @@ convert_markdown() { [[ -z "$tag" ]] && continue local tag_slug=$(echo "$tag" | tr '[:upper:]' '[:lower:]' | sed -e 's/ /-/g' -e 's/[^a-z0-9-]//g') if [[ -n "$tag_slug" ]]; then # Ensure tag slug is not empty - tags_html+=$(printf ' %s' "${SITE_URL:-}" "$tag_slug" "$tag") + tags_html+=" ${tag}" fi done tags_html+="
" @@ -172,68 +251,34 @@ convert_markdown() { meta_desc=$(echo "${description:-$SITE_DESCRIPTION}" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//') header_content=${header_content//\{\{og_description\}\}/"$meta_desc"} header_content=${header_content//\{\{twitter_description\}\}/"$meta_desc"} + local display_author_name="${author_name:-${AUTHOR_NAME:-Anonymous}}" + local fediverse_creator_meta_tag="" + fediverse_creator_meta_tag=$(build_fediverse_creator_meta_tag "$display_author_name" "$fediverse_creator_override") + if [[ "$header_content" == *"{{fediverse_creator_meta}}"* ]]; then + header_content=${header_content//\{\{fediverse_creator_meta\}\}/"$fediverse_creator_meta_tag"} + elif [ -n "$fediverse_creator_meta_tag" ]; then + if [[ "$header_content" == *""* ]]; then + header_content=${header_content/<\/head>/$'\n'"$fediverse_creator_meta_tag"$'\n'''} + else + header_content+=$'\n'"$fediverse_creator_meta_tag" + fi + fi # Generate Schema.org JSON-LD for articles local schema_json_ld="" if [ -n "$date" ]; then local iso_date iso_lastmod_date - # Function to format date to ISO 8601 with corrected timezone - format_iso8601() { - local input_dt="$1" - local iso_dt="" - if [ -z "$input_dt" ]; then echo ""; return; fi - - # Handle "now" separately - if [ "$input_dt" = "now" ]; then - iso_dt=$(LC_ALL=C date +"%Y-%m-%dT%H:%M:%S%z" 2>/dev/null) - else - # Try parsing different formats based on OS - # Add LC_ALL=C for consistent parsing - if [[ "$OSTYPE" == "darwin"* ]] || [[ "$OSTYPE" == *"bsd"* ]]; then - # macOS/BSD: Try formats one by one with date -j -f - # Format 1: YYYY-MM-DD HH:MM:SS ZZZZ (e.g., +0200) - iso_dt=$(LC_ALL=C date -j -f "%Y-%m-%d %H:%M:%S %z" "$input_dt" +"%Y-%m-%dT%H:%M:%S%z" 2>/dev/null) - # Format 2: YYYY-MM-DD HH:MM:SS - [ -z "$iso_dt" ] && iso_dt=$(LC_ALL=C date -j -f "%Y-%m-%d %H:%M:%S" "$input_dt" +"%Y-%m-%dT%H:%M:%S%z" 2>/dev/null) - # Format 3: YYYY-MM-DD (assume T00:00:00) - [ -z "$iso_dt" ] && iso_dt=$(LC_ALL=C date -j -f "%Y-%m-%d" "$input_dt" +"%Y-%m-%dT00:00:00%z" 2>/dev/null) - # Format 4: RFC 2822 subset (e.g., 07 Sep 2023 08:10:00 +0200) - [ -z "$iso_dt" ] && iso_dt=$(LC_ALL=C date -j -f "%d %b %Y %H:%M:%S %z" "$input_dt" +"%Y-%m-%dT%H:%M:%S%z" 2>/dev/null) - else # Linux - # GNU date -d is more flexible and handles many formats automatically - iso_dt=$(LC_ALL=C date -d "$input_dt" +"%Y-%m-%dT%H:%M:%S%z" 2>/dev/null) - fi - fi - - # If parsing succeeded, fix timezone format - if [ -n "$iso_dt" ]; then - # Fix timezone format from +0000 to +00:00 or Z - if [[ "$iso_dt" =~ ([+-][0-9]{2})([0-9]{2})$ ]]; then - local tz_offset="${BASH_REMATCH[0]}" - local tz_hh="${BASH_REMATCH[1]}" - local tz_mm="${BASH_REMATCH[2]}" - if [ "$tz_hh" == "+00" ] && [ "$tz_mm" == "00" ]; then - iso_dt="${iso_dt%$tz_offset}Z" - else - iso_dt="${iso_dt%$tz_offset}${tz_hh}:${tz_mm}" - fi - fi - echo "$iso_dt" - else - echo "" # Return empty if formatting failed - fi - } - - iso_date=$(format_iso8601 "$date") + iso_date=$(format_iso8601_post_date "$date") # Use date as fallback for lastmod, then format - iso_lastmod_date=$(format_iso8601 "${lastmod:-$date}") + iso_lastmod_date=$(format_iso8601_post_date "${lastmod:-$date}") # If lastmod still empty, use iso_date as fallback [ -z "$iso_lastmod_date" ] && iso_lastmod_date="$iso_date" # Fallback to build time if both are empty (should be rare) if [ -z "$iso_date" ]; then - local now_iso=$(format_iso8601 "now") + local now_iso + now_iso=$(format_iso8601_post_date "now") iso_date="$now_iso" iso_lastmod_date="$now_iso" fi @@ -295,7 +340,6 @@ convert_markdown() { local formatted_lastmod=$(format_date "$lastmod" "$display_date_format") local post_meta_reading_time post_meta_reading_time=$(printf "${MSG_READING_TIME_TEMPLATE:-%d min read}" "$reading_time") - local display_author_name="${author_name:-${AUTHOR_NAME:-Anonymous}}" local post_meta="
" post_meta+="

" post_meta+="${MSG_PUBLISHED_ON:-Published on}: ${MSG_BY:-by} $display_author_name" @@ -319,10 +363,24 @@ convert_markdown() { # Generate related posts if enabled and tags exist local related_posts_html="" if [ "${ENABLE_RELATED_POSTS:-true}" = true ] && [ -n "$tags" ]; then - echo -e "${BLUE}DEBUG: Generating related posts for $slug with tags: $tags${NC}" - related_posts_html=$(generate_related_posts "$slug" "$tags" "$date" "${RELATED_POSTS_COUNT:-3}") + # RAM fast path: direct map lookup avoids per-post command-substitution/function overhead. + if [ "${BSSG_RAM_MODE:-false}" = true ] && \ + [ "${BSSG_RAM_RELATED_POSTS_READY:-false}" = true ] && \ + [ "${BSSG_RAM_RELATED_POSTS_LIMIT:-}" = "${RELATED_POSTS_COUNT:-3}" ]; then + related_posts_html="${BSSG_RAM_RELATED_POSTS_HTML[$slug]-}" + if [ "${RAM_MODE_VERBOSE:-false}" = true ]; then + echo -e "${BLUE}DEBUG: Generating related posts for $slug with tags: $tags${NC}" + fi + else + if [ "${BSSG_RAM_MODE:-false}" != true ] || [ "${RAM_MODE_VERBOSE:-false}" = true ]; then + echo -e "${BLUE}DEBUG: Generating related posts for $slug with tags: $tags${NC}" + fi + related_posts_html=$(generate_related_posts "$slug" "$tags" "$date" "${RELATED_POSTS_COUNT:-3}") + fi else - echo -e "${BLUE}DEBUG: Skipping related posts for $slug - ENABLE_RELATED_POSTS=${ENABLE_RELATED_POSTS:-true}, tags=$tags${NC}" + if [ "${BSSG_RAM_MODE:-false}" != true ] || [ "${RAM_MODE_VERBOSE:-false}" = true ]; then + echo -e "${BLUE}DEBUG: Skipping related posts for $slug - ENABLE_RELATED_POSTS=${ENABLE_RELATED_POSTS:-true}, tags=$tags${NC}" + fi fi # Construct article body @@ -368,13 +426,24 @@ process_all_markdown_files() { local modified_tags_list="${CACHE_DIR:-.bssg_cache}/modified_tags.list" # Define path for modified tags local modified_authors_list="${CACHE_DIR:-.bssg_cache}/modified_authors.list" # Define path for modified authors local file_index_prev="${CACHE_DIR:-.bssg_cache}/file_index_prev.txt" # Path to previous index + local ram_mode_active=false + local file_index_data="" + if [ "${BSSG_RAM_MODE:-false}" = true ]; then + ram_mode_active=true + file_index_data=$(ram_mode_get_dataset "file_index") + fi - if [ ! -f "$file_index" ]; then + if ! $ram_mode_active && [ ! -f "$file_index" ]; then echo -e "${RED}Error: File index not found at '$file_index'. Run indexing first.${NC}" >&2 return 1 fi - local total_file_count=$(wc -l < "$file_index") + local total_file_count=0 + if $ram_mode_active; then + total_file_count=$(printf '%s\n' "$file_index_data" | awk 'NF { c++ } END { print c+0 }') + else + total_file_count=$(wc -l < "$file_index") + fi if [ "$total_file_count" -eq 0 ]; then echo -e "${YELLOW}No posts found in file index. Skipping post generation.${NC}" return 0 @@ -386,7 +455,7 @@ process_all_markdown_files() { local posts_needing_rebuild=0 # Only do expensive Pass 1 if related posts are enabled AND posts might need rebuilding - if [ "${ENABLE_RELATED_POSTS:-true}" = true ]; then + if [ "${ENABLE_RELATED_POSTS:-true}" = true ] && ! $ram_mode_active; then echo -e "${BLUE}DEBUG: Related posts enabled, starting quick scan...${NC}" # Quick scan to see if ANY posts need rebuilding before doing expensive Pass 1 echo -e "${YELLOW}Quick scan: Checking if any posts need rebuilding...${NC}" @@ -435,13 +504,19 @@ process_all_markdown_files() { # Early exit optimization: if we find posts needing rebuild, we need Pass 1 break fi - done < "$file_index" + done < <( + if $ram_mode_active; then + printf '%s\n' "$file_index_data" | awk 'NF' + else + cat "$file_index" + fi + ) echo -e "Quick scan result: ${GREEN}$posts_needing_rebuild${NC} posts need rebuilding" fi # --- PASS 1: Only run if needed (posts need rebuilding AND related posts enabled) --- - if [ "$needs_pass1" = true ] && [ "${ENABLE_RELATED_POSTS:-true}" = true ]; then + if [ "$needs_pass1" = true ] && [ "${ENABLE_RELATED_POSTS:-true}" = true ] && ! $ram_mode_active; then echo -e "${BLUE}DEBUG: Both needs_pass1=true and ENABLE_RELATED_POSTS=true, running Pass 1...${NC}" echo -e "${YELLOW}Pass 1: Identifying modified tags for related posts cache invalidation...${NC}" @@ -554,6 +629,8 @@ process_all_markdown_files() { # Export the list for use in pass 2 export RELATED_POSTS_INVALIDATED_LIST fi + elif $ram_mode_active; then + echo -e "${BLUE}DEBUG: RAM mode active, skipping Pass 1 related-posts invalidation (in-memory computation).${NC}" else echo -e "${BLUE}DEBUG: Pass 1 skipped - needs_pass1=$needs_pass1, ENABLE_RELATED_POSTS=${ENABLE_RELATED_POSTS:-true}${NC}" fi @@ -566,63 +643,81 @@ process_all_markdown_files() { local files_to_process_count=0 local skipped_count=0 - while IFS= read -r line; do - local file filename title date lastmod tags slug image image_caption description author_name author_email - IFS='|' read -r file filename title date lastmod tags slug image image_caption description author_name author_email <<< "$line" - - # Basic check if it looks like a post - if [ -z "$date" ] || [[ "$file" != "$SRC_DIR"* ]]; then - # echo -e "Skipping non-post file listed in index (pre-check): ${YELLOW}$file${NC}" >&2 # Too verbose - continue - fi - - # Calculate expected output path (logic copied from process_single_file) - local output_path - local year month day - if [[ "$date" =~ ^([0-9]{4})-([0-9]{1,2})-([0-9]{1,2}) ]]; then - year="${BASH_REMATCH[1]}" - month=$(printf "%02d" "$((10#${BASH_REMATCH[2]}))") - day=$(printf "%02d" "$((10#${BASH_REMATCH[3]}))") - else - year=$(date +%Y); month=$(date +%m); day=$(date +%d) - fi - local url_path="${URL_SLUG_FORMAT:-Year/Month/Day/slug}" - url_path="${url_path//Year/$year}"; url_path="${url_path//Month/$month}"; - url_path="${url_path//Day/$day}"; url_path="${url_path//slug/$slug}" - local output_html_file="${OUTPUT_DIR:-output}/$url_path/index.html" - - # Perform the rebuild check here - common_rebuild_check "$output_html_file" - local common_result=$? - local needs_rebuild=false - - if [ $common_result -eq 0 ]; then - needs_rebuild=true # Common checks failed (config changed, template newer, output missing) - else # common_result is 2 (output exists and newer than templates/locale) - local input_time=$(get_file_mtime "$file") - local output_time=$(get_file_mtime "$output_html_file") - if (( input_time > output_time )); then - needs_rebuild=true # Input file is newer + if $ram_mode_active && [ "${FORCE_REBUILD:-false}" = true ]; then + echo -e "RAM mode force rebuild: skipping per-post rebuild checks." + while IFS= read -r line; do + local file filename title date + IFS='|' read -r file filename _ date _ <<< "$line" + if [ -n "$date" ] && [[ "$file" == "$SRC_DIR"* ]]; then + files_to_process_list+=("$line") + files_to_process_count=$((files_to_process_count + 1)) fi - fi + done < <(printf '%s\n' "$file_index_data" | awk 'NF') + else + while IFS= read -r line; do + local file filename title date lastmod tags slug image image_caption description author_name author_email + IFS='|' read -r file filename title date lastmod tags slug image image_caption description author_name author_email <<< "$line" - # Check if this post needs rebuilding due to related posts cache invalidation - if [ "$needs_rebuild" = false ] && [ -n "${RELATED_POSTS_INVALIDATED_LIST:-}" ] && [ -f "$RELATED_POSTS_INVALIDATED_LIST" ]; then - if grep -Fxq "$slug" "$RELATED_POSTS_INVALIDATED_LIST" 2>/dev/null; then - needs_rebuild=true # Related posts cache was invalidated - echo -e "Rebuilding ${GREEN}$(basename "$file")${NC} due to related posts cache invalidation" + # Basic check if it looks like a post + if [ -z "$date" ] || [[ "$file" != "$SRC_DIR"* ]]; then + # echo -e "Skipping non-post file listed in index (pre-check): ${YELLOW}$file${NC}" >&2 # Too verbose + continue fi - fi - if $needs_rebuild; then - files_to_process_list+=("$line") - files_to_process_count=$((files_to_process_count + 1)) - else - # Only print skip message if not rebuilding - echo -e "Skipping unchanged file: ${YELLOW}$(basename "$file")${NC}" - skipped_count=$((skipped_count + 1)) - fi - done < "$file_index" + # Calculate expected output path (logic copied from process_single_file) + local output_path + local year month day + if [[ "$date" =~ ^([0-9]{4})-([0-9]{1,2})-([0-9]{1,2}) ]]; then + year="${BASH_REMATCH[1]}" + month=$(printf "%02d" "$((10#${BASH_REMATCH[2]}))") + day=$(printf "%02d" "$((10#${BASH_REMATCH[3]}))") + else + year=$(date +%Y); month=$(date +%m); day=$(date +%d) + fi + local url_path="${URL_SLUG_FORMAT:-Year/Month/Day/slug}" + url_path="${url_path//Year/$year}"; url_path="${url_path//Month/$month}"; + url_path="${url_path//Day/$day}"; url_path="${url_path//slug/$slug}" + local output_html_file="${OUTPUT_DIR:-output}/$url_path/index.html" + + # Perform the rebuild check here + common_rebuild_check "$output_html_file" + local common_result=$? + local needs_rebuild=false + + if [ $common_result -eq 0 ]; then + needs_rebuild=true # Common checks failed (config changed, template newer, output missing) + else # common_result is 2 (output exists and newer than templates/locale) + local input_time=$(get_file_mtime "$file") + local output_time=$(get_file_mtime "$output_html_file") + if (( input_time > output_time )); then + needs_rebuild=true # Input file is newer + fi + fi + + # Check if this post needs rebuilding due to related posts cache invalidation + if ! $ram_mode_active && [ "$needs_rebuild" = false ] && [ -n "${RELATED_POSTS_INVALIDATED_LIST:-}" ] && [ -f "$RELATED_POSTS_INVALIDATED_LIST" ]; then + if grep -Fxq "$slug" "$RELATED_POSTS_INVALIDATED_LIST" 2>/dev/null; then + needs_rebuild=true # Related posts cache was invalidated + echo -e "Rebuilding ${GREEN}$(basename "$file")${NC} due to related posts cache invalidation" + fi + fi + + if $needs_rebuild; then + files_to_process_list+=("$line") + files_to_process_count=$((files_to_process_count + 1)) + else + # Only print skip message if not rebuilding + echo -e "Skipping unchanged file: ${YELLOW}$(basename "$file")${NC}" + skipped_count=$((skipped_count + 1)) + fi + done < <( + if $ram_mode_active; then + printf '%s\n' "$file_index_data" | awk 'NF' + else + cat "$file_index" + fi + ) + fi # Check if any files need processing if [ $files_to_process_count -eq 0 ]; then @@ -633,6 +728,10 @@ process_all_markdown_files() { echo -e "Found ${GREEN}$files_to_process_count${NC} posts needing processing out of $total_file_count (Skipped: $skipped_count)." + if $ram_mode_active && [ "${ENABLE_RELATED_POSTS:-true}" = true ]; then + prepare_related_posts_ram_cache "${RELATED_POSTS_COUNT:-3}" + fi + # Define a function for processing a single file line from the *filtered* list process_single_file_for_rebuild() { local line="$1" @@ -658,21 +757,59 @@ process_all_markdown_files() { url_path="${url_path//Day/$day}"; url_path="${url_path//slug/$slug}" output_path="${OUTPUT_DIR:-output}/$url_path" - # Call the main conversion function - # We no longer rely on its internal file_needs_rebuild check - # TODO: Consider modifying convert_markdown to accept a force flag or skip its check - if ! convert_markdown "$file" "$output_path" "$title" "$date" "$lastmod" "$tags" "$slug" "$image" "$image_caption" "$description" "$author_name" "$author_email"; then + # Call the conversion function, skipping internal rebuild checks because this + # function only receives files pre-selected for rebuild. + if ! convert_markdown "$file" "$output_path" "$title" "$date" "$lastmod" "$tags" "$slug" "$image" "$image_caption" "$description" "$author_name" "$author_email" true; then local exit_code=$? echo -e "${RED}ERROR:${NC} convert_markdown failed for '$file' with exit code $exit_code. Output HTML may be missing or incomplete." >&2 fi } # Use GNU parallel if available - if [ "${HAS_PARALLEL:-false}" = true ]; then + if $ram_mode_active; then + local cores + cores=$(get_parallel_jobs) + if [ "$cores" -gt "$files_to_process_count" ]; then + cores="$files_to_process_count" + fi + + if [ "$files_to_process_count" -gt 1 ] && [ "$cores" -gt 1 ]; then + echo -e "${YELLOW}Using shell parallel workers for $files_to_process_count RAM-mode posts${NC}" + + local worker_pids=() + local worker_idx + for ((worker_idx = 0; worker_idx < cores; worker_idx++)); do + ( + local idx + for ((idx = worker_idx; idx < files_to_process_count; idx += cores)); do + process_single_file_for_rebuild "${files_to_process_list[$idx]}" + done + ) & + worker_pids+=("$!") + done + + local pid + local worker_failed=false + for pid in "${worker_pids[@]}"; do + if ! wait "$pid"; then + worker_failed=true + fi + done + if $worker_failed; then + echo -e "${RED}Parallel RAM-mode post processing failed.${NC}" + exit 1 + fi + else + echo -e "${YELLOW}Using sequential processing for $files_to_process_count RAM-mode posts${NC}" + local line + for line in "${files_to_process_list[@]}"; do + process_single_file_for_rebuild "$line" + done + fi + elif [ "${HAS_PARALLEL:-false}" = true ]; then echo -e "${GREEN}Using GNU parallel to process $files_to_process_count posts${NC}" - local cores=1 - if command -v nproc > /dev/null 2>&1; then cores=$(nproc); - elif command -v sysctl > /dev/null 2>&1; then cores=$(sysctl -n hw.ncpu 2>/dev/null || echo 1); fi + local cores + cores=$(get_parallel_jobs) # Export functions and variables needed by parallel tasks # Note: We export the new process function @@ -680,9 +817,12 @@ process_all_markdown_files() { # Export dependencies of convert_markdown and its helpers export -f file_needs_rebuild get_file_mtime common_rebuild_check config_has_changed # Still needed by convert_markdown *internally* for now export -f calculate_reading_time generate_slug format_date fix_url parse_metadata extract_metadata convert_markdown_to_html + export -f trim_whitespace resolve_fediverse_creator build_fediverse_creator_meta_tag + export -f format_iso8601_post_date export -f portable_md5sum # Used by cache funcs export CACHE_DIR FORCE_REBUILD OUTPUT_DIR SITE_URL URL_SLUG_FORMAT HEADER_TEMPLATE FOOTER_TEMPLATE export SITE_TITLE SITE_DESCRIPTION AUTHOR_NAME MARKDOWN_PROCESSOR MARKDOWN_PL_PATH DATE_FORMAT TIMEZONE SHOW_TIMEZONE + export FEDIVERSE_CREATOR AUTHOR_FEDIVERSE_CREATORS_SERIALIZED export MSG_PUBLISHED_ON MSG_UPDATED_ON MSG_READING_TIME_TEMPLATE # Export needed locale messages export CONFIG_HASH_FILE BSSG_CONFIG_CHANGED_STATUS # Export status for common_rebuild_check export ENABLE_RELATED_POSTS RELATED_POSTS_COUNT # Export related posts configuration diff --git a/scripts/build/generate_secondary_pages.sh b/scripts/build/generate_secondary_pages.sh index 7aefa4f..efffb6b 100755 --- a/scripts/build/generate_secondary_pages.sh +++ b/scripts/build/generate_secondary_pages.sh @@ -14,6 +14,10 @@ generate_pages_index() { # --- Define Target File --- local pages_index="$OUTPUT_DIR/pages.html" local secondary_pages_list_file="${CACHE_DIR:-.bssg_cache}/secondary_pages.list" + local ram_mode_active=false + if [ "${BSSG_RAM_MODE:-false}" = true ]; then + ram_mode_active=true + fi # --- Cache Check --- START --- # Rebuild if force flag is set OR if list file exists and output is older than list file @@ -22,13 +26,13 @@ generate_pages_index() { if [[ "${FORCE_REBUILD:-false}" == true ]]; then should_rebuild=true echo -e "${YELLOW}Forcing pages index rebuild (--force-rebuild).${NC}" - elif [ ! -f "$secondary_pages_list_file" ]; then + elif ! $ram_mode_active && [ ! -f "$secondary_pages_list_file" ]; then # If list file doesn't exist, we need to generate pages.html (or handle absence) # This case might mean 0 secondary pages after a clean build. # Let the existing logic handle the case of 0 pages later. should_rebuild=true echo -e "${YELLOW}Secondary pages list file not found, rebuilding pages index.${NC}" - elif [ ! -f "$pages_index" ] || [ "$pages_index" -ot "$secondary_pages_list_file" ]; then + elif ! $ram_mode_active && { [ ! -f "$pages_index" ] || [ "$pages_index" -ot "$secondary_pages_list_file" ]; }; then should_rebuild=true echo -e "${YELLOW}Pages index is older than secondary pages list, rebuilding.${NC}" # Add checks for template file changes? More complex, rely on overall rebuild for now. @@ -47,7 +51,9 @@ generate_pages_index() { # --- Read secondary pages from cache file --- START --- local temp_secondary_pages=() - if [ -f "$secondary_pages_list_file" ]; then + if $ram_mode_active; then + mapfile -t temp_secondary_pages < <(printf '%s\n' "$(ram_mode_get_dataset "secondary_pages")" | awk 'NF') + elif [ -f "$secondary_pages_list_file" ]; then # Use mapfile (readarray) to read lines into the array mapfile -t temp_secondary_pages < "$secondary_pages_list_file" # Optional: Trim whitespace from each element if necessary (mapfile usually handles newlines) @@ -81,15 +87,13 @@ generate_pages_index() { header_content=${header_content//\{\{og_type\}\}/"website"} # Set proper URL in og:url - header_content=${header_content//\{\{page_url\}\}/"pages.html"} + header_content=${header_content//\{\{page_url\}\}/"/pages.html"} header_content=${header_content//\{\{site_url\}\}/"$SITE_URL"} # Generate CollectionPage schema local schema_json_ld="" - local tmp_schema=$(mktemp) - # Create CollectionPage schema - cat > "$tmp_schema" << EOF + schema_json_ld=$(cat << EOF EOF - - # Read the schema from the temporary file - schema_json_ld=$(cat "$tmp_schema") - - # Remove the temporary file - rm "$tmp_schema" +) # Add schema markup to header header_content=${header_content//\{\{schema_json_ld\}\}/"$schema_json_ld"} @@ -118,6 +117,7 @@ EOF # Remove image placeholders header_content=${header_content//\{\{og_image\}\}/""} header_content=${header_content//\{\{twitter_image\}\}/""} + header_content=${header_content//\{\{fediverse_creator_meta\}\}/"${SITE_FEDIVERSE_CREATOR_META_TAG}"} # Replace placeholders in the footer footer_content=${footer_content//\{\{current_year\}\}/$(date +%Y)} @@ -135,7 +135,7 @@ EOF IFS='|' read -r title url _ <<< "$page" # Ignore date for menu cat >> "$pages_index" << EOF

EOF done @@ -150,4 +150,4 @@ EOF } # Make function available for sourcing -export -f generate_pages_index \ No newline at end of file +export -f generate_pages_index diff --git a/scripts/build/generate_tags.sh b/scripts/build/generate_tags.sh index 01e6012..798f9be 100755 --- a/scripts/build/generate_tags.sh +++ b/scripts/build/generate_tags.sh @@ -13,8 +13,604 @@ source "$(dirname "$0")/cache.sh" || { echo >&2 "Error: Failed to source cache.s # shellcheck source=generate_feeds.sh disable=SC1091 source "$(dirname "$0")/generate_feeds.sh" || { echo >&2 "Error: Failed to source generate_feeds.sh from generate_tags.sh"; exit 1; } +declare -gA BSSG_RAM_TAG_POST_SLUGS_BY_SLUG=() +declare -gA BSSG_RAM_TAG_POST_COUNT_BY_SLUG=() +declare -gA BSSG_RAM_TAG_ARTICLE_HTML_BY_SLUG=() +declare -gA BSSG_RAM_RSS_TEMPLATE_BY_SLUG=() +declare -g BSSG_RAM_TAG_DISPLAY_DATE_FORMAT="" +declare -g BSSG_RAM_TAG_HEADER_BASE="" +declare -g BSSG_RAM_TAG_FOOTER_CONTENT="" + +_bssg_tags_now_ms() { + if declare -F _bssg_ram_timing_now_ms > /dev/null; then + _bssg_ram_timing_now_ms + return + fi + + if [ -n "${EPOCHREALTIME:-}" ]; then + local epoch_norm sec frac ms_part + # Some locales expose EPOCHREALTIME with ',' instead of '.' as decimal separator. + epoch_norm="${EPOCHREALTIME/,/.}" + if [[ "$epoch_norm" =~ ^([0-9]+)([.][0-9]+)?$ ]]; then + sec="${BASH_REMATCH[1]}" + frac="${BASH_REMATCH[2]#.}" + frac="${frac}000" + ms_part="${frac:0:3}" + printf '%s\n' $(( 10#$sec * 1000 + 10#$ms_part )) + return + fi + fi + + if command -v perl >/dev/null 2>&1; then + perl -MTime::HiRes=time -e 'printf("%.0f\n", time()*1000)' + else + printf '%s\n' $(( $(date +%s) * 1000 )) + fi +} + +_bssg_tags_format_ms() { + local ms="${1:-0}" + printf '%d.%03ds' $((ms / 1000)) $((ms % 1000)) +} + +_write_tag_rss_from_cached_items_ram() { + local output_file="$1" + local feed_link_rel="$2" + local feed_atom_link_rel="$3" + local tag="$4" + local rss_items_xml="$5" + + local feed_title="${SITE_TITLE} - ${MSG_TAG_PAGE_TITLE:-"Posts tagged with"}: $tag" + local feed_description="${MSG_POSTS_TAGGED_WITH:-"Posts tagged with"}: $tag" + local rss_date_fmt="%a, %d %b %Y %H:%M:%S %z" + + local escaped_feed_title escaped_feed_description feed_link feed_atom_link channel_last_build_date + escaped_feed_title=$(html_escape "$feed_title") + escaped_feed_description=$(html_escape "$feed_description") + feed_link=$(fix_url "$feed_link_rel") + feed_atom_link=$(fix_url "$feed_atom_link_rel") + channel_last_build_date=$(format_date "now" "$rss_date_fmt") + + exec 4> "$output_file" || return 1 + printf '%s\n' \ + '' \ + '' \ + '' \ + " ${escaped_feed_title}" \ + " ${feed_link}" \ + " ${escaped_feed_description}" \ + " ${SITE_LANG:-en}" \ + " ${channel_last_build_date}" \ + " " >&4 + + if [ -n "$rss_items_xml" ]; then + printf '%s' "$rss_items_xml" >&4 + fi + + printf '%s\n' '' '' >&4 + exec 4>&- + + if [ "${BSSG_RAM_MODE:-false}" != true ] || [ "${RAM_MODE_VERBOSE:-false}" = true ]; then + echo -e "${GREEN}RSS feed generated at $output_file${NC}" + fi +} + +_process_single_tag_page_ram() { + local tag_url="$1" + local tag="$2" + local tag_page_html_file="$OUTPUT_DIR/tags/$tag_url/index.html" + local tag_rss_file="$OUTPUT_DIR/tags/$tag_url/${RSS_FILENAME:-rss.xml}" + local tag_page_rel_url="/tags/${tag_url}/" + local tag_rss_rel_url="/tags/${tag_url}/${RSS_FILENAME:-rss.xml}" + mkdir -p "$(dirname "$tag_page_html_file")" + + local header_content="$BSSG_RAM_TAG_HEADER_BASE" + header_content=${header_content//\{\{page_title\}\}/"${MSG_TAG_PAGE_TITLE:-"Posts tagged with"}: $tag"} + header_content=${header_content//\{\{page_url\}\}/"$tag_page_rel_url"} + if [ "${ENABLE_TAG_RSS:-false}" = true ]; then + header_content=${header_content///} + else + header_content=${header_content///} + fi + local schema_json_ld + schema_json_ld=$(cat < +{ + "@context": "https://schema.org", + "@type": "CollectionPage", + "name": "Posts tagged with: $tag", + "description": "Posts with tag: $tag", + "url": "$SITE_URL${tag_page_rel_url}", + "isPartOf": { + "@type": "WebSite", + "name": "$SITE_TITLE", + "url": "$SITE_URL" + } +} + +EOF +) + header_content=${header_content//\{\{schema_json_ld\}\}/"$schema_json_ld"} + local footer_content="$BSSG_RAM_TAG_FOOTER_CONTENT" + + exec 3> "$tag_page_html_file" + printf '%s\n' "$header_content" >&3 + printf '

%s: %s

\n' "${MSG_TAG_PAGE_TITLE:-Posts tagged with}" "$tag" >&3 + printf '
\n' >&3 + + local rss_item_limit=${RSS_ITEM_LIMIT:-15} + local rss_count=0 + local cached_rss_items="" + local rss_all_items_cached=true + local -a selected_rss_templates=() + local tag_post_slugs="" + if [[ -n "${BSSG_RAM_TAG_POST_SLUGS_BY_SLUG[$tag_url]+_}" ]]; then + tag_post_slugs="${BSSG_RAM_TAG_POST_SLUGS_BY_SLUG[$tag_url]}" + fi + + local slug cached_article_html rss_template + while IFS= read -r slug; do + [ -z "$slug" ] && continue + cached_article_html="${BSSG_RAM_TAG_ARTICLE_HTML_BY_SLUG[$slug]}" + if [ -n "$cached_article_html" ]; then + printf '%s' "$cached_article_html" >&3 + fi + if [ "${ENABLE_TAG_RSS:-false}" = true ] && [ "$rss_count" -lt "$rss_item_limit" ]; then + rss_template="${BSSG_RAM_RSS_TEMPLATE_BY_SLUG[$slug]}" + if [ -n "$rss_template" ]; then + selected_rss_templates+=("$rss_template") + if $rss_all_items_cached; then + local rss_file rss_filename rss_title rss_date rss_lastmod rss_tags rss_slug rss_image rss_image_caption rss_description rss_author_name rss_author_email + IFS='|' read -r rss_file rss_filename rss_title rss_date rss_lastmod rss_tags rss_slug rss_image rss_image_caption rss_description rss_author_name rss_author_email <<< "$rss_template" + local rss_item_cache_key="${RSS_INCLUDE_FULL_CONTENT:-false}|${rss_file}|${rss_date}|${rss_lastmod}|${rss_slug}|${rss_title}" + local rss_item_xml="${BSSG_RAM_RSS_ITEM_XML_CACHE[$rss_item_cache_key]-}" + if [ -n "$rss_item_xml" ]; then + cached_rss_items+="$rss_item_xml" + else + rss_all_items_cached=false + fi + fi + rss_count=$((rss_count + 1)) + fi + fi + done <<< "$tag_post_slugs" + + printf '
\n' >&3 + printf '

%s

\n' "$SITE_URL" "${MSG_ALL_TAGS:-All Tags}" >&3 + printf '%s\n' "$footer_content" >&3 + exec 3>&- + + if [ "${ENABLE_TAG_RSS:-false}" = true ] && [ "${#selected_rss_templates[@]}" -gt 0 ]; then + if $rss_all_items_cached; then + _write_tag_rss_from_cached_items_ram "$tag_rss_file" "$tag_page_rel_url" "$tag_rss_rel_url" "$tag" "$cached_rss_items" + else + local tag_post_data="" + local rss_template_entry + for rss_template_entry in "${selected_rss_templates[@]}"; do + tag_post_data+="${rss_template_entry//%TAG%/$tag}"$'\n' + done + _generate_rss_feed "$tag_rss_file" "${SITE_TITLE} - ${MSG_TAG_PAGE_TITLE:-"Posts tagged with"}: $tag" "${MSG_POSTS_TAGGED_WITH:-"Posts tagged with"}: $tag" "$tag_page_rel_url" "$tag_rss_rel_url" "$tag_post_data" + fi + fi +} + +_generate_tag_pages_ram() { + echo -e "${YELLOW}Processing tag pages${NC}${ENABLE_TAG_RSS:+" and RSS feeds"}...${NC}" + local ram_tags_timing_enabled=false + if [ "${RAM_MODE_VERBOSE:-false}" = true ]; then + ram_tags_timing_enabled=true + fi + local tags_total_start_ms=0 + local tags_phase_start_ms=0 + local tags_prep_ms=0 + local tags_render_ms=0 + local tags_index_ms=0 + local tags_total_ms=0 + if [ "$ram_tags_timing_enabled" = true ]; then + tags_total_start_ms="$(_bssg_tags_now_ms)" + tags_phase_start_ms="$tags_total_start_ms" + fi + + local tags_index_data + tags_index_data=$(ram_mode_get_dataset "tags_index") + local main_tags_index_output="$OUTPUT_DIR/tags/index.html" + + mkdir -p "$OUTPUT_DIR/tags" + + if [ -z "$tags_index_data" ]; then + echo -e "${YELLOW}No tags found in RAM index. Skipping tag page generation.${NC}" + return 0 + fi + + BSSG_RAM_TAG_POST_SLUGS_BY_SLUG=() + BSSG_RAM_TAG_POST_COUNT_BY_SLUG=() + BSSG_RAM_TAG_ARTICLE_HTML_BY_SLUG=() + BSSG_RAM_RSS_TEMPLATE_BY_SLUG=() + declare -A tag_name_by_slug=() + local sorted_tag_urls=() + declare -A rss_prefill_slug_set=() + declare -A rss_prefill_slug_hits=() + local rss_prefill_slugs=() + local rss_prefill_occurrences=0 + local rss_item_limit="${RSS_ITEM_LIMIT:-15}" + local rss_prefill_min_hits="${RAM_RSS_PREFILL_MIN_HITS:-2}" + local rss_prefill_max_posts="${RAM_RSS_PREFILL_MAX_POSTS:-24}" + if ! [[ "$rss_prefill_min_hits" =~ ^[0-9]+$ ]] || [ "$rss_prefill_min_hits" -lt 1 ]; then + rss_prefill_min_hits=1 + fi + if ! [[ "$rss_prefill_max_posts" =~ ^[0-9]+$ ]]; then + rss_prefill_max_posts=24 + fi + declare -A seen_post_slugs=() + local display_date_format="$DATE_FORMAT" + if [ "${SHOW_TIMEZONE:-false}" = false ]; then + display_date_format=$(echo "$display_date_format" | sed -e 's/%[zZ]//g' -e 's/[[:space:]]*$//') + fi + BSSG_RAM_TAG_DISPLAY_DATE_FORMAT="$display_date_format" + + # Prime per-post caches once from file_index (one row per post), then build + # lightweight tag->post mappings from tags_index (many rows per post). + local file_index_data + file_index_data=$(ram_mode_get_dataset "file_index") + + local can_prime_rss_metadata=false + local rss_date_fmt="%a, %d %b %Y %H:%M:%S %z" + local build_timestamp_iso="" + if [ "${ENABLE_TAG_RSS:-false}" = true ] && declare -F _ram_prime_rss_metadata_entry > /dev/null; then + can_prime_rss_metadata=true + build_timestamp_iso=$(format_date "now" "%Y-%m-%dT%H:%M:%S%z") + if [[ "$build_timestamp_iso" =~ ([+-][0-9]{2})([0-9]{2})$ ]]; then + build_timestamp_iso="${build_timestamp_iso::${#build_timestamp_iso}-2}:${BASH_REMATCH[2]}" + fi + fi + + local file filename title date lastmod tags slug image image_caption description author_name author_email + while IFS='|' read -r file filename title date lastmod tags slug image image_caption description author_name author_email; do + [ -z "$file" ] && continue + [ -z "$slug" ] && continue + [[ -n "${seen_post_slugs[$slug]+_}" ]] && continue + seen_post_slugs["$slug"]=1 + + local post_year post_month post_day + if [[ "$date" =~ ^([0-9]{4})-([0-9]{1,2})-([0-9]{1,2}) ]]; then + post_year="${BASH_REMATCH[1]}" + post_month=$(printf "%02d" "$((10#${BASH_REMATCH[2]}))") + post_day=$(printf "%02d" "$((10#${BASH_REMATCH[3]}))") + else + post_year=$(date +%Y); post_month=$(date +%m); post_day=$(date +%d) + fi + + local formatted_path="${URL_SLUG_FORMAT//Year/$post_year}" + formatted_path="${formatted_path//Month/$post_month}" + formatted_path="${formatted_path//Day/$post_day}" + formatted_path="${formatted_path//slug/$slug}" + local post_link="/${formatted_path}/" + local formatted_date + formatted_date=$(format_date "$date" "$display_date_format") + + local display_author_name="${author_name:-${AUTHOR_NAME:-Anonymous}}" + local article_html="" + article_html+='
'$'\n' + article_html+="

${title}

"$'\n' + article_html+="
${MSG_PUBLISHED_ON:-Published on} ${formatted_date} ${MSG_BY:-by} ${display_author_name}
"$'\n' + if [ -n "$image" ]; then + local image_url alt_text figcaption_content + image_url=$(fix_url "$image") + alt_text="${image_caption:-$title}" + figcaption_content="${image_caption:-$title}" + article_html+=' '$'\n' + fi + if [ -n "$description" ]; then + article_html+='
'$'\n' + article_html+=" ${description}"$'\n' + article_html+='
'$'\n' + fi + article_html+='
'$'\n' + BSSG_RAM_TAG_ARTICLE_HTML_BY_SLUG["$slug"]="$article_html" + BSSG_RAM_RSS_TEMPLATE_BY_SLUG["$slug"]="${filename}|${filename}|${title}|${date}|${lastmod}|%TAG%|${slug}|${image}|${image_caption}|${description}|${author_name}|${author_email}" + + if $can_prime_rss_metadata; then + _ram_prime_rss_metadata_entry "$date" "$lastmod" "$slug" "$rss_date_fmt" "$build_timestamp_iso" "$file" >/dev/null || true + fi + done <<< "$file_index_data" + + if $can_prime_rss_metadata; then + BSSG_RAM_RSS_METADATA_CACHE_READY=true + fi + + # Sort once globally by tag slug, then by publish date/lastmod descending. + # Aggregate per-tag rows in awk to reduce per-line bash map churn. + local aggregated_tags_data + aggregated_tags_data=$(printf '%s\n' "$tags_index_data" | awk 'NF' | LC_ALL=C sort -t'|' -k2,2 -k4,4r -k5,5r | awk -F'|' -v OFS='|' ' + { + tag = $1 + tag_slug = $2 + post_slug = $7 + if (tag == "" || tag_slug == "") next + + if (current_tag_slug != "" && tag_slug != current_tag_slug) { + print current_tag_slug, current_tag_name, current_count, current_post_slugs + current_count = 0 + current_post_slugs = "" + } + + if (tag_slug != current_tag_slug) { + current_tag_slug = tag_slug + current_tag_name = tag + } + + if (post_slug != "") { + if (current_post_slugs == "") { + current_post_slugs = post_slug + } else { + current_post_slugs = current_post_slugs "," post_slug + } + } + current_count++ + } + END { + if (current_tag_slug != "") { + print current_tag_slug, current_tag_name, current_count, current_post_slugs + } + }') + + local tag_slug tag_name tag_count_value tag_post_slugs_csv + while IFS='|' read -r tag_slug tag_name tag_count_value tag_post_slugs_csv; do + [ -z "$tag_slug" ] && continue + tag_name_by_slug["$tag_slug"]="$tag_name" + BSSG_RAM_TAG_POST_COUNT_BY_SLUG["$tag_slug"]="$tag_count_value" + local tag_post_slugs_newline="" + if [ -n "$tag_post_slugs_csv" ]; then + tag_post_slugs_newline="${tag_post_slugs_csv//,/$'\n'}" + fi + BSSG_RAM_TAG_POST_SLUGS_BY_SLUG["$tag_slug"]="$tag_post_slugs_newline" + sorted_tag_urls+=("$tag_slug") + + if [ "${ENABLE_TAG_RSS:-false}" = true ] && [ -n "$tag_post_slugs_newline" ]; then + local rss_prefill_count=0 + local rss_prefill_slug="" + while IFS= read -r rss_prefill_slug; do + [ -z "$rss_prefill_slug" ] && continue + rss_prefill_occurrences=$((rss_prefill_occurrences + 1)) + rss_prefill_slug_hits["$rss_prefill_slug"]=$(( ${rss_prefill_slug_hits[$rss_prefill_slug]:-0} + 1 )) + if [[ -z "${rss_prefill_slug_set[$rss_prefill_slug]+_}" ]]; then + rss_prefill_slug_set["$rss_prefill_slug"]=1 + rss_prefill_slugs+=("$rss_prefill_slug") + fi + rss_prefill_count=$((rss_prefill_count + 1)) + if [ "$rss_prefill_count" -ge "$rss_item_limit" ]; then + break + fi + done <<< "$tag_post_slugs_newline" + fi + done <<< "$aggregated_tags_data" + + if [ "${ENABLE_TAG_RSS:-false}" = true ] && [ "$rss_prefill_min_hits" -gt 1 ] && [ "${#rss_prefill_slugs[@]}" -gt 0 ]; then + local -a rss_prefill_filtered_slugs=() + local rss_prefill_slug + for rss_prefill_slug in "${rss_prefill_slugs[@]}"; do + if [ "${rss_prefill_slug_hits[$rss_prefill_slug]:-0}" -ge "$rss_prefill_min_hits" ]; then + rss_prefill_filtered_slugs+=("$rss_prefill_slug") + fi + done + if [ "${#rss_prefill_filtered_slugs[@]}" -gt 0 ]; then + rss_prefill_slugs=("${rss_prefill_filtered_slugs[@]}") + fi + fi + + local rss_prefill_pool_count="${#rss_prefill_slugs[@]}" + if [ "${ENABLE_TAG_RSS:-false}" = true ] && [ "$rss_prefill_max_posts" -gt 0 ] && [ "${#rss_prefill_slugs[@]}" -gt "$rss_prefill_max_posts" ]; then + local -a rss_prefill_ranked_lines=() + local rss_prefill_slug + for rss_prefill_slug in "${rss_prefill_slugs[@]}"; do + rss_prefill_ranked_lines+=("${rss_prefill_slug_hits[$rss_prefill_slug]:-0}|$rss_prefill_slug") + done + + local -a rss_prefill_capped_slugs=() + local rss_prefill_rank_line + while IFS= read -r rss_prefill_rank_line; do + [ -z "$rss_prefill_rank_line" ] && continue + rss_prefill_capped_slugs+=("${rss_prefill_rank_line#*|}") + done < <( + printf '%s\n' "${rss_prefill_ranked_lines[@]}" \ + | LC_ALL=C sort -t'|' -k1,1nr -k2,2 \ + | head -n "$rss_prefill_max_posts" + ) + + if [ "${#rss_prefill_capped_slugs[@]}" -gt 0 ]; then + rss_prefill_slugs=("${rss_prefill_capped_slugs[@]}") + fi + fi + + local footer_base="$FOOTER_TEMPLATE" + footer_base=${footer_base//\{\{current_year\}\}/$(date +%Y)} + footer_base=${footer_base//\{\{author_name\}\}/"$AUTHOR_NAME"} + BSSG_RAM_TAG_FOOTER_CONTENT="$footer_base" + + local header_base="$HEADER_TEMPLATE" + header_base=${header_base//\{\{site_title\}\}/"$SITE_TITLE"} + header_base=${header_base//\{\{site_description\}\}/"$SITE_DESCRIPTION"} + header_base=${header_base//\{\{og_description\}\}/"$SITE_DESCRIPTION"} + header_base=${header_base//\{\{twitter_description\}\}/"$SITE_DESCRIPTION"} + header_base=${header_base//\{\{og_type\}\}/"website"} + header_base=${header_base//\{\{site_url\}\}/"$SITE_URL"} + header_base=${header_base//\{\{og_image\}\}/""} + header_base=${header_base//\{\{twitter_image\}\}/""} + header_base=${header_base//\{\{fediverse_creator_meta\}\}/"${SITE_FEDIVERSE_CREATOR_META_TAG}"} + BSSG_RAM_TAG_HEADER_BASE="$header_base" + + local tag_count="${#sorted_tag_urls[@]}" + echo -e "Generating ${GREEN}$tag_count${NC} tag pages from RAM index." + + if [ "${ENABLE_TAG_RSS:-false}" = true ]; then + if declare -F prepare_ram_rss_metadata_cache > /dev/null; then + prepare_ram_rss_metadata_cache + fi + if [ "${RSS_INCLUDE_FULL_CONTENT:-false}" = true ] && declare -F prepare_ram_rss_full_content_cache > /dev/null; then + prepare_ram_rss_full_content_cache + fi + + # Pre-warm RAM RSS item XML cache once in parent process so worker + # subshells inherit it read-only and avoid rebuilding duplicate items. + if declare -F _generate_rss_feed > /dev/null; then + local rss_prefill_post_data="" + local rss_prefill_slug rss_template_entry + for rss_prefill_slug in "${rss_prefill_slugs[@]}"; do + rss_template_entry="${BSSG_RAM_RSS_TEMPLATE_BY_SLUG[$rss_prefill_slug]}" + [ -z "$rss_template_entry" ] && continue + rss_prefill_post_data+="${rss_template_entry//%TAG%/__prefill__}"$'\n' + done + if [ -n "$rss_prefill_post_data" ]; then + if [ "${RAM_MODE_VERBOSE:-false}" = true ]; then + local max_posts_label="unlimited" + if [ "$rss_prefill_max_posts" -gt 0 ]; then + max_posts_label="$rss_prefill_max_posts" + fi + echo -e "DEBUG: Pre-warming RAM RSS item cache for ${#rss_prefill_slugs[@]} posts (${rss_prefill_occurrences} tag-RSS slots, min hits: ${rss_prefill_min_hits}, max posts: ${max_posts_label}, pool: ${rss_prefill_pool_count})." + fi + _generate_rss_feed "/dev/null" "__prefill__" "__prefill__" "/" "/rss.xml" "$rss_prefill_post_data" >/dev/null || true + fi + fi + fi + + if [ "$ram_tags_timing_enabled" = true ]; then + local now_ms + now_ms="$(_bssg_tags_now_ms)" + tags_prep_ms=$((now_ms - tags_phase_start_ms)) + tags_phase_start_ms="$now_ms" + fi + + local tag_url + local cores + cores=$(get_parallel_jobs) + if [ "$cores" -gt "$tag_count" ]; then + cores="$tag_count" + fi + + if [ "$tag_count" -gt 1 ] && [ "$cores" -gt 1 ]; then + local worker_pids=() + local worker_idx + for ((worker_idx = 0; worker_idx < cores; worker_idx++)); do + ( + local idx local_tag_url local_tag + for ((idx = worker_idx; idx < tag_count; idx += cores)); do + local_tag_url="${sorted_tag_urls[$idx]}" + local_tag="${tag_name_by_slug[$local_tag_url]}" + _process_single_tag_page_ram "$local_tag_url" "$local_tag" + done + ) & + worker_pids+=("$!") + done + + local pid + local worker_failed=false + for pid in "${worker_pids[@]}"; do + if ! wait "$pid"; then + worker_failed=true + fi + done + if $worker_failed; then + echo -e "${RED}Parallel RAM-mode tag processing failed.${NC}" + exit 1 + fi + else + for tag_url in "${sorted_tag_urls[@]}"; do + tag="${tag_name_by_slug[$tag_url]}" + _process_single_tag_page_ram "$tag_url" "$tag" + done + fi + + if [ "$ram_tags_timing_enabled" = true ]; then + local now_ms + now_ms="$(_bssg_tags_now_ms)" + tags_render_ms=$((now_ms - tags_phase_start_ms)) + tags_phase_start_ms="$now_ms" + fi + + local header_content="$HEADER_TEMPLATE" + local footer_content="$FOOTER_TEMPLATE" + header_content=${header_content//\{\{site_title\}\}/"$SITE_TITLE"} + header_content=${header_content//\{\{page_title\}\}/"${MSG_ALL_TAGS:-"All Tags"}"} + header_content=${header_content//\{\{site_description\}\}/"$SITE_DESCRIPTION"} + header_content=${header_content//\{\{og_description\}\}/"$SITE_DESCRIPTION"} + header_content=${header_content//\{\{twitter_description\}\}/"$SITE_DESCRIPTION"} + header_content=${header_content//\{\{og_type\}\}/"website"} + header_content=${header_content//\{\{page_url\}\}/"/tags/"} + header_content=${header_content//\{\{site_url\}\}/"$SITE_URL"} + header_content=${header_content///} + local tags_schema_json + tags_schema_json=$(cat < +{ + "@context": "https://schema.org", + "@type": "CollectionPage", + "name": "${MSG_ALL_TAGS:-"All Tags"}", + "description": "List of all tags on $SITE_TITLE", + "url": "$SITE_URL/tags/", + "isPartOf": { + "@type": "WebSite", + "name": "$SITE_TITLE", + "url": "$SITE_URL" + } +} + +EOF +) + header_content=${header_content//\{\{schema_json_ld\}\}/"$tags_schema_json"} + header_content=${header_content//\{\{og_image\}\}/""} + header_content=${header_content//\{\{twitter_image\}\}/""} + header_content=${header_content//\{\{fediverse_creator_meta\}\}/"${SITE_FEDIVERSE_CREATOR_META_TAG}"} + footer_content=${footer_content//\{\{current_year\}\}/$(date +%Y)} + footer_content=${footer_content//\{\{author_name\}\}/"$AUTHOR_NAME"} + + exec 5> "$main_tags_index_output" + printf '%s\n' "$header_content" >&5 + printf '

%s

\n' "${MSG_ALL_TAGS:-All Tags}" >&5 + printf '
\n' >&5 + for tag_url in "${sorted_tag_urls[@]}"; do + tag="${tag_name_by_slug[$tag_url]}" + local post_count="${BSSG_RAM_TAG_POST_COUNT_BY_SLUG[$tag_url]:-0}" + printf ' %s (%s)\n' "$SITE_URL" "$tag_url" "$tag" "$post_count" >&5 + done + printf '
\n' >&5 + printf '%s\n' "$footer_content" >&5 + exec 5>&- + + if [ "$ram_tags_timing_enabled" = true ]; then + local now_ms + now_ms="$(_bssg_tags_now_ms)" + tags_index_ms=$((now_ms - tags_phase_start_ms)) + tags_total_ms=$((now_ms - tags_total_start_ms)) + echo -e "${BLUE}RAM tags sub-timing:${NC}" + echo -e " Prepare maps/cache: $(_bssg_tags_format_ms "$tags_prep_ms")" + echo -e " Tag pages+RSS: $(_bssg_tags_format_ms "$tags_render_ms")" + echo -e " tags/index.html: $(_bssg_tags_format_ms "$tags_index_ms")" + echo -e " Total tags stage: $(_bssg_tags_format_ms "$tags_total_ms")" + fi + + BSSG_RAM_TAG_POST_SLUGS_BY_SLUG=() + BSSG_RAM_TAG_POST_COUNT_BY_SLUG=() + BSSG_RAM_TAG_ARTICLE_HTML_BY_SLUG=() + BSSG_RAM_RSS_TEMPLATE_BY_SLUG=() + BSSG_RAM_TAG_HEADER_BASE="" + BSSG_RAM_TAG_FOOTER_CONTENT="" + BSSG_RAM_TAG_DISPLAY_DATE_FORMAT="" + + echo -e "${GREEN}Tag pages processed!${NC}" +} + # Generate tag pages generate_tag_pages() { + if [ "${BSSG_RAM_MODE:-false}" = true ]; then + _generate_tag_pages_ram + return $? + fi + echo -e "${YELLOW}Processing tag pages${NC}${ENABLE_TAG_RSS:+" and RSS feeds"}...${NC}" local tags_index_file="$CACHE_DIR/tags_index.txt" @@ -285,6 +881,7 @@ EOF # Remove image placeholders header_content=${header_content//\{\{og_image\}\}/""} header_content=${header_content//\{\{twitter_image\}\}/""} + header_content=${header_content//\{\{fediverse_creator_meta\}\}/"${SITE_FEDIVERSE_CREATOR_META_TAG}"} # Replace placeholders in the footer footer_content=${footer_content//\{\{current_year\}\}/$(date +%Y)} @@ -342,7 +939,7 @@ EOF cat >> "$tag_page_html_file" << EOF
-

$title

+

$title

${MSG_PUBLISHED_ON:-"Published on"} $formatted_date ${MSG_BY:-"by"} $display_author_name
EOF @@ -493,9 +1090,8 @@ EOF # Use parallel if [ "${HAS_PARALLEL:-false}" = true ] ; then echo -e "${GREEN}Using GNU parallel to process tag pages${NC}${ENABLE_TAG_RSS:+/feeds}" - local cores=1 - if command -v nproc > /dev/null 2>&1; then cores=$(nproc); - elif command -v sysctl > /dev/null 2>&1; then cores=$(sysctl -n hw.ncpu 2>/dev/null || echo 1); fi + local cores + cores=$(get_parallel_jobs) local jobs=$cores # Use all cores for tags by default if parallel # Export necessary functions and variables @@ -622,6 +1218,7 @@ EOF header_content=${header_content//\{\{schema_json_ld\}\}/"$schema_json_ld"} header_content=${header_content//\{\{og_image\}\}/""} header_content=${header_content//\{\{twitter_image\}\}/""} + header_content=${header_content//\{\{fediverse_creator_meta\}\}/"${SITE_FEDIVERSE_CREATOR_META_TAG}"} # Replace placeholders in the footer footer_content=${footer_content//\{\{current_year\}\}/$(date +%Y)} diff --git a/scripts/build/indexing.sh b/scripts/build/indexing.sh index 044f6aa..d5087bf 100755 --- a/scripts/build/indexing.sh +++ b/scripts/build/indexing.sh @@ -175,12 +175,33 @@ _process_raw_file_index() { } # Optimized file index building - orchestrates raw build and processing +_build_file_index_from_ram() { + while IFS= read -r file; do + [[ -z "$file" ]] && continue + local metadata + metadata=$(extract_metadata "$file") || continue + local filename + filename=$(basename "$file") + echo "$file|$filename|$metadata" + done < <(ram_mode_list_src_files) | sort -t '|' -k 4,4r -k 1,1 +} + optimized_build_file_index() { echo -e "${YELLOW}Building file index...${NC}" local file_index="${CACHE_DIR:-.bssg_cache}/file_index.txt" local index_marker="${CACHE_DIR:-.bssg_cache}/index_marker" local frontmatter_changes_marker="${CACHE_DIR:-.bssg_cache}/frontmatter_changes_marker" + + if [ "${BSSG_RAM_MODE:-false}" = true ] && declare -F ram_mode_list_src_files > /dev/null; then + local file_index_data + file_index_data=$(_build_file_index_from_ram) + ram_mode_set_dataset "file_index" "$file_index_data" + ram_mode_clear_dataset "file_index_prev" + ram_mode_set_dataset "frontmatter_changes_marker" "1" + echo -e "${GREEN}File index built from RAM preload with $(ram_mode_dataset_line_count "file_index") complete entries!${NC}" + return 0 + fi # Check if rebuild is needed if [ "${FORCE_REBUILD:-false}" = false ] && [ -f "$file_index" ] && [ -f "$index_marker" ]; then @@ -293,6 +314,44 @@ build_tags_index() { local tags_index_file="${CACHE_DIR:-.bssg_cache}/tags_index.txt" local frontmatter_changes_marker="${CACHE_DIR:-.bssg_cache}/frontmatter_changes_marker" + if [ "${BSSG_RAM_MODE:-false}" = true ]; then + local file_index_data tags_index_data + file_index_data=$(ram_mode_get_dataset "file_index") + if [ -z "$file_index_data" ]; then + ram_mode_set_dataset "tags_index" "" + ram_mode_clear_dataset "has_tags" + echo -e "${GREEN}Tags index built!${NC}" + return 0 + fi + + tags_index_data=$(printf '%s\n' "$file_index_data" | awk -F'|' -v OFS='|' ' + { + if (length($6) > 0) { + split($6, tags_array, ","); + for (i in tags_array) { + tag = tags_array[i]; + gsub(/^[[:space:]]+|[[:space:]]+$/, "", tag); + if (length(tag) == 0) continue; + + tag_slug = tolower(tag); + gsub(/[^a-z0-9]+/, "-", tag_slug); + gsub(/^-+|-+$/, "", tag_slug); + if (length(tag_slug) == 0) tag_slug = "-"; + + print tag, tag_slug, $3, $4, $5, $2, $7, $8, $9, $10, $11, $12; + } + } + }') + ram_mode_set_dataset "tags_index" "$tags_index_data" + if [ -n "$tags_index_data" ]; then + ram_mode_set_dataset "has_tags" "1" + else + ram_mode_clear_dataset "has_tags" + fi + echo -e "${GREEN}Tags index built!${NC}" + return 0 + fi + # --- Optimized Rebuild Check --- START --- local rebuild_needed=false local reason="" @@ -376,6 +435,39 @@ build_authors_index() { local file_index="${CACHE_DIR:-.bssg_cache}/file_index.txt" local authors_index_file="${CACHE_DIR:-.bssg_cache}/authors_index.txt" + if [ "${BSSG_RAM_MODE:-false}" = true ]; then + local file_index_data authors_index_data + file_index_data=$(ram_mode_get_dataset "file_index") + if [ -z "$file_index_data" ]; then + ram_mode_set_dataset "authors_index" "" + ram_mode_clear_dataset "has_authors" + echo -e "${GREEN}Authors index built!${NC}" + return 0 + fi + + authors_index_data=$(printf '%s\n' "$file_index_data" | awk -F'|' -v OFS='|' ' + { + author_name = $11; + author_email = $12; + if (length(author_name) == 0) next; + + author_slug = tolower(author_name); + gsub(/[^a-z0-9]+/, "-", author_slug); + gsub(/^-+|-+$/, "", author_slug); + if (length(author_slug) == 0) author_slug = "anonymous"; + + print author_name, author_slug, author_email, $3, $4, $5, $2, $7, $8, $9, $10; + }') + ram_mode_set_dataset "authors_index" "$authors_index_data" + if [ -n "$authors_index_data" ]; then + ram_mode_set_dataset "has_authors" "1" + else + ram_mode_clear_dataset "has_authors" + fi + echo -e "${GREEN}Authors index built!${NC}" + return 0 + fi + # Check if rebuild is needed: missing cache or input/dependencies changed local rebuild_needed=false if [ ! -f "$authors_index_file" ]; then @@ -443,6 +535,18 @@ identify_affected_authors() { export AFFECTED_AUTHORS="" export AUTHORS_INDEX_NEEDS_REBUILD="false" + if [ "${BSSG_RAM_MODE:-false}" = true ]; then + local authors_index_data + authors_index_data=$(ram_mode_get_dataset "authors_index") + if [ -n "$authors_index_data" ]; then + AFFECTED_AUTHORS=$(printf '%s\n' "$authors_index_data" | awk -F'|' 'NF { print $1 }' | sort -u | tr '\n' ' ') + AUTHORS_INDEX_NEEDS_REBUILD="true" + fi + export AFFECTED_AUTHORS + export AUTHORS_INDEX_NEEDS_REBUILD + return 0 + fi + # If previous index doesn't exist, all authors in the current index are affected, # and the main index needs rebuilding. if [ ! -f "$authors_index_prev_file" ]; then @@ -519,6 +623,43 @@ build_archive_index() { local file_index="${CACHE_DIR:-.bssg_cache}/file_index.txt" local archive_index_file="${CACHE_DIR:-.bssg_cache}/archive_index.txt" + if [ "${BSSG_RAM_MODE:-false}" = true ]; then + local file_index_data archive_index_data="" + file_index_data=$(ram_mode_get_dataset "file_index") + if [ -z "$file_index_data" ]; then + ram_mode_set_dataset "archive_index" "" + echo -e "${GREEN}Archive index built!${NC}" + return 0 + fi + + local line file filename title date lastmod tags slug image image_caption description author_name author_email + while IFS= read -r line; do + [ -z "$line" ] && continue + IFS='|' read -r file filename title date lastmod tags slug image image_caption description author_name author_email <<< "$line" + [ -z "$date" ] && continue + + local year month month_name + if [[ "$date" =~ ^([0-9]{4})[-/]([0-9]{1,2})[-/]([0-9]{1,2}) ]]; then + year="${BASH_REMATCH[1]}" + month=$(printf "%02d" "$((10#${BASH_REMATCH[2]}))") + else + continue + fi + + local month_name_var="MSG_MONTH_${month}" + month_name="${!month_name_var}" + if [[ -z "$month_name" ]]; then + month_name="$month" + fi + + archive_index_data+="$year|$month|$month_name|$title|$date|$lastmod|$filename.html|$slug|$image|$image_caption|$description|$author_name|$author_email"$'\n' + done <<< "$file_index_data" + + ram_mode_set_dataset "archive_index" "$archive_index_data" + echo -e "${GREEN}Archive index built!${NC}" + return 0 + fi + # Check if rebuild is needed: missing cache or input/dependencies changed local rebuild_needed=false if [ ! -f "$archive_index_file" ]; then @@ -604,6 +745,18 @@ identify_affected_archive_months() { export AFFECTED_ARCHIVE_MONTHS="" export ARCHIVE_INDEX_NEEDS_REBUILD="false" + if [ "${BSSG_RAM_MODE:-false}" = true ]; then + local archive_index_data + archive_index_data=$(ram_mode_get_dataset "archive_index") + if [ -n "$archive_index_data" ]; then + AFFECTED_ARCHIVE_MONTHS=$(printf '%s\n' "$archive_index_data" | awk -F'|' 'NF { print $1 "|" $2 }' | sort -u | tr '\n' ' ') + ARCHIVE_INDEX_NEEDS_REBUILD="true" + fi + export AFFECTED_ARCHIVE_MONTHS + export ARCHIVE_INDEX_NEEDS_REBUILD + return 0 + fi + # If previous index doesn't exist, all months in the current index are affected, # and the main index needs rebuilding. if [ ! -f "$archive_index_prev_file" ]; then @@ -673,4 +826,4 @@ identify_affected_archive_months() { trap - RETURN # Remove trap upon successful completion } -# --- Indexing Functions --- END --- \ No newline at end of file +# --- Indexing Functions --- END --- diff --git a/scripts/build/main.sh b/scripts/build/main.sh index ec2db28..678c316 100755 --- a/scripts/build/main.sh +++ b/scripts/build/main.sh @@ -13,6 +13,7 @@ BUILD_START_TIME=$(date +%s) SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )" # Determine the project root (one level up from the SCRIPT_DIR's parent) PROJECT_ROOT="$( dirname "$( dirname "$SCRIPT_DIR" )" )" +export BSSG_PROJECT_ROOT="$PROJECT_ROOT" # Check if PROJECT_ROOT is already the current directory to avoid unnecessary cd if [ "$PWD" != "$PROJECT_ROOT" ]; then echo "Changing directory to project root: $PROJECT_ROOT" @@ -81,25 +82,180 @@ fi # shellcheck source=utils.sh source "${SCRIPT_DIR}/utils.sh" || { echo -e "\033[0;31mError: Failed to source utils.sh\033[0m"; exit 1; } +# Build mode validation and setup +BUILD_MODE="${BUILD_MODE:-normal}" +case "$BUILD_MODE" in + normal|ram) ;; + *) + echo -e "${RED}Error: Invalid BUILD_MODE '$BUILD_MODE'. Use 'normal' or 'ram'.${NC}" >&2 + exit 1 + ;; +esac +export BUILD_MODE +export BSSG_RAM_MODE=false + # Print the theme being used for this build (final value after potential random selection) echo -e "${GREEN}Using theme: ${THEME}${NC}" echo "Loaded utilities." +# --- RAM Mode Stage Timing --- START --- +BSSG_RAM_TIMING_ENABLED=false +if [ "$BUILD_MODE" = "ram" ]; then + BSSG_RAM_TIMING_ENABLED=true +fi +declare -ga BSSG_RAM_TIMING_STAGE_KEYS=() +declare -ga BSSG_RAM_TIMING_STAGE_LABELS=() +declare -ga BSSG_RAM_TIMING_STAGE_MS=() +BSSG_RAM_TIMING_STAGE_ACTIVE=false +BSSG_RAM_TIMING_CURRENT_STAGE_KEY="" +BSSG_RAM_TIMING_CURRENT_STAGE_LABEL="" +BSSG_RAM_TIMING_CURRENT_STAGE_START_MS=0 + +_bssg_ram_timing_now_ms() { + if [ -n "${EPOCHREALTIME:-}" ]; then + local epoch_norm sec frac ms_part + # Some locales expose EPOCHREALTIME with ',' instead of '.' as decimal separator. + epoch_norm="${EPOCHREALTIME/,/.}" + if [[ "$epoch_norm" =~ ^([0-9]+)([.][0-9]+)?$ ]]; then + sec="${BASH_REMATCH[1]}" + frac="${BASH_REMATCH[2]#.}" + frac="${frac}000" + ms_part="${frac:0:3}" + printf '%s\n' $(( 10#$sec * 1000 + 10#$ms_part )) + return + fi + fi + + if command -v perl >/dev/null 2>&1; then + perl -MTime::HiRes=time -e 'printf("%.0f\n", time()*1000)' + else + printf '%s\n' $(( $(date +%s) * 1000 )) + fi +} + +_bssg_ram_timing_format_ms() { + local ms="$1" + printf '%d.%03ds' $((ms / 1000)) $((ms % 1000)) +} + +bssg_ram_timing_start() { + if [ "$BSSG_RAM_TIMING_ENABLED" != true ]; then + return + fi + + if [ "$BSSG_RAM_TIMING_STAGE_ACTIVE" = true ]; then + bssg_ram_timing_end + fi + + BSSG_RAM_TIMING_CURRENT_STAGE_KEY="$1" + BSSG_RAM_TIMING_CURRENT_STAGE_LABEL="$2" + BSSG_RAM_TIMING_CURRENT_STAGE_START_MS="$(_bssg_ram_timing_now_ms)" + BSSG_RAM_TIMING_STAGE_ACTIVE=true +} + +bssg_ram_timing_end() { + if [ "$BSSG_RAM_TIMING_ENABLED" != true ] || [ "$BSSG_RAM_TIMING_STAGE_ACTIVE" != true ]; then + return + fi + + local end_ms elapsed_ms + end_ms="$(_bssg_ram_timing_now_ms)" + elapsed_ms=$((end_ms - BSSG_RAM_TIMING_CURRENT_STAGE_START_MS)) + if [ "$elapsed_ms" -lt 0 ]; then + elapsed_ms=0 + fi + + BSSG_RAM_TIMING_STAGE_KEYS+=("$BSSG_RAM_TIMING_CURRENT_STAGE_KEY") + BSSG_RAM_TIMING_STAGE_LABELS+=("$BSSG_RAM_TIMING_CURRENT_STAGE_LABEL") + BSSG_RAM_TIMING_STAGE_MS+=("$elapsed_ms") + + BSSG_RAM_TIMING_STAGE_ACTIVE=false + BSSG_RAM_TIMING_CURRENT_STAGE_KEY="" + BSSG_RAM_TIMING_CURRENT_STAGE_LABEL="" + BSSG_RAM_TIMING_CURRENT_STAGE_START_MS=0 +} + +bssg_ram_timing_print_summary() { + if [ "$BSSG_RAM_TIMING_ENABLED" != true ]; then + return + fi + + # Close any open stage (defensive; build flow should end stages explicitly). + if [ "$BSSG_RAM_TIMING_STAGE_ACTIVE" = true ]; then + bssg_ram_timing_end + fi + + local count="${#BSSG_RAM_TIMING_STAGE_MS[@]}" + if [ "$count" -eq 0 ]; then + return + fi + + local total_ms=0 + local max_ms=0 + local max_label="" + local i + for ((i = 0; i < count; i++)); do + local stage_ms="${BSSG_RAM_TIMING_STAGE_MS[$i]}" + total_ms=$((total_ms + stage_ms)) + if [ "$stage_ms" -gt "$max_ms" ]; then + max_ms="$stage_ms" + max_label="${BSSG_RAM_TIMING_STAGE_LABELS[$i]}" + fi + done + + echo "------------------------------------------------------" + echo -e "${GREEN}RAM mode timing summary:${NC}" + printf " %-26s %12s %10s\n" "Stage" "Duration" "Share" + for ((i = 0; i < count; i++)); do + local stage_label="${BSSG_RAM_TIMING_STAGE_LABELS[$i]}" + local stage_ms="${BSSG_RAM_TIMING_STAGE_MS[$i]}" + local pct_tenths=0 + if [ "$total_ms" -gt 0 ]; then + pct_tenths=$(( (stage_ms * 1000 + total_ms / 2) / total_ms )) + fi + local formatted_ms + formatted_ms="$(_bssg_ram_timing_format_ms "$stage_ms")" + printf " %-26s %12s %6d.%d%%\n" "$stage_label" "$formatted_ms" $((pct_tenths / 10)) $((pct_tenths % 10)) + done + echo -e " ${GREEN}Total (timed stages):$(_bssg_ram_timing_format_ms "$total_ms")${NC}" + if [ -n "$max_label" ]; then + echo -e " ${YELLOW}Slowest stage:${NC} ${max_label} ($(_bssg_ram_timing_format_ms "$max_ms"))" + fi +} +# --- RAM Mode Stage Timing --- END --- + # Check Dependencies # shellcheck source=deps.sh +bssg_ram_timing_start "dependencies" "Dependencies" source "${SCRIPT_DIR}/deps.sh" || { echo -e "${RED}Error: Failed to source deps.sh${NC}"; exit 1; } check_dependencies # Call the function to perform checks and export HAS_PARALLEL +bssg_ram_timing_end + +if [ "$BUILD_MODE" = "ram" ]; then + export BSSG_RAM_MODE=true + export FORCE_REBUILD=true + + # shellcheck source=ram_mode.sh + source "${SCRIPT_DIR}/ram_mode.sh" || { echo -e "${RED}Error: Failed to source ram_mode.sh${NC}"; exit 1; } + print_info "RAM mode enabled: source/template files and build indexes are held in memory." + print_info "RAM mode parallel worker cap: ${RAM_MODE_MAX_JOBS:-6} (set RAM_MODE_MAX_JOBS to tune)." +fi + echo "Checked dependencies. Parallel available: ${HAS_PARALLEL:-false}" # Source Cache Manager (defines cache functions) # shellcheck source=cache.sh +bssg_ram_timing_start "cache_setup" "Cache Setup/Clean" source "${SCRIPT_DIR}/cache.sh" || { echo -e "${RED}Error: Failed to source cache.sh${NC}"; exit 1; } echo "Loaded cache manager." # Check if config changed BEFORE updating the hash file, store status for later use BSSG_CONFIG_CHANGED_STATUS=1 # Default to 1 (not changed) -if config_has_changed; then +if [ "${BSSG_RAM_MODE:-false}" = true ]; then + # RAM mode is intentionally ephemeral, always rebuild from preloaded inputs. + BSSG_CONFIG_CHANGED_STATUS=0 +elif config_has_changed; then BSSG_CONFIG_CHANGED_STATUS=0 # Set to 0 (changed) fi export BSSG_CONFIG_CHANGED_STATUS @@ -118,17 +274,21 @@ fi # --- Add check for CLEAN_OUTPUT influencing FORCE_REBUILD --- END --- # Handle --force-rebuild first -if [ "${FORCE_REBUILD:-false}" = true ]; then +if [ "${BSSG_RAM_MODE:-false}" != true ] && [ "${FORCE_REBUILD:-false}" = true ]; then echo -e "${YELLOW}Force rebuild enabled, deleting entire cache directory (${CACHE_DIR:-.bssg_cache})...${NC}" rm -rf "${CACHE_DIR:-.bssg_cache}" echo -e "${GREEN}Cache deleted!${NC}" fi -echo "Ensuring cache directory structure exists... (${CACHE_DIR:-.bssg_cache})" -mkdir -p "${CACHE_DIR:-.bssg_cache}/meta" "${CACHE_DIR:-.bssg_cache}/content" +if [ "${BSSG_RAM_MODE:-false}" != true ]; then + echo "Ensuring cache directory structure exists... (${CACHE_DIR:-.bssg_cache})" + mkdir -p "${CACHE_DIR:-.bssg_cache}/meta" "${CACHE_DIR:-.bssg_cache}/content" -# Create initial config hash *after* ensuring cache dir exists -create_config_hash + # Create initial config hash *after* ensuring cache dir exists + create_config_hash +else + echo "RAM mode: skipping cache directory creation and config hash persistence." +fi # --- Initial Cache Setup & Cleaning --- END # Handle --clean-output flag (using logic moved from original main/clean_output_directory) @@ -148,10 +308,12 @@ if [ "${CLEAN_OUTPUT:-false}" = true ]; then echo -e "${YELLOW}Output directory (${OUTPUT_DIR:-output}) does not exist, no need to clean.${NC}" fi fi +bssg_ram_timing_end # Source Content Processor (defines functions like extract_metadata, convert_markdown_to_html) # Moved up before indexing as indexing uses some content functions (e.g., generate_slug) # shellcheck source=content.sh +bssg_ram_timing_start "index_build" "Index/Data Build" source "${SCRIPT_DIR}/content.sh" || { echo -e "${RED}Error: Failed to source content.sh${NC}"; exit 1; } echo "Loaded content processing functions." @@ -161,17 +323,23 @@ echo "Loaded content processing functions." source "${SCRIPT_DIR}/indexing.sh" || { echo -e "${RED}Error: Failed to source indexing.sh${NC}"; exit 1; } echo "Loaded indexing functions." +if [ "${BSSG_RAM_MODE:-false}" = true ]; then + ram_mode_preload_inputs || { echo -e "${RED}Error: RAM preload failed.${NC}"; exit 1; } +fi + # --- Build Intermediate Indexes --- # Moved up before preload_templates # --- Start Change: Snapshot previous file index --- file_index_file="${CACHE_DIR:-.bssg_cache}/file_index.txt" file_index_prev_file="${CACHE_DIR:-.bssg_cache}/file_index_prev.txt" -if [ -f "$file_index_file" ]; then - echo "Snapshotting previous file index to $file_index_prev_file" >&2 # Debug - cp "$file_index_file" "$file_index_prev_file" -else - # Ensure previous file doesn't exist if current doesn't - rm -f "$file_index_prev_file" +if [ "${BSSG_RAM_MODE:-false}" != true ]; then + if [ -f "$file_index_file" ]; then + echo "Snapshotting previous file index to $file_index_prev_file" >&2 # Debug + cp "$file_index_file" "$file_index_prev_file" + else + # Ensure previous file doesn't exist if current doesn't + rm -f "$file_index_prev_file" + fi fi # --- End Change --- optimized_build_file_index || { echo -e "${RED}Error: Failed to build file index.${NC}"; exit 1; } @@ -179,12 +347,14 @@ optimized_build_file_index || { echo -e "${RED}Error: Failed to build file index # --- Start Change: Snapshot previous tags index --- tags_index_file="${CACHE_DIR:-.bssg_cache}/tags_index.txt" tags_index_prev_file="${CACHE_DIR:-.bssg_cache}/tags_index_prev.txt" -if [ -f "$tags_index_file" ]; then - echo "Snapshotting previous tags index to $tags_index_prev_file" >&2 # Debug - cp "$tags_index_file" "$tags_index_prev_file" -else - # Ensure previous file doesn't exist if current doesn't - rm -f "$tags_index_prev_file" +if [ "${BSSG_RAM_MODE:-false}" != true ]; then + if [ -f "$tags_index_file" ]; then + echo "Snapshotting previous tags index to $tags_index_prev_file" >&2 # Debug + cp "$tags_index_file" "$tags_index_prev_file" + else + # Ensure previous file doesn't exist if current doesn't + rm -f "$tags_index_prev_file" + fi fi # --- End Change --- @@ -199,12 +369,14 @@ build_tags_index || { echo -e "${RED}Error: Failed to build tags index.${NC}"; e # --- Start Change: Snapshot previous authors index --- authors_index_file="${CACHE_DIR:-.bssg_cache}/authors_index.txt" authors_index_prev_file="${CACHE_DIR:-.bssg_cache}/authors_index_prev.txt" -if [ -f "$authors_index_file" ]; then - echo "Snapshotting previous authors index to $authors_index_prev_file" >&2 # Debug - cp "$authors_index_file" "$authors_index_prev_file" -else - # Ensure previous file doesn't exist if current doesn't - rm -f "$authors_index_prev_file" +if [ "${BSSG_RAM_MODE:-false}" != true ]; then + if [ -f "$authors_index_file" ]; then + echo "Snapshotting previous authors index to $authors_index_prev_file" >&2 # Debug + cp "$authors_index_file" "$authors_index_prev_file" + else + # Ensure previous file doesn't exist if current doesn't + rm -f "$authors_index_prev_file" + fi fi # --- End Change --- @@ -218,12 +390,14 @@ if [ "${ENABLE_ARCHIVES:-false}" = true ]; then # --- Start Change: Snapshot previous archive index --- archive_index_file="${CACHE_DIR:-.bssg_cache}/archive_index.txt" archive_index_prev_file="${CACHE_DIR:-.bssg_cache}/archive_index_prev.txt" - if [ -f "$archive_index_file" ]; then - echo "Snapshotting previous archive index to $archive_index_prev_file" >&2 # Debug - cp "$archive_index_file" "$archive_index_prev_file" - else - # Ensure previous file doesn't exist if current doesn't - rm -f "$archive_index_prev_file" + if [ "${BSSG_RAM_MODE:-false}" != true ]; then + if [ -f "$archive_index_file" ]; then + echo "Snapshotting previous archive index to $archive_index_prev_file" >&2 # Debug + cp "$archive_index_file" "$archive_index_prev_file" + else + # Ensure previous file doesn't exist if current doesn't + rm -f "$archive_index_prev_file" + fi fi # --- End Change --- build_archive_index || { echo -e "${RED}Error: Failed to build archive index.${NC}"; exit 1; } @@ -232,10 +406,12 @@ if [ "${ENABLE_ARCHIVES:-false}" = true ]; then # --- End Change --- fi echo "Built intermediate cache indexes." +bssg_ram_timing_end # Load Templates (and generate dynamic menus, exports vars like HEADER_TEMPLATE) # Moved down after indexing # shellcheck source=templates.sh +bssg_ram_timing_start "templates" "Template Prep" source "${SCRIPT_DIR}/templates.sh" || { echo -e "${RED}Error: Failed to source templates.sh${NC}"; exit 1; } preload_templates # Call the function echo "Loaded and processed templates." @@ -279,6 +455,7 @@ fi export BSSG_MAX_TEMPLATE_LOCALE_TIME=$latest_template_locale_time echo "Latest template/locale time: $BSSG_MAX_TEMPLATE_LOCALE_TIME (Header: $header_time, Footer: $footer_time, Locale: $locale_time)" # --- Pre-calculate Max Template/Locale Time --- END --- +bssg_ram_timing_end # --- Prepare for Parallel Processing --- if [ "${HAS_PARALLEL:-false}" = true ]; then @@ -311,32 +488,39 @@ fi # --- Generate Content HTML --- # Source and run Post Generator # shellcheck source=generate_posts.sh +bssg_ram_timing_start "posts" "Posts" source "${SCRIPT_DIR}/generate_posts.sh" || { echo -e "${RED}Error: Failed to source generate_posts.sh${NC}"; exit 1; } process_all_markdown_files || { echo -e "${RED}Error: Post processing failed.${NC}"; exit 1; } echo "Generated post HTML files." +bssg_ram_timing_end # --- Post Generation --- END --- # --- Page Generation --- START -- # Source the page generation script # shellcheck source=generate_pages.sh disable=SC1091 +bssg_ram_timing_start "pages" "Static Pages" source "$SCRIPT_DIR/generate_pages.sh" || { echo -e "${RED}Error: Failed to source generate_pages.sh${NC}"; exit 1; } # Call the main page processing function process_all_pages || { echo -e "${RED}Error: Page processing failed.${NC}"; exit 1; } +bssg_ram_timing_end # --- Page Generation --- END --- # --- Tag Page Generation --- START --- # Source and run Tag Page Generator # shellcheck source=generate_tags.sh disable=SC1091 +bssg_ram_timing_start "tags" "Tags" source "$SCRIPT_DIR/generate_tags.sh" || { echo -e "${RED}Error: Failed to source generate_tags.sh${NC}"; exit 1; } # Call the main function from the sourced script generate_tag_pages || { echo -e "${RED}Error: Tag page generation failed.${NC}"; exit 1; } echo "Generated tag list pages." +bssg_ram_timing_end # --- Tag Page Generation --- END --- # --- Author Page Generation --- START --- # Source and run Author Page Generator (if enabled) if [ "${ENABLE_AUTHOR_PAGES:-true}" = true ]; then + bssg_ram_timing_start "authors" "Authors" # shellcheck source=generate_authors.sh disable=SC1091 source "$SCRIPT_DIR/generate_authors.sh" || { echo -e "${RED}Error: Failed to source generate_authors.sh${NC}"; exit 1; } @@ -344,12 +528,14 @@ if [ "${ENABLE_AUTHOR_PAGES:-true}" = true ]; then # It will internally use AFFECTED_AUTHORS and AUTHORS_INDEX_NEEDS_REBUILD generate_author_pages || { echo -e "${RED}Error: Author page generation failed.${NC}"; exit 1; } echo "Generated author pages." + bssg_ram_timing_end fi # --- Author Page Generation --- END --- # --- Archive Page Generation --- START --- # Source and run Archive Page Generator (if enabled) if [ "${ENABLE_ARCHIVES:-false}" = true ]; then + bssg_ram_timing_start "archives" "Archives" # Source the script (loads functions) # shellcheck source=generate_archives.sh disable=SC1091 source "$SCRIPT_DIR/generate_archives.sh" || { echo -e "${RED}Error: Failed to source generate_archives.sh${NC}"; exit 1; } @@ -358,28 +544,68 @@ if [ "${ENABLE_ARCHIVES:-false}" = true ]; then # It will internally use AFFECTED_ARCHIVE_MONTHS and ARCHIVE_INDEX_NEEDS_REBUILD generate_archive_pages || { echo -e "${RED}Error: Archive page generation failed.${NC}"; exit 1; } echo "Generated archive pages." + bssg_ram_timing_end fi # --- Archive Page Generation --- END --- # --- Main Index Page Generation --- START --- # Source and run Main Index Page Generator # shellcheck source=generate_index.sh disable=SC1091 +bssg_ram_timing_start "main_index" "Main Index" source "$SCRIPT_DIR/generate_index.sh" || { echo -e "${RED}Error: Failed to source generate_index.sh${NC}"; exit 1; } # Call the main function from the sourced script generate_index || { echo -e "${RED}Error: Index page generation failed.${NC}"; exit 1; } echo "Generated main index/pagination pages." +bssg_ram_timing_end # --- Main Index Page Generation --- END --- # --- Feed Generation --- START --- # Source and run Feed Generator # shellcheck source=generate_feeds.sh disable=SC1091 +bssg_ram_timing_start "feeds" "Sitemap/RSS" source "$SCRIPT_DIR/generate_feeds.sh" || { echo -e "${RED}Error: Failed to source generate_feeds.sh${NC}"; exit 1; } # Call the functions from the sourced script echo "Timing sitemap generation..." -generate_sitemap || echo -e "${YELLOW}Sitemap generation failed, continuing build...${NC}" # Allow failure -echo "Timing RSS feed generation..." -generate_rss || echo -e "${YELLOW}RSS feed generation failed, continuing build...${NC}" # Allow failure +if [ "${BSSG_RAM_MODE:-false}" = true ]; then + echo "Timing RSS feed generation..." + feed_jobs=0 + feed_jobs=$(get_parallel_jobs) + if [ "$feed_jobs" -gt 1 ]; then + echo "RAM mode: generating sitemap and RSS in parallel..." + + sitemap_failed=false + rss_failed=false + + generate_sitemap & + sitemap_pid=$! + + generate_rss & + rss_pid=$! + + if ! wait "$sitemap_pid"; then + sitemap_failed=true + fi + if ! wait "$rss_pid"; then + rss_failed=true + fi + + if $sitemap_failed; then + echo -e "${YELLOW}Sitemap generation failed, continuing build...${NC}" + fi + if $rss_failed; then + echo -e "${YELLOW}RSS feed generation failed, continuing build...${NC}" + fi + else + generate_sitemap || echo -e "${YELLOW}Sitemap generation failed, continuing build...${NC}" # Allow failure + generate_rss || echo -e "${YELLOW}RSS feed generation failed, continuing build...${NC}" # Allow failure + fi +else + generate_sitemap || echo -e "${YELLOW}Sitemap generation failed, continuing build...${NC}" # Allow failure + echo "Timing RSS feed generation..." + generate_rss || echo -e "${YELLOW}RSS feed generation failed, continuing build...${NC}" # Allow failure +fi echo "Generated RSS feed and sitemap." +bssg_ram_timing_end # --- Feed Generation --- END --- # --- Secondary Pages Index Generation --- START --- @@ -389,10 +615,12 @@ echo "Generated RSS feed and sitemap." # We attempt to reconstruct the array from the exported string. # shellcheck disable=SC2154 # SECONDARY_PAGES is exported by templates.sh if [ -n "$SECONDARY_PAGES" ] && [ "$SECONDARY_PAGES" != "()" ]; then + bssg_ram_timing_start "secondary_index" "Secondary Index" # shellcheck source=generate_secondary_pages.sh disable=SC1091 source "$SCRIPT_DIR/generate_secondary_pages.sh" || { echo -e "${RED}Error: Failed to source generate_secondary_pages.sh${NC}"; exit 1; } generate_pages_index || echo -e "${YELLOW}Secondary pages index generation failed, continuing build...${NC}" # Allow failure echo "Generated secondary pages index." + bssg_ram_timing_end else echo "No secondary pages defined, skipping secondary index generation." fi @@ -401,6 +629,7 @@ fi # --- Asset Handling --- START --- # Source the asset handling script # shellcheck source=assets.sh disable=SC1091 +bssg_ram_timing_start "assets" "Assets/CSS" source "$SCRIPT_DIR/assets.sh" || { echo -e "${RED}Error: Failed to source assets.sh${NC}"; exit 1; } # Copy static assets echo "Timing static files copy..." @@ -409,41 +638,60 @@ copy_static_files || { echo -e "${RED}Error: Failed to copy static assets.${NC}" echo "Timing CSS/Theme processing..." create_css "$OUTPUT_DIR" "$THEME" || { echo -e "${RED}Error: Failed to process CSS.${NC}"; exit 1; } # Pass OUTPUT_DIR and THEME echo "Handled static assets and CSS." +bssg_ram_timing_end # --- Asset Handling --- END --- # --- Post Processing --- START --- # Source and run Post Processor # shellcheck source=post_process.sh disable=SC1091 +bssg_ram_timing_start "post_process" "Post Processing" source "$SCRIPT_DIR/post_process.sh" || { echo -e "${RED}Error: Failed to source post_process.sh${NC}"; exit 1; } echo "Timing URL post-processing..." post_process_urls || echo -e "${YELLOW}URL post-processing failed, continuing...${NC}" # Allow failure echo "Timing output permissions fix..." fix_output_permissions || echo -e "${YELLOW}Fixing output permissions failed, continuing...${NC}" # Allow failure echo "Completed post-processing." +bssg_ram_timing_end # --- Post Processing --- END --- # --- Final Cache Update --- START --- -create_config_hash +if [ "${BSSG_RAM_MODE:-false}" != true ]; then + create_config_hash +fi # --- Final Cache Update --- END --- # --- Final Cleanup --- START --- -echo "Cleaning up previous index files..." -rm -f "${CACHE_DIR:-.bssg_cache}/file_index_prev.txt" -rm -f "${CACHE_DIR:-.bssg_cache}/tags_index_prev.txt" -rm -f "${CACHE_DIR:-.bssg_cache}/authors_index_prev.txt" -rm -f "${CACHE_DIR:-.bssg_cache}/archive_index_prev.txt" +if [ "${BSSG_RAM_MODE:-false}" != true ]; then + echo "Cleaning up previous index files..." + rm -f "${CACHE_DIR:-.bssg_cache}/file_index_prev.txt" + rm -f "${CACHE_DIR:-.bssg_cache}/tags_index_prev.txt" + rm -f "${CACHE_DIR:-.bssg_cache}/authors_index_prev.txt" + rm -f "${CACHE_DIR:-.bssg_cache}/archive_index_prev.txt" -# Remove the frontmatter changes marker if it exists -rm -f "${CACHE_DIR:-.bssg_cache}/frontmatter_changes_marker" + # Remove the frontmatter changes marker if it exists + rm -f "${CACHE_DIR:-.bssg_cache}/frontmatter_changes_marker" -# Clean up related posts temporary files to prevent unnecessary cache invalidation on next build -rm -f "${CACHE_DIR:-.bssg_cache}/modified_tags.list" -rm -f "${CACHE_DIR:-.bssg_cache}/modified_authors.list" -rm -f "${CACHE_DIR:-.bssg_cache}/related_posts_invalidated.list" + # Clean up related posts temporary files to prevent unnecessary cache invalidation on next build + rm -f "${CACHE_DIR:-.bssg_cache}/modified_tags.list" + rm -f "${CACHE_DIR:-.bssg_cache}/modified_authors.list" + rm -f "${CACHE_DIR:-.bssg_cache}/related_posts_invalidated.list" +fi # --- Final Cleanup --- END --- # --- Pre-compress Assets --- START --- +_precompress_single_file() { + local file="$1" + local gzfile="$2" + local compression_level="$3" + local verbose_logs="$4" + + if [ "$verbose_logs" = "true" ]; then + echo "Compressing: $file" + fi + gzip -c "-${compression_level}" -- "$file" > "$gzfile" +} + precompress_assets() { # Check if pre-compression is enabled in the config. if [ ! "${PRECOMPRESS_ASSETS:-false}" = "true" ]; then @@ -451,6 +699,11 @@ precompress_assets() { fi echo "Starting pre-compression of assets..." + local compression_level="${PRECOMPRESS_GZIP_LEVEL:-9}" + if ! [[ "$compression_level" =~ ^[1-9]$ ]]; then + compression_level=9 + fi + local verbose_logs="${PRECOMPRESS_VERBOSE:-${RAM_MODE_VERBOSE:-false}}" # 1. Cleanup: Remove any .gz file that does not have a corresponding original file. # This handles cases where original files were deleted. @@ -465,25 +718,63 @@ precompress_assets() { # 2. Compression: Compress text files if they are new or have been updated. # We target .html, .css, .xml and .js files. - find "${OUTPUT_DIR}" -type f \( -name "*.html" -o -name "*.css" -o -name "*.xml" -o -name "*.js" \) -print0 | while IFS= read -r -d '' file; do - gzfile="${file}.gz" + local changed_files=() + while IFS= read -r -d '' file; do + local gzfile="${file}.gz" # Compress if the .gz file doesn't exist, or if the original file is newer. if [ ! -f "$gzfile" ] || [ "$file" -nt "$gzfile" ]; then - echo "Compressing: $file" - # Use gzip with best compression (-9) and write to stdout, then redirect. - # This is a robust way to handle output and overwriting. - gzip -c -9 -- "$file" > "$gzfile" + changed_files+=("$file") fi - done + done < <(find "${OUTPUT_DIR}" -type f \( -name "*.html" -o -name "*.css" -o -name "*.xml" -o -name "*.js" \) -print0) + + if [ "${#changed_files[@]}" -eq 0 ]; then + echo "No changed assets to pre-compress." + echo "Asset pre-compression finished." + return + fi + + local compress_jobs + compress_jobs=$(get_parallel_jobs "${PRECOMPRESS_MAX_JOBS:-0}") + if [ "$compress_jobs" -gt "${#changed_files[@]}" ]; then + compress_jobs="${#changed_files[@]}" + fi + + if [ "$compress_jobs" -gt 1 ]; then + local file gzfile q_file q_gzfile q_level q_verbose + q_level=$(printf '%q' "$compression_level") + q_verbose=$(printf '%q' "$verbose_logs") + run_parallel "$compress_jobs" < <( + for file in "${changed_files[@]}"; do + gzfile="${file}.gz" + q_file=$(printf '%q' "$file") + q_gzfile=$(printf '%q' "$gzfile") + printf "_precompress_single_file %s %s %s %s\n" "$q_file" "$q_gzfile" "$q_level" "$q_verbose" + done + ) || { echo -e "${RED}Asset pre-compression failed.${NC}"; return 1; } + else + local file gzfile + for file in "${changed_files[@]}"; do + gzfile="${file}.gz" + _precompress_single_file "$file" "$gzfile" "$compression_level" "$verbose_logs" || { + echo -e "${RED}Asset pre-compression failed for ${file}.${NC}" + return 1 + } + done + fi + + echo "Pre-compressed ${#changed_files[@]} assets using ${compress_jobs} worker(s) (gzip -${compression_level})." echo "Asset pre-compression finished." } # Execute the asset compression. +bssg_ram_timing_start "precompress" "Pre-compress" precompress_assets +bssg_ram_timing_end # --- Pre-compress Assets --- END --- # --- Deployment --- START --- +bssg_ram_timing_start "deployment" "Deployment Decision/Run" deploy_now="false" if [[ "${CMD_DEPLOY_OVERRIDE:-unset}" == "true" ]]; then # Use default value for safety deploy_now="true" @@ -544,12 +835,15 @@ if [[ "$deploy_now" == "true" ]]; then echo -e "${YELLOW}Warning: Deployment was requested, but DEPLOY_SCRIPT is not set in configuration.${NC}" fi fi +bssg_ram_timing_end # --- Deployment --- END --- # --- End of execution --- BUILD_END_TIME=$(date +%s) BUILD_DURATION=$((BUILD_END_TIME - BUILD_START_TIME)) +bssg_ram_timing_print_summary echo "------------------------------------------------------" echo -e "${GREEN}Build process completed in ${BUILD_DURATION} seconds.${NC}" + exit 0 diff --git a/scripts/build/ram_mode.sh b/scripts/build/ram_mode.sh new file mode 100644 index 0000000..a659766 --- /dev/null +++ b/scripts/build/ram_mode.sh @@ -0,0 +1,221 @@ +#!/usr/bin/env bash +# +# BSSG - RAM Build Helpers +# Preloads input content in memory and provides lookup helpers. +# + +# Guard against duplicate sourcing +if [[ -n "${BSSG_RAM_MODE_SCRIPT_LOADED:-}" ]]; then + return 0 +fi +export BSSG_RAM_MODE_SCRIPT_LOADED=1 + +# In-memory stores +declare -gA BSSG_RAM_FILE_CONTENT=() +declare -gA BSSG_RAM_FILE_MTIME=() +declare -gA BSSG_RAM_DATASET=() +declare -gA BSSG_RAM_BASENAME_KEY=() +declare -ga BSSG_RAM_SRC_FILES=() +declare -ga BSSG_RAM_PAGE_FILES=() +declare -ga BSSG_RAM_TEMPLATE_FILES=() + +ram_mode_enabled() { + [[ "${BSSG_RAM_MODE:-false}" == "true" ]] +} + +_ram_mode_disk_mtime() { + local file="$1" + local kernel_name + kernel_name=$(uname -s) + if [[ "$kernel_name" == "Darwin" ]] || [[ "$kernel_name" == *"BSD" ]]; then + stat -f "%m" "$file" 2>/dev/null || echo "0" + else + stat -c "%Y" "$file" 2>/dev/null || echo "0" + fi +} + +ram_mode_resolve_key() { + local file="$1" + if [[ -z "$file" ]]; then + return 1 + fi + + if [[ -n "${BSSG_RAM_FILE_CONTENT[$file]+_}" || -n "${BSSG_RAM_FILE_MTIME[$file]+_}" ]]; then + echo "$file" + return 0 + fi + + if [[ "$file" == /* && -n "${BSSG_PROJECT_ROOT:-}" ]]; then + local prefix="${BSSG_PROJECT_ROOT%/}/" + if [[ "$file" == "$prefix"* ]]; then + local rel="${file#"$prefix"}" + if [[ -n "${BSSG_RAM_FILE_CONTENT[$rel]+_}" || -n "${BSSG_RAM_FILE_MTIME[$rel]+_}" ]]; then + echo "$rel" + return 0 + fi + fi + fi + + if [[ "$file" != */* && -n "${BSSG_RAM_BASENAME_KEY[$file]+_}" ]]; then + local mapped="${BSSG_RAM_BASENAME_KEY[$file]}" + if [[ "$mapped" != "__AMBIGUOUS__" ]]; then + echo "$mapped" + return 0 + fi + fi + + echo "$file" + return 0 +} + +ram_mode_has_file() { + local key + if ! key=$(ram_mode_resolve_key "$1"); then + return 1 + fi + [[ -n "${BSSG_RAM_FILE_CONTENT[$key]+_}" || -n "${BSSG_RAM_FILE_MTIME[$key]+_}" ]] +} + +ram_mode_get_content() { + local key + if ! key=$(ram_mode_resolve_key "$1"); then + return 0 + fi + if [[ -n "${BSSG_RAM_FILE_CONTENT[$key]+_}" ]]; then + printf '%s' "${BSSG_RAM_FILE_CONTENT[$key]}" + fi +} + +ram_mode_get_mtime() { + local key + if ! key=$(ram_mode_resolve_key "$1"); then + printf '0\n' + return 0 + fi + if [[ -n "${BSSG_RAM_FILE_MTIME[$key]+_}" ]]; then + printf '%s\n' "${BSSG_RAM_FILE_MTIME[$key]}" + else + printf '0\n' + fi +} + +ram_mode_list_src_files() { + if [[ ${#BSSG_RAM_SRC_FILES[@]} -eq 0 ]]; then + return 0 + fi + printf '%s\n' "${BSSG_RAM_SRC_FILES[@]}" +} + +ram_mode_list_page_files() { + if [[ ${#BSSG_RAM_PAGE_FILES[@]} -eq 0 ]]; then + return 0 + fi + printf '%s\n' "${BSSG_RAM_PAGE_FILES[@]}" +} + +ram_mode_set_dataset() { + local key="$1" + local value="$2" + BSSG_RAM_DATASET["$key"]="$value" +} + +ram_mode_get_dataset() { + local key="$1" + if [[ -n "${BSSG_RAM_DATASET[$key]+_}" ]]; then + printf '%s' "${BSSG_RAM_DATASET[$key]}" + fi +} + +ram_mode_clear_dataset() { + local key="$1" + unset 'BSSG_RAM_DATASET[$key]' +} + +ram_mode_dataset_line_count() { + local key="$1" + local data + data=$(ram_mode_get_dataset "$key") + if [[ -z "$data" ]]; then + echo "0" + return 0 + fi + printf '%s\n' "$data" | awk 'NF { c++ } END { print c+0 }' +} + +_ram_mode_store_file() { + local file="$1" + [[ -f "$file" ]] || return 0 + + local file_content + file_content=$(cat "$file") + BSSG_RAM_FILE_CONTENT["$file"]="$file_content" + BSSG_RAM_FILE_MTIME["$file"]="$(_ram_mode_disk_mtime "$file")" + + local base + base=$(basename "$file") + if [[ -z "${BSSG_RAM_BASENAME_KEY[$base]+_}" ]]; then + BSSG_RAM_BASENAME_KEY["$base"]="$file" + elif [[ "${BSSG_RAM_BASENAME_KEY[$base]}" != "$file" ]]; then + BSSG_RAM_BASENAME_KEY["$base"]="__AMBIGUOUS__" + fi +} + +_ram_mode_collect_content_files() { + local dir="$1" + [[ -d "$dir" ]] || return 0 + find "$dir" -type f \( -name "*.md" -o -name "*.html" \) -not -path "*/.*" | sort +} + +_ram_mode_collect_template_files() { + local dir="$1" + [[ -d "$dir" ]] || return 0 + find "$dir" -type f -name "*.html" -not -path "*/.*" | sort +} + +ram_mode_preload_inputs() { + if ! ram_mode_enabled; then + return 0 + fi + + BSSG_RAM_FILE_CONTENT=() + BSSG_RAM_FILE_MTIME=() + BSSG_RAM_DATASET=() + BSSG_RAM_BASENAME_KEY=() + BSSG_RAM_SRC_FILES=() + BSSG_RAM_PAGE_FILES=() + BSSG_RAM_TEMPLATE_FILES=() + + local file + while IFS= read -r file; do + [[ -z "$file" ]] && continue + BSSG_RAM_SRC_FILES+=("$file") + _ram_mode_store_file "$file" + done < <(_ram_mode_collect_content_files "${SRC_DIR:-src}") + + while IFS= read -r file; do + [[ -z "$file" ]] && continue + BSSG_RAM_PAGE_FILES+=("$file") + _ram_mode_store_file "$file" + done < <(_ram_mode_collect_content_files "${PAGES_DIR:-pages}") + + while IFS= read -r file; do + [[ -z "$file" ]] && continue + BSSG_RAM_TEMPLATE_FILES+=("$file") + _ram_mode_store_file "$file" + done < <(_ram_mode_collect_template_files "${TEMPLATES_DIR:-templates}") + + # Preload active locale (and fallback locale) so date/menu rendering avoids disk reads. + if [[ -f "${LOCALE_DIR:-locales}/${SITE_LANG:-en}.sh" ]]; then + _ram_mode_store_file "${LOCALE_DIR:-locales}/${SITE_LANG:-en}.sh" + fi + if [[ -f "${LOCALE_DIR:-locales}/en.sh" ]]; then + _ram_mode_store_file "${LOCALE_DIR:-locales}/en.sh" + fi + + print_info "RAM mode preloaded ${#BSSG_RAM_FILE_CONTENT[@]} text files (${#BSSG_RAM_SRC_FILES[@]} posts, ${#BSSG_RAM_PAGE_FILES[@]} pages)." +} + +export -f ram_mode_enabled ram_mode_resolve_key ram_mode_has_file ram_mode_get_content ram_mode_get_mtime +export -f ram_mode_list_src_files ram_mode_list_page_files ram_mode_preload_inputs +export -f ram_mode_set_dataset ram_mode_get_dataset ram_mode_clear_dataset +export -f ram_mode_dataset_line_count diff --git a/scripts/build/related_posts.sh b/scripts/build/related_posts.sh index 8fe0b86..e071499 100644 --- a/scripts/build/related_posts.sh +++ b/scripts/build/related_posts.sh @@ -12,6 +12,169 @@ source "$(dirname "$0")/cache.sh" || { echo >&2 "Error: Failed to source cache.s # --- Related Posts Functions --- START --- +declare -gA BSSG_RAM_RELATED_POSTS_HTML=() +declare -g BSSG_RAM_RELATED_POSTS_READY=false +declare -g BSSG_RAM_RELATED_POSTS_LIMIT="" + +_build_post_url_from_date_slug() { + local post_date="$1" + local post_slug="$2" + local post_year post_month post_day + + if [[ "$post_date" =~ ^([0-9]{4})-([0-9]{1,2})-([0-9]{1,2}) ]]; then + post_year="${BASH_REMATCH[1]}" + post_month=$(printf "%02d" "$((10#${BASH_REMATCH[2]}))") + post_day=$(printf "%02d" "$((10#${BASH_REMATCH[3]}))") + else + post_year=$(date +%Y) + post_month=$(date +%m) + post_day=$(date +%d) + fi + + local url_path="${URL_SLUG_FORMAT:-Year/Month/Day/slug}" + url_path="${url_path//Year/$post_year}" + url_path="${url_path//Month/$post_month}" + url_path="${url_path//Day/$post_day}" + url_path="${url_path//slug/$post_slug}" + printf '/%s/\n' "$url_path" +} + +_build_ram_related_posts_cache() { + local max_results="${1:-3}" + local file_index_data + file_index_data=$(ram_mode_get_dataset "file_index") + + BSSG_RAM_RELATED_POSTS_HTML=() + BSSG_RAM_RELATED_POSTS_READY=true + BSSG_RAM_RELATED_POSTS_LIMIT="$max_results" + + [ -z "$file_index_data" ] && return 0 + + local scored_results="" + scored_results=$(printf '%s\n' "$file_index_data" | awk -F'|' ' + function trim(s) { + gsub(/^[[:space:]]+|[[:space:]]+$/, "", s) + return s + } + + { + n++ + title[n] = $3 + date[n] = $4 + tags_raw[n] = $6 + slug[n] = $7 + desc[n] = $10 + + split(tags_raw[n], tag_arr, ",") + for (k in tag_arr) { + t = trim(tag_arr[k]) + if (t != "") { + tags[n SUBSEP t] = 1 + } + } + } + + END { + for (i = 1; i <= n; i++) { + if (slug[i] == "" || tags_raw[i] == "") { + continue + } + + split(tags_raw[i], i_tags, ",") + for (j = 1; j <= n; j++) { + if (i == j || slug[j] == "" || date[j] == "" || tags_raw[j] == "") { + continue + } + + score = 0 + delete seen + for (k in i_tags) { + t = trim(i_tags[k]) + if (t == "" || seen[t]) { + continue + } + seen[t] = 1 + if (tags[j SUBSEP t]) { + score++ + } + } + + if (score > 0) { + printf "%s|%d|%s|%s|%s|%s\n", slug[i], score, date[j], title[j], slug[j], desc[j] + } + } + } + } + ' | sort -t'|' -k1,1 -k2,2nr -k3,3r) + + [ -z "$scored_results" ] && return 0 + + local current_slug="" current_count=0 + local html_output="" + local slug score date title related_slug description + + while IFS='|' read -r slug score date title related_slug description; do + [ -z "$slug" ] && continue + + if [ "$slug" != "$current_slug" ]; then + if [ -n "$current_slug" ] && [ "$current_count" -gt 0 ]; then + html_output+='
'$'\n' + html_output+=''$'\n' + BSSG_RAM_RELATED_POSTS_HTML["$current_slug"]="$html_output" + fi + current_slug="$slug" + current_count=0 + html_output="" + fi + + if [ "$current_count" -ge "$max_results" ]; then + continue + fi + + local post_url + post_url=$(_build_post_url_from_date_slug "$date" "$related_slug") + + local short_desc="$description" + if [[ ${#short_desc} -gt 120 ]]; then + short_desc="${short_desc:0:117}..." + fi + + if [ "$current_count" -eq 0 ]; then + html_output+=''$'\n' + BSSG_RAM_RELATED_POSTS_HTML["$current_slug"]="$html_output" + fi +} + +prepare_related_posts_ram_cache() { + local max_results="${1:-3}" + if [ "${BSSG_RAM_MODE:-false}" != true ]; then + return 0 + fi + + if [ "$BSSG_RAM_RELATED_POSTS_READY" = true ] && [ "$BSSG_RAM_RELATED_POSTS_LIMIT" = "$max_results" ]; then + return 0 + fi + + _build_ram_related_posts_cache "$max_results" +} + # Generate related posts for a given post based on shared tags # Args: $1=current_post_slug $2=current_post_tags $3=current_post_date $4=max_results (optional, default=3) # Returns: HTML snippet with related posts @@ -26,6 +189,17 @@ generate_related_posts() { return 0 # No related posts if missing essential data fi + # RAM mode uses a precomputed in-memory map to avoid repeated O(n^2) scans. + if [ "${BSSG_RAM_MODE:-false}" = true ]; then + if [ "$BSSG_RAM_RELATED_POSTS_READY" != true ] || [ "$BSSG_RAM_RELATED_POSTS_LIMIT" != "$max_results" ]; then + _build_ram_related_posts_cache "$max_results" + fi + if [[ -n "${BSSG_RAM_RELATED_POSTS_HTML[$current_slug]+_}" ]]; then + printf '%s' "${BSSG_RAM_RELATED_POSTS_HTML[$current_slug]}" + fi + return 0 + fi + # Check cache first local cache_file="${CACHE_DIR:-.bssg_cache}/related_posts/${current_slug}.html" local file_index="${CACHE_DIR:-.bssg_cache}/file_index.txt" @@ -60,8 +234,18 @@ compute_related_posts() { local max_results="$4" local file_index="${CACHE_DIR:-.bssg_cache}/file_index.txt" + local file_index_data="" + local ram_mode_active=false + if [ "${BSSG_RAM_MODE:-false}" = true ]; then + ram_mode_active=true + file_index_data=$(ram_mode_get_dataset "file_index") + fi - if [[ ! -f "$file_index" ]]; then + if $ram_mode_active; then + if [[ -z "$file_index_data" ]]; then + return 0 + fi + elif [[ ! -f "$file_index" ]]; then return 0 # No posts to compare against fi @@ -81,7 +265,7 @@ compute_related_posts() { fi # Process all posts and calculate similarity scores - local temp_results=$(mktemp) + local temp_results="" while IFS='|' read -r file filename title date lastmod tags slug image image_caption description author_name author_email; do # Skip current post @@ -113,17 +297,22 @@ compute_related_posts() { # Only consider posts with at least one shared tag if [[ $score -gt 0 ]]; then # Store: score|date|title|slug|description - echo "${score}|${date}|${title}|${slug}|${description}" >> "$temp_results" + temp_results+="${score}|${date}|${title}|${slug}|${description}"$'\n' fi - done < "$file_index" + done < <( + if $ram_mode_active; then + printf '%s\n' "$file_index_data" | awk 'NF' + else + cat "$file_index" + fi + ) # Sort by score (descending), then by date (descending), limit results local sorted_results="" - if [[ -s "$temp_results" ]]; then - sorted_results=$(sort -t'|' -k1,1nr -k2,2r "$temp_results" | head -n "$max_results") + if [[ -n "$temp_results" ]]; then + sorted_results=$(printf '%s\n' "$temp_results" | awk 'NF' | sort -t'|' -k1,1nr -k2,2r | head -n "$max_results") fi - rm -f "$temp_results" # Generate HTML output if [[ -z "$sorted_results" ]]; then @@ -248,4 +437,5 @@ invalidate_related_posts_cache_for_tags() { # --- Related Posts Functions --- END --- # Export functions for use by other scripts -export -f generate_related_posts compute_related_posts clean_related_posts_cache invalidate_related_posts_cache_for_tags \ No newline at end of file +export -f generate_related_posts compute_related_posts clean_related_posts_cache invalidate_related_posts_cache_for_tags +export -f prepare_related_posts_ram_cache diff --git a/scripts/build/templates.sh b/scripts/build/templates.sh index d12f184..293cf45 100755 --- a/scripts/build/templates.sh +++ b/scripts/build/templates.sh @@ -63,7 +63,9 @@ load_template() { # Function to pre-load all templates and process menus/placeholders preload_templates() { # Create template cache directory if it doesn't exist - mkdir -p "$TEMPLATE_CACHE_DIR" + if [ "${BSSG_RAM_MODE:-false}" != true ]; then + mkdir -p "$TEMPLATE_CACHE_DIR" + fi local template_dir local templates_to_load=("header.html" "footer.html" "post.html" "page.html" "index.html" "tag.html" "archive.html") @@ -131,10 +133,16 @@ preload_templates() { # Scan pages directory for markdown and HTML files if [ -d "${PAGES_DIR:-pages}" ]; then - local page_files - page_files=($(find "${PAGES_DIR:-pages}" -type f \( -name "*.md" -o -name "*.html" \) | sort)) + local page_files=() + if [ "${BSSG_RAM_MODE:-false}" = true ] && declare -F ram_mode_list_page_files > /dev/null; then + mapfile -t page_files < <(ram_mode_list_page_files) + else + page_files=($(find "${PAGES_DIR:-pages}" -type f \( -name "*.md" -o -name "*.html" \) | sort)) + fi for file in "${page_files[@]}"; do + [[ -z "$file" ]] && continue + # Skip if file is hidden if [[ $(basename "$file") == .* ]]; then continue @@ -144,10 +152,19 @@ preload_templates() { local title slug date secondary if [[ "$file" == *.html ]]; then # Crude HTML parsing - assumes specific meta tags exist - title=$(grep -m 1 '' "$file" 2>/dev/null | sed 's/<[^>]*>//g') - slug=$(grep -m 1 'meta name="slug"' "$file" 2>/dev/null | sed 's/.*content="\([^"]*\)".*/\1/') - date=$(grep -m 1 'meta name="date"' "$file" 2>/dev/null | sed 's/.*content="\([^"]*\)".*/\1/') # Extract date from meta - secondary=$(grep -m 1 'meta name="secondary"' "$file" 2>/dev/null | sed 's/.*content="\([^"]*\)".*/\1/') + local html_source="" + if [ "${BSSG_RAM_MODE:-false}" = true ] && declare -F ram_mode_has_file > /dev/null && ram_mode_has_file "$file"; then + html_source=$(ram_mode_get_content "$file") + title=$(printf '%s\n' "$html_source" | grep -m 1 '<title>' 2>/dev/null | sed 's/<[^>]*>//g') + slug=$(printf '%s\n' "$html_source" | grep -m 1 'meta name="slug"' 2>/dev/null | sed 's/.*content="\([^"]*\)".*/\1/') + date=$(printf '%s\n' "$html_source" | grep -m 1 'meta name="date"' 2>/dev/null | sed 's/.*content="\([^"]*\)".*/\1/') + secondary=$(printf '%s\n' "$html_source" | grep -m 1 'meta name="secondary"' 2>/dev/null | sed 's/.*content="\([^"]*\)".*/\1/') + else + title=$(grep -m 1 '<title>' "$file" 2>/dev/null | sed 's/<[^>]*>//g') + slug=$(grep -m 1 'meta name="slug"' "$file" 2>/dev/null | sed 's/.*content="\([^"]*\)".*/\1/') + date=$(grep -m 1 'meta name="date"' "$file" 2>/dev/null | sed 's/.*content="\([^"]*\)".*/\1/') # Extract date from meta + secondary=$(grep -m 1 'meta name="secondary"' "$file" 2>/dev/null | sed 's/.*content="\([^"]*\)".*/\1/') + fi else # Assumes parse_metadata is available title=$(parse_metadata "$file" "title") @@ -206,18 +223,33 @@ preload_templates() { # Add standard menu items local tags_flag_file="${CACHE_DIR:-.bssg_cache}/has_tags.flag" - # Add tags link only if the flag file exists (meaning tags were found in the last indexing run) - if [ -f "$tags_flag_file" ]; then + local has_tags=false + if [ "${BSSG_RAM_MODE:-false}" = true ]; then + [ -n "$(ram_mode_get_dataset "has_tags")" ] && has_tags=true + elif [ -f "$tags_flag_file" ]; then + has_tags=true + fi + # Add tags link only if tags are present. + if [ "$has_tags" = true ]; then menu_items+=" <a href=\"${SITE_URL}/tags/\">${MSG_TAGS:-"Tags"}</a>" fi # Add Authors link if enabled and multiple authors exist local authors_flag_file="${CACHE_DIR:-.bssg_cache}/has_authors.flag" - if [ "${ENABLE_AUTHOR_PAGES:-true}" = true ] && [ -f "$authors_flag_file" ]; then + if [ "${ENABLE_AUTHOR_PAGES:-true}" = true ]; then # Check if we have multiple authors (more than the threshold) local authors_index_file="${CACHE_DIR:-.bssg_cache}/authors_index.txt" - if [ -f "$authors_index_file" ]; then - local unique_author_count=$(awk -F'|' '{print $1}' "$authors_index_file" | sort -u | wc -l) + local unique_author_count=0 + if [ "${BSSG_RAM_MODE:-false}" = true ]; then + local authors_index_data + authors_index_data=$(ram_mode_get_dataset "authors_index") + if [ -n "$authors_index_data" ]; then + unique_author_count=$(printf '%s\n' "$authors_index_data" | awk -F'|' 'NF { print $1 }' | sort -u | wc -l | tr -d ' ') + fi + elif [ -f "$authors_index_file" ] && [ -f "$authors_flag_file" ]; then + unique_author_count=$(awk -F'|' '{print $1}' "$authors_index_file" | sort -u | wc -l) + fi + if [ "$unique_author_count" -gt 0 ]; then local threshold="${SHOW_AUTHORS_MENU_THRESHOLD:-2}" if [ "$unique_author_count" -ge "$threshold" ]; then menu_items+=" <a href=\"${SITE_URL}/authors/\">${MSG_AUTHORS:-"Authors"}</a>" @@ -233,14 +265,23 @@ preload_templates() { menu_items+=" <a href=\"${SITE_URL}/${RSS_FILENAME:-rss.xml}\">${MSG_RSS:-"RSS"}</a>" # Add tags link to footer only if the flag file exists - if [ -f "$tags_flag_file" ]; then + if [ "$has_tags" = true ]; then footer_items+=" <a href=\"${SITE_URL}/tags/\">${MSG_TAGS:-"Tags"}</a> ·" fi # Add Authors link to footer if enabled and multiple authors exist - if [ "${ENABLE_AUTHOR_PAGES:-true}" = true ] && [ -f "$authors_flag_file" ]; then - if [ -f "$authors_index_file" ]; then - local unique_author_count_footer=$(awk -F'|' '{print $1}' "$authors_index_file" | sort -u | wc -l) + if [ "${ENABLE_AUTHOR_PAGES:-true}" = true ]; then + local unique_author_count_footer=0 + if [ "${BSSG_RAM_MODE:-false}" = true ]; then + local authors_index_data_footer + authors_index_data_footer=$(ram_mode_get_dataset "authors_index") + if [ -n "$authors_index_data_footer" ]; then + unique_author_count_footer=$(printf '%s\n' "$authors_index_data_footer" | awk -F'|' 'NF { print $1 }' | sort -u | wc -l | tr -d ' ') + fi + elif [ -f "$authors_index_file" ] && [ -f "$authors_flag_file" ]; then + unique_author_count_footer=$(awk -F'|' '{print $1}' "$authors_index_file" | sort -u | wc -l) + fi + if [ "$unique_author_count_footer" -gt 0 ]; then local threshold_footer="${SHOW_AUTHORS_MENU_THRESHOLD:-2}" if [ "$unique_author_count_footer" -ge "$threshold_footer" ]; then footer_items+=" <a href=\"${SITE_URL}/authors/\">${MSG_AUTHORS:-"Authors"}</a> ·" @@ -279,6 +320,24 @@ preload_templates() { HEADER_TEMPLATE=$(echo "$HEADER_TEMPLATE" | sed "s|{{[[:space:]]*rss_filename[[:space:]]*}}|${RSS_FILENAME:-rss.xml}|g") # --- Add RSS Filename Placeholder --- END --- + # --- Handle rel="me" Verification Link --- START --- + local rel_me_tags="" + if [ -n "${REL_ME_URLS_SERIALIZED:-}" ]; then + local rel_me_link_url rel_me_href + while IFS= read -r rel_me_link_url; do + [ -n "$rel_me_link_url" ] || continue + rel_me_href=$(html_escape "$rel_me_link_url") + rel_me_tags+="<link rel=\"me\" href=\"${rel_me_href}\">"$'\n' + done <<< "$REL_ME_URLS_SERIALIZED" + rel_me_tags="${rel_me_tags%$'\n'}" + print_info "Adding rel=\"me\" verification links from REL_ME_URL/REL_ME_URLS." + else + print_info "No REL_ME_URL or REL_ME_URLS specified, skipping rel=\"me\" links." + fi + HEADER_TEMPLATE=$(echo "$HEADER_TEMPLATE" | sed "s|{{[[:space:]]*rel_me_link[[:space:]]*}}|__BSSG_REL_ME_LINK__|g") + HEADER_TEMPLATE=${HEADER_TEMPLATE//__BSSG_REL_ME_LINK__/$rel_me_tags} + # --- Handle rel="me" Verification Link --- END --- + # --- Handle Custom CSS --- START --- local custom_css_tag="" if [ -n "$CUSTOM_CSS" ]; then @@ -299,50 +358,55 @@ preload_templates() { HEADER_TEMPLATE=$(echo "$HEADER_TEMPLATE" | sed "s|{{[[:space:]]*custom_css_link[[:space:]]*}}|${custom_css_tag}|") # --- Handle Custom CSS --- END --- - # Write primary and secondary page lists to cache files only if changed - local primary_pages_cache="$CACHE_DIR/primary_pages.tmp" - local secondary_pages_cache="$CACHE_DIR/secondary_pages.tmp" - local secondary_pages_list_file="$CACHE_DIR/secondary_pages.list" # <-- Define list file path - - # Prepare content in temporary files - local primary_tmp=$(mktemp) - local secondary_tmp=$(mktemp) - local secondary_list_tmp=$(mktemp) # <-- Temp file for the list - - # Write current content to temporary files - # Use printf for safer writing - for page in "${primary_pages[@]}"; do - printf "%s\n" "$page" >> "$primary_tmp" - done - for page in "${SECONDARY_PAGES[@]}"; do - # Write to the temp file for comparison - printf "%s\n" "$page" >> "$secondary_tmp" - # Also write to the list temp file, one per line - printf "%s\n" "$page" >> "$secondary_list_tmp" - done + if [ "${BSSG_RAM_MODE:-false}" = true ]; then + ram_mode_set_dataset "primary_pages" "$(printf '%s\n' "${primary_pages[@]}")" + ram_mode_set_dataset "secondary_pages" "$(printf '%s\n' "${SECONDARY_PAGES[@]}")" + else + # Write primary and secondary page lists to cache files only if changed + local primary_pages_cache="$CACHE_DIR/primary_pages.tmp" + local secondary_pages_cache="$CACHE_DIR/secondary_pages.tmp" + local secondary_pages_list_file="$CACHE_DIR/secondary_pages.list" # <-- Define list file path + + # Prepare content in temporary files + local primary_tmp=$(mktemp) + local secondary_tmp=$(mktemp) + local secondary_list_tmp=$(mktemp) # <-- Temp file for the list + + # Write current content to temporary files + # Use printf for safer writing + for page in "${primary_pages[@]}"; do + printf "%s\n" "$page" >> "$primary_tmp" + done + for page in "${SECONDARY_PAGES[@]}"; do + # Write to the temp file for comparison + printf "%s\n" "$page" >> "$secondary_tmp" + # Also write to the list temp file, one per line + printf "%s\n" "$page" >> "$secondary_list_tmp" + done - # Function to compare and update cache file - update_cache_if_changed() { - local temp_file="$1" - local cache_file="$2" - local file_desc="$3" + # Function to compare and update cache file + update_cache_if_changed() { + local temp_file="$1" + local cache_file="$2" + local file_desc="$3" - if [ ! -f "$cache_file" ] || ! cmp -s "$temp_file" "$cache_file"; then - mv "$temp_file" "$cache_file" - # echo "DEBUG: Updated $file_desc cache file." # Optional debug - else - rm "$temp_file" - # echo "DEBUG: $file_desc cache file unchanged." # Optional debug - fi - } + if [ ! -f "$cache_file" ] || ! cmp -s "$temp_file" "$cache_file"; then + mv "$temp_file" "$cache_file" + # echo "DEBUG: Updated $file_desc cache file." # Optional debug + else + rm "$temp_file" + # echo "DEBUG: $file_desc cache file unchanged." # Optional debug + fi + } - # Compare and update cache files - update_cache_if_changed "$primary_tmp" "$primary_pages_cache" - update_cache_if_changed "$secondary_tmp" "$secondary_pages_cache" - update_cache_if_changed "$secondary_list_tmp" "$secondary_pages_list_file" # <-- Update the list file + # Compare and update cache files + update_cache_if_changed "$primary_tmp" "$primary_pages_cache" + update_cache_if_changed "$secondary_tmp" "$secondary_pages_cache" + update_cache_if_changed "$secondary_list_tmp" "$secondary_pages_list_file" # <-- Update the list file - # Clean up temporary files - rm -f "$primary_tmp" "$secondary_tmp" "$secondary_list_tmp" # <-- Cleanup list temp file + # Clean up temporary files + rm -f "$primary_tmp" "$secondary_tmp" "$secondary_list_tmp" # <-- Cleanup list temp file + fi echo -e "${GREEN}Templates pre-processed (menus, locale placeholders).${NC}" } @@ -364,4 +428,4 @@ export FOOTER_TEMPLATE # Export functions - Do not export the SECONDARY_PAGES array itself anymore export -f preload_templates -# export SECONDARY_PAGES # <-- Remove this export \ No newline at end of file +# export SECONDARY_PAGES # <-- Remove this export diff --git a/scripts/build/utils.sh b/scripts/build/utils.sh index 80ad813..b4c3b9d 100755 --- a/scripts/build/utils.sh +++ b/scripts/build/utils.sh @@ -19,6 +19,31 @@ else NC="" fi +# Cache kernel name once to avoid repeated `uname` calls in hot paths. +if [ -z "${BSSG_KERNEL_NAME:-}" ]; then + BSSG_KERNEL_NAME="$(uname -s 2>/dev/null || echo "")" +fi + +# Cache repeated date formatting work across stages in the same process. +declare -gA BSSG_FORMAT_DATE_CACHE=() +declare -gA BSSG_FORMAT_DATE_TS_CACHE=() + +# GNU parallel workers import functions, but array declarations may not carry over. +# Keep date caches associative in every process to avoid bad-subscript errors. +_bssg_ensure_assoc_cache() { + local var_name="$1" + local var_decl + + var_decl=$(declare -p "$var_name" 2>/dev/null || true) + if [[ "$var_decl" == declare\ -A* ]]; then + return 0 + fi + + unset "$var_name" 2>/dev/null || true + declare -gA "$var_name" + eval "$var_name=()" +} + # --- Printing Functions --- START --- print_error() { # Print message in red to stderr @@ -69,7 +94,10 @@ format_date() { local format_override="$2" # Optional format string local target_format=${format_override:-"$DATE_FORMAT"} # Use override or global DATE_FORMAT local formatted_date - local kernel_name=$(uname -s) # Get kernel name (e.g., Linux, Darwin, FreeBSD) + local kernel_name="${BSSG_KERNEL_NAME:-}" + if [ -z "$kernel_name" ]; then + kernel_name="$(uname -s)" + fi # Skip formatting if date is empty if [ -z "$input_date" ]; then @@ -91,27 +119,46 @@ format_date() { return fi + _bssg_ensure_assoc_cache "BSSG_FORMAT_DATE_CACHE" + + # Use cached values for stable (non-"now") inputs. + local cache_tz="${TIMEZONE:-local}" + local cache_key="${cache_tz}|${target_format}|${input_date}" + if [[ -n "${BSSG_FORMAT_DATE_CACHE[$cache_key]+_}" ]]; then + echo "${BSSG_FORMAT_DATE_CACHE[$cache_key]}" + return + fi + # Try to format the date using the configured format # IMPORTANT: DATE_FORMAT must be exported or sourced *before* calling this if [[ "$kernel_name" == "Darwin" ]] || [[ "$kernel_name" == *"BSD" ]]; then # macOS/BSD date formatting (uses date -j -f) - # IMPORTANT: Using ISO 8601 format (YYYY-MM-DD HH:MM:SS) in source - # files is strongly recommended for portability. - - # Try parsing full ISO date-time first - formatted_date=$(eval "${tz_prefix}LC_ALL=C date -j -f \"%Y-%m-%d %H:%M:%S\" \"$input_date\" +\"$target_format\"" 2>/dev/null) - - # If failed, try RFC2822 format - if [ -z "$formatted_date" ]; then + # Fast-path common stable inputs to avoid multiple failed parse attempts. + if [[ "$input_date" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]]; then + formatted_date=$(eval "${tz_prefix}LC_ALL=C date -j -f \"%Y-%m-%d\" \"$input_date\" +\"$target_format\"" 2>/dev/null) + elif [[ "$input_date" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}[[:space:]][0-9]{2}:[0-9]{2}:[0-9]{2}$ ]]; then + formatted_date=$(eval "${tz_prefix}LC_ALL=C date -j -f \"%Y-%m-%d %H:%M:%S\" \"$input_date\" +\"$target_format\"" 2>/dev/null) + elif [[ "$input_date" =~ ^[A-Za-z]{3},[[:space:]][0-9]{2}[[:space:]][A-Za-z]{3}[[:space:]][0-9]{4}[[:space:]][0-9]{2}:[0-9]{2}:[0-9]{2}[[:space:]][+-][0-9]{4}$ ]]; then formatted_date=$(eval "${tz_prefix}LC_ALL=C date -j -f \"%a, %d %b %Y %H:%M:%S %z\" \"$input_date\" +\"$target_format\"" 2>/dev/null) fi - # If still failed, try parsing date-only (YYYY-MM-DD) and assume midnight + # Fallback parser chain for uncommon/legacy input variants. if [ -z "$formatted_date" ]; then - # Check if input looks like YYYY-MM-DD using shell pattern matching - if [[ "$input_date" == [0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9] ]]; then - # Try parsing by appending midnight time - formatted_date=$(eval "${tz_prefix}LC_ALL=C date -j -f \"%Y-%m-%d %H:%M:%S\" \"$input_date 00:00:00\" +\"$target_format\"" 2>/dev/null) + # Try parsing full ISO date-time first + formatted_date=$(eval "${tz_prefix}LC_ALL=C date -j -f \"%Y-%m-%d %H:%M:%S\" \"$input_date\" +\"$target_format\"" 2>/dev/null) + + # If failed, try RFC2822 format + if [ -z "$formatted_date" ]; then + formatted_date=$(eval "${tz_prefix}LC_ALL=C date -j -f \"%a, %d %b %Y %H:%M:%S %z\" \"$input_date\" +\"$target_format\"" 2>/dev/null) + fi + + # If still failed, try parsing date-only (YYYY-MM-DD) and assume midnight + if [ -z "$formatted_date" ]; then + # Check if input looks like YYYY-MM-DD using shell pattern matching + if [[ "$input_date" == [0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9] ]]; then + # Try parsing by appending midnight time + formatted_date=$(eval "${tz_prefix}LC_ALL=C date -j -f \"%Y-%m-%d %H:%M:%S\" \"$input_date 00:00:00\" +\"$target_format\"" 2>/dev/null) + fi fi fi @@ -125,6 +172,7 @@ format_date() { formatted_date=$(eval "${tz_prefix}LC_ALL=C date -d \"$input_date\" +\"$target_format\"" 2>/dev/null || echo "$input_date") fi + BSSG_FORMAT_DATE_CACHE["$cache_key"]="$formatted_date" echo "$formatted_date" } @@ -141,6 +189,16 @@ format_date_from_timestamp() { return fi + _bssg_ensure_assoc_cache "BSSG_FORMAT_DATE_TS_CACHE" + + # Cache by timestamp/format/timezone. + local cache_tz="${TIMEZONE:-local}" + local cache_key="${cache_tz}|${target_format}|${timestamp}" + if [[ -n "${BSSG_FORMAT_DATE_TS_CACHE[$cache_key]+_}" ]]; then + echo "${BSSG_FORMAT_DATE_TS_CACHE[$cache_key]}" + return + fi + # Set TZ environment variable if TIMEZONE is set and not "local" local tz_prefix="" if [ -n "${TIMEZONE:-}" ] && [ "${TIMEZONE:-local}" != "local" ]; then @@ -159,6 +217,7 @@ format_date_from_timestamp() { formatted_date=$(eval "${tz_prefix}LC_ALL=C date -d \"@$timestamp\" +\"$target_format\"" 2>/dev/null || echo "") fi + BSSG_FORMAT_DATE_TS_CACHE["$cache_key"]="$formatted_date" echo "$formatted_date" } @@ -226,7 +285,21 @@ unlock_file() { # Get file modification time in a portable way get_file_mtime() { local file="$1" - local kernel_name=$(uname -s) + local kernel_name="${BSSG_KERNEL_NAME:-}" + + # In RAM mode, prefer preloaded input timestamps. + if [ "${BSSG_RAM_MODE:-false}" = true ] && declare -F ram_mode_get_mtime > /dev/null; then + local ram_mtime + ram_mtime=$(ram_mode_get_mtime "$file") + if [ -n "$ram_mtime" ] && [ "$ram_mtime" != "0" ]; then + echo "$ram_mtime" + return 0 + fi + fi + + if [ -z "$kernel_name" ]; then + kernel_name="$(uname -s)" + fi # Use specific stat flags based on kernel name # %m for BSD/macOS (seconds since Epoch) @@ -242,58 +315,108 @@ get_file_mtime() { # Fallback parallel implementation using background processes # Used when GNU parallel is not available +detect_cpu_cores() { + if command -v nproc > /dev/null 2>&1; then + nproc + elif command -v sysctl > /dev/null 2>&1; then + sysctl -n hw.ncpu 2>/dev/null || echo 1 + else + echo 2 + fi +} + +# Determine worker count. +# In RAM mode we cap concurrency by default to reduce memory pressure from +# large inherited in-memory arrays in each worker process. +get_parallel_jobs() { + local requested_jobs="$1" + local jobs=0 + + if [[ "$requested_jobs" =~ ^[0-9]+$ ]] && [ "$requested_jobs" -gt 0 ]; then + jobs="$requested_jobs" + else + jobs=$(detect_cpu_cores) + fi + + if [ "${BSSG_RAM_MODE:-false}" = true ]; then + local ram_cap="${RAM_MODE_MAX_JOBS:-6}" + if ! [[ "$ram_cap" =~ ^[0-9]+$ ]] || [ "$ram_cap" -lt 1 ]; then + ram_cap=6 + fi + if [ "$jobs" -gt "$ram_cap" ]; then + jobs="$ram_cap" + fi + fi + + if [ "$jobs" -lt 1 ]; then + jobs=1 + fi + + echo "$jobs" +} + run_parallel() { local max_jobs="$1" shift - if [ -z "$max_jobs" ] || [ "$max_jobs" -lt 1 ]; then - # Determine number of CPU cores if not specified - if command -v nproc > /dev/null 2>&1; then - # Linux - max_jobs=$(nproc) - elif command -v sysctl > /dev/null 2>&1; then - # macOS, BSD - max_jobs=$(sysctl -n hw.ncpu 2>/dev/null || echo 1) - else - # Default to 2 jobs if we can't determine - max_jobs=2 - fi + max_jobs=$(get_parallel_jobs "$max_jobs") + + local had_error=0 + local wait_n_supported=0 + if [[ ${BASH_VERSINFO[0]:-0} -gt 4 ]] || { [[ ${BASH_VERSINFO[0]:-0} -eq 4 ]] && [[ ${BASH_VERSINFO[1]:-0} -ge 3 ]]; }; then + wait_n_supported=1 fi - local job_count=0 - local pids=() + if [ "$wait_n_supported" -eq 1 ]; then + local running_jobs=0 - # Read commands from stdin - while read -r cmd; do - # Skip empty lines - [ -z "$cmd" ] && continue + while read -r cmd; do + [ -z "$cmd" ] && continue - # If we've reached max jobs, wait for one to finish - if [ $job_count -ge $max_jobs ]; then - # Wait for any child process to finish - wait -n 2>/dev/null || true - - # Cleanup finished jobs from pids array - local new_pids=() - for pid in "${pids[@]}"; do - if kill -0 $pid 2>/dev/null; then - new_pids+=($pid) + while [ "$running_jobs" -ge "$max_jobs" ]; do + if ! wait -n 2>/dev/null; then + had_error=1 fi + running_jobs=$((running_jobs - 1)) done - pids=("${new_pids[@]}") - # Update job count - job_count=${#pids[@]} - fi + (eval "$cmd") & + running_jobs=$((running_jobs + 1)) + done - # Run the command in the background - (eval "$cmd") & - pids+=($!) - job_count=$((job_count + 1)) - done + while [ "$running_jobs" -gt 0 ]; do + if ! wait -n 2>/dev/null; then + had_error=1 + fi + running_jobs=$((running_jobs - 1)) + done + else + # Portable fallback for older bash without wait -n. + local pids=() + while read -r cmd; do + [ -z "$cmd" ] && continue - # Wait for all remaining jobs to finish - wait + while [ "${#pids[@]}" -ge "$max_jobs" ]; do + local oldest_pid="${pids[0]}" + if ! wait "$oldest_pid"; then + had_error=1 + fi + pids=("${pids[@]:1}") + done + + (eval "$cmd") & + pids+=($!) + done + + local pid + for pid in "${pids[@]}"; do + if ! wait "$pid"; then + had_error=1 + fi + done + fi + + return "$had_error" } # Add a reading time calculation function @@ -327,17 +450,65 @@ html_escape() { fi } +trim_whitespace() { + printf '%s' "$1" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//' +} + +resolve_fediverse_creator() { + local author_name="$1" + local post_fediverse_creator="$2" + local resolved_creator="" + + resolved_creator=$(trim_whitespace "$post_fediverse_creator") + if [ -n "$resolved_creator" ]; then + printf '%s' "$resolved_creator" + return 0 + fi + + if [ -n "$author_name" ] && [ -n "${AUTHOR_FEDIVERSE_CREATORS_SERIALIZED:-}" ]; then + local configured_author configured_creator + while IFS=$'\t' read -r configured_author configured_creator; do + [ "$configured_author" = "$author_name" ] || continue + resolved_creator=$(trim_whitespace "$configured_creator") + if [ -n "$resolved_creator" ]; then + printf '%s' "$resolved_creator" + return 0 + fi + done <<< "${AUTHOR_FEDIVERSE_CREATORS_SERIALIZED}" + fi + + trim_whitespace "${FEDIVERSE_CREATOR:-}" +} + +build_fediverse_creator_meta_tag() { + local fediverse_creator + fediverse_creator=$(resolve_fediverse_creator "$1" "$2") + + if [ -z "$fediverse_creator" ]; then + printf '%s' "" + return 0 + fi + + printf '<meta name="fediverse:creator" content="%s">' "$(html_escape "$fediverse_creator")" +} + # Export the functions export -f format_date_from_timestamp export -f generate_slug export -f lock_file export -f unlock_file export -f get_file_mtime +export -f detect_cpu_cores +export -f get_parallel_jobs export -f run_parallel export -f calculate_reading_time export -f html_escape +export -f trim_whitespace +export -f resolve_fediverse_creator +export -f build_fediverse_creator_meta_tag # Export the new print functions export -f print_error export -f print_warning export -f print_success -export -f print_info \ No newline at end of file +export -f print_info +export -f _bssg_ensure_assoc_cache diff --git a/scripts/post.sh b/scripts/post.sh index 9533ac5..f6cb964 100755 --- a/scripts/post.sh +++ b/scripts/post.sh @@ -224,6 +224,7 @@ EOM <meta name="slug" content="$slug"> <meta name="author_name" content=""> <meta name="author_email" content=""> + <meta name="fediverse_creator" content=""> </head> <body> <h1>$title</h1> @@ -249,6 +250,7 @@ image_caption: description: author_name: author_email: +fediverse_creator: --- $initial_content @@ -475,6 +477,7 @@ else <meta name="slug" content="$POST_SLUG"> <meta name="author_name" content=""> <meta name="author_email" content=""> + <meta name="fediverse_creator" content=""> </head> <body> <h1>$POST_TITLE</h1> @@ -496,6 +499,7 @@ image_caption: description: author_name: author_email: +fediverse_creator: --- $POST_CONTENT diff --git a/src/2025-04-01-bssg-features-and-examples.md b/src/2025-04-01-bssg-features-and-examples.md index f33b14e..853959e 100644 --- a/src/2025-04-01-bssg-features-and-examples.md +++ b/src/2025-04-01-bssg-features-and-examples.md @@ -6,6 +6,7 @@ slug: bssg-features-examples description: A detailed overview of BSSG's key features with practical examples showing how to get the most out of this Bash Static Site Generator. image: https://picsum.photos/537/354 image_caption: Sample, random pic from picsum +fediverse_creator: @author@example.social --- BSSG (Bash Static Site Generator) offers a powerful yet simple approach to creating static websites. This post demonstrates some of its key features with practical examples. @@ -23,6 +24,7 @@ slug: custom-url-slug image: /path/to/featured-image.jpg image_caption: A caption for your featured image description: A brief summary of your post for previews and SEO +fediverse_creator: @author@example.social --- ``` @@ -123,6 +125,7 @@ When you build your BSSG site, it generates clean HTML with excellent accessibil <meta property="og:title" content="Post Title"> <meta property="og:description" content="Post description"> <meta property="og:url" content="https://example.com/post-slug"> + <meta name="fediverse:creator" content="@author@example.social"> <link rel="stylesheet" href="/css/style.css"> </head> <body> diff --git a/templates/header.html b/templates/header.html index c892f8d..c15ae55 100644 --- a/templates/header.html +++ b/templates/header.html @@ -5,18 +5,20 @@ <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>{{page_title}} | {{site_title}} - + - + {{og_image}} - + {{twitter_image}} + {{fediverse_creator_meta}} + {{rel_me_link}} {{schema_json_ld}} {{custom_css_link}} diff --git a/themes/amiga500/style.css b/themes/amiga500/style.css index fe02c34..0d4d584 100644 --- a/themes/amiga500/style.css +++ b/themes/amiga500/style.css @@ -890,7 +890,7 @@ hr { margin-bottom: 20px; } -.posts-list h2, .posts-list h3 { +.posts-list h2 { margin-top: 0; margin-bottom: 0.5rem; } diff --git a/themes/apple2/style.css b/themes/apple2/style.css index 45d4d9e..09e2030 100644 --- a/themes/apple2/style.css +++ b/themes/apple2/style.css @@ -225,6 +225,7 @@ h2 { font-size: 1.3rem; } +.posts-list h2, h3 { font-size: 1.2rem; } diff --git a/themes/art-deco/style.css b/themes/art-deco/style.css index c343729..e1838d1 100644 --- a/themes/art-deco/style.css +++ b/themes/art-deco/style.css @@ -691,7 +691,7 @@ figcaption { margin-bottom: 3rem; } -.posts-list h3 { +.posts-list h2 { margin-top: 0; font-size: 1.8rem; text-align: center; diff --git a/themes/atarist/style.css b/themes/atarist/style.css index f8743d3..e6af47e 100644 --- a/themes/atarist/style.css +++ b/themes/atarist/style.css @@ -244,6 +244,7 @@ h2 { font-size: 1.4rem; } +.posts-list h2, h3 { font-size: 1.2rem; } @@ -593,6 +594,7 @@ footer a:focus { font-size: 1.2rem; } + .posts-list h2, h3 { font-size: 1.1rem; } diff --git a/themes/bauhaus/style.css b/themes/bauhaus/style.css index 729e4c1..7b29f97 100644 --- a/themes/bauhaus/style.css +++ b/themes/bauhaus/style.css @@ -486,7 +486,7 @@ footer a:focus { border-bottom: none; } -.posts-list h3 { +.posts-list h2 { margin-top: 0; font-size: 1.8rem; } diff --git a/themes/bbs/style.css b/themes/bbs/style.css index 9cad0c1..39925d2 100644 --- a/themes/bbs/style.css +++ b/themes/bbs/style.css @@ -315,11 +315,13 @@ h2::before { color: var(--accent-1); } +.posts-list h2, h3 { font-size: 1.3rem; color: var(--link-hover); } +.posts-list h2::before, h3::before { content: "> "; color: var(--accent-1); @@ -920,6 +922,7 @@ hr::before { font-size: 1.4rem; } + .posts-list h2, h3 { font-size: 1.2rem; } @@ -975,6 +978,7 @@ hr::before { font-size: 1.2rem; } + .posts-list h2, h3 { font-size: 1.1rem; } @@ -988,4 +992,4 @@ hr::before { font-size: 0.8rem; padding: 6px 8px; } -} \ No newline at end of file +} diff --git a/themes/beos/style.css b/themes/beos/style.css index b403a5a..edd9cd2 100644 --- a/themes/beos/style.css +++ b/themes/beos/style.css @@ -252,6 +252,7 @@ h2 { font-size: 1.6rem; } +.posts-list h2, h3 { font-size: 1.4rem; } @@ -717,6 +718,7 @@ tr:nth-child(even) { font-size: 1.4rem; } + .posts-list h2, h3 { font-size: 1.2rem; } diff --git a/themes/blackberry/style.css b/themes/blackberry/style.css index 6e7a676..0734f30 100644 --- a/themes/blackberry/style.css +++ b/themes/blackberry/style.css @@ -637,7 +637,7 @@ hr { margin-bottom: 2rem; } -.posts-list h2, .posts-list h3 { +.posts-list h2 { margin-top: 0; margin-bottom: 0.5rem; } diff --git a/themes/braun/style.css b/themes/braun/style.css index 563bfe6..9ae4abb 100644 --- a/themes/braun/style.css +++ b/themes/braun/style.css @@ -441,7 +441,7 @@ footer a:focus { padding-bottom: 0; } -.posts-list h3 { +.posts-list h2 { margin-top: 0; font-size: 1.5rem; margin-bottom: calc(var(--grid-size) * 1); diff --git a/themes/brutalist/style.css b/themes/brutalist/style.css index 72b1c4f..a77cfdc 100644 --- a/themes/brutalist/style.css +++ b/themes/brutalist/style.css @@ -266,6 +266,7 @@ h2 { letter-spacing: -1px; } +.posts-list h2, h3 { font-size: 1.5rem; } @@ -667,6 +668,7 @@ footer a:focus { font-size: 1.8rem; } + .posts-list h2, h3 { font-size: 1.5rem; } @@ -741,6 +743,7 @@ footer a:focus { font-size: 1.6rem; } + .posts-list h2, h3 { font-size: 1.4rem; } @@ -795,6 +798,7 @@ footer a:focus { font-size: 1.5rem; } + .posts-list h2, h3 { font-size: 1.3rem; } diff --git a/themes/c64/style.css b/themes/c64/style.css index 5ae1768..5c83d88 100644 --- a/themes/c64/style.css +++ b/themes/c64/style.css @@ -481,12 +481,12 @@ hr { margin-bottom: 2rem; } -.posts-list h2, .posts-list h3 { +.posts-list h2 { margin-top: 0; margin-bottom: 0.5rem; } -.posts-list h2::before, .posts-list h3::before { +.posts-list h2::before { content: ">"; margin-right: 0.5rem; color: var(--link-color); diff --git a/themes/cyber-dark/style.css b/themes/cyber-dark/style.css index 4b379a3..1b1f60b 100644 --- a/themes/cyber-dark/style.css +++ b/themes/cyber-dark/style.css @@ -18,6 +18,7 @@ a { font-family:sans-serif; color:var(--highlight2); text-decoration:none; } body { font-size:1.1em; color:var(--text); padding:0.2em; font-family:sans-serif; max-width:60em; margin:auto; line-height:1.5; } h1 { font-size:2em; color:var(--highlight1); text-shadow:0 0 20px; } h2 { font-size:1.7em; color:var(--highlight1); text-shadow:0 0 20px; } +.posts-list h2, h3 { font-size:1.4em; color:var(--highlight1); text-shadow:0 0 20px; } nav { display:block; text-align:center; padding-top:0.8em; padding-bottom:3.5em; } nav { a { padding-left:0.5em; padding-right:0.5em; text-decoration:underline var(--highlight1); text-shadow:0 0 9px var(--highlight1); } } @@ -26,7 +27,7 @@ p { padding-top:0.5em; padding-bottom:0.5em; } header { text-align:center; margin:auto; } header { p { text-shadow:0 0 10px var(--highlight2); } } img { display:block; max-width: 100%; margin: auto; padding-top: 20px; padding-bottom: 20px; } -.posts-list { h3 { a { color:var(--highlight1); text-decoration: underline var(--highlight2); text-shadow:0 0 15px var(--highlight2); } } } +.posts-list { h2 { a { color:var(--highlight1); text-decoration: underline var(--highlight2); text-shadow:0 0 15px var(--highlight2); } } } .featured-image.index-image { img { display:block; max-width: 100%; max-height:640px; margin: auto; padding-top: 20px; padding-bottom: 20px; } } .image-caption { color:var(--muted-text); text-align: center; } .site-title { a { text-shadow:0 0 30px var(--highlight2); text-decoration:underline 2px var(--highlight2); font-size:3em; font-weight:bold; color:var(--highlight1); }} diff --git a/themes/dark/style.css b/themes/dark/style.css index 764361b..e6fb210 100644 --- a/themes/dark/style.css +++ b/themes/dark/style.css @@ -525,7 +525,7 @@ footer::before { padding-bottom: 1.5rem; } -.posts-list h3 { +.posts-list h2 { margin-top: 0; margin-bottom: 0.5rem; } diff --git a/themes/default/style.css b/themes/default/style.css index bf8f1d9..725ada9 100644 --- a/themes/default/style.css +++ b/themes/default/style.css @@ -742,18 +742,18 @@ hr::before { padding-bottom: 1.5rem; } -.posts-list h2, .posts-list h3 { +.posts-list h2 { margin-top: 0; margin-bottom: 0.5rem; } -.posts-list h2 a, .posts-list h3 a { +.posts-list h2 a { outline: 2px solid transparent; outline-offset: 2px; border-radius: 2px; } -.posts-list h2 a:focus, .posts-list h3 a:focus { +.posts-list h2 a:focus { outline-color: var(--link-color); } diff --git a/themes/diary/style.css b/themes/diary/style.css index cb963f1..731473c 100644 --- a/themes/diary/style.css +++ b/themes/diary/style.css @@ -336,10 +336,15 @@ h2::after { background: linear-gradient(to right, var(--accent-primary), transparent); } +.posts-list h2, h3 { font-size: 1.5rem; } +.posts-list h2::after { + content: none; +} + h4 { font-size: 1.25rem; } @@ -822,6 +827,7 @@ article { font-size: 1.6rem; } + .posts-list h2, h3 { font-size: 1.3rem; } @@ -878,6 +884,7 @@ article { font-size: 1.5rem; } + .posts-list h2, h3 { font-size: 1.25rem; } @@ -1007,6 +1014,7 @@ article { font-size: 1.4rem; } + .posts-list h2, h3 { font-size: 1.2rem; } diff --git a/themes/field-journal/style.css b/themes/field-journal/style.css new file mode 100644 index 0000000..e13a04d --- /dev/null +++ b/themes/field-journal/style.css @@ -0,0 +1,461 @@ +/* + * Field Journal Theme for BSSG + * Warm paper, natural inks, and notebook-like details. + */ + +:root { + --bg-color: #f6f0e2; + --paper-color: #fffaf0; + --text-color: #2f3a2b; + --heading-color: #273221; + --muted-text: #68725f; + --link-color: #365d3b; + --link-hover: #234028; + --border-color: #d5c9a8; + --accent-color: #8d6f43; + --accent-soft: #ede2cb; + --tag-bg: #e8dcc2; + --tag-text: #3f5133; + --quote-bg: #f2e8d3; + --code-bg: #efe6d2; + --content-width: 860px; + --radius: 8px; + --shadow: 0 4px 12px rgba(68, 53, 24, 0.11); + --font-body: "Palatino Linotype", Palatino, "Book Antiqua", Georgia, serif; + --font-heading: "Hoefler Text", Baskerville, "Times New Roman", serif; + --font-ui: "Trebuchet MS", Verdana, Arial, sans-serif; + --font-mono: "SFMono-Regular", Menlo, Consolas, "Liberation Mono", monospace; +} + +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; + } +} + +*, +*::before, +*::after { + box-sizing: border-box; +} + +html { + font-size: 18px; +} + +body { + margin: 0; + color: var(--text-color); + background-color: var(--bg-color); + background-image: + radial-gradient(circle at 2px 2px, rgba(92, 77, 46, 0.06) 0.6px, transparent 0.6px), + linear-gradient(180deg, #f8f3e7 0%, #f2ead7 100%); + background-size: 4px 4px, 100% 100%; + font-family: var(--font-body); + line-height: 1.75; +} + +::selection { + background: #d7c49a; + color: #20261b; +} + +.container { + max-width: var(--content-width); + margin: 0 auto; + padding: 2rem 1.3rem 2.8rem; +} + +header { + margin-bottom: 2rem; + padding: 1.2rem 0 1rem; + border-top: 2px solid var(--border-color); + border-bottom: 2px solid var(--border-color); + background: linear-gradient(180deg, rgba(255, 252, 244, 0.82) 0%, rgba(246, 238, 221, 0.72) 100%); +} + +.site-title, +header h1 { + margin: 0; + color: var(--heading-color); + font-family: var(--font-heading); + font-size: clamp(2rem, 4.4vw, 2.9rem); + font-weight: 600; + letter-spacing: 0.02em; +} + +.site-title a { + color: inherit; + text-decoration: none; +} + +header p { + margin: 0.45rem 0 0; + color: var(--muted-text); + font-family: var(--font-ui); + font-size: 0.85rem; + letter-spacing: 0.09em; + text-transform: uppercase; +} + +nav { + margin-top: 0.95rem; + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +nav a { + display: inline-block; + text-decoration: none; + color: var(--link-color); + border: 1px solid #cfbf9b; + border-radius: 999px; + padding: 0.22rem 0.68rem; + font-family: var(--font-ui); + font-size: 0.74rem; + letter-spacing: 0.08em; + text-transform: uppercase; + background: rgba(255, 251, 239, 0.84); +} + +nav a:hover, +nav a:focus { + color: #1d3622; + background: #e5d9bd; +} + +main { + min-height: 62vh; +} + +article { + margin-bottom: 1.5rem; +} + +article.post, +article.page, +.posts-list article { + background: var(--paper-color); + border: 1px solid #d3c4a0; + border-radius: var(--radius); + box-shadow: var(--shadow); + padding: 1.35rem 1.15rem 1.15rem; +} + +h1, +h2, +h3, +h4, +h5, +h6 { + margin: 1.25rem 0 0.75rem; + color: var(--heading-color); + font-family: var(--font-heading); + line-height: 1.3; +} + +h1 { font-size: clamp(1.8rem, 3.7vw, 2.4rem); } +h2 { font-size: clamp(1.45rem, 3.1vw, 1.9rem); } +h3 { font-size: clamp(1.18rem, 2.3vw, 1.45rem); } + +p, +ul, +ol { + margin-top: 0; + margin-bottom: 1rem; +} + +article.post > p:first-of-type::first-letter { + float: left; + font-size: 2.6em; + line-height: 0.88; + margin-right: 0.12em; + margin-top: 0.06em; + color: #4a633f; +} + +a { + color: var(--link-color); + text-underline-offset: 0.16em; +} + +a:hover, +a:focus { + color: var(--link-hover); +} + +.page-meta { + margin-bottom: 0.85rem; +} + +.meta { + margin: 0; + color: var(--muted-text); + font-family: var(--font-ui); + font-size: 0.77rem; + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.reading-time { + margin-top: 0.28rem; +} + +.summary { + margin-top: 0.75rem; + color: #43533a; +} + +.post-content { + margin-top: 0.7rem; +} + +.featured-image, +.index-image, +.tag-image, +.archive-image, +.author-image { + margin: 1.05rem 0; +} + +img { + max-width: 100%; + height: auto; + display: block; + border-radius: 4px; + border: 1px solid #bcae8c; +} + +.image-caption, +figcaption { + margin-top: 0.38rem; + color: var(--muted-text); + font-style: italic; + font-size: 0.86rem; +} + +blockquote { + margin: 1.25rem 0; + padding: 0.75rem 0.95rem; + background: var(--quote-bg); + border-left: 4px solid #9a8256; + color: #3d4d34; +} + +pre, +code { + font-family: var(--font-mono); + font-size: 0.87rem; +} + +code { + background: var(--code-bg); + padding: 0.1rem 0.3rem; + border-radius: 3px; +} + +pre { + background: var(--code-bg); + border: 1px solid #cdbd99; + border-radius: 6px; + padding: 0.86rem; + overflow-x: auto; +} + +pre code { + background: none; + padding: 0; +} + +hr { + border: 0; + border-top: 2px dashed #c7b792; + margin: 1.3rem 0; +} + +.tags { + display: flex; + flex-wrap: wrap; + gap: 0.42rem; + margin-top: 1rem; +} + +.tags a, +.tags-list a { + display: inline-block; + padding: 0.22rem 0.62rem; + border-radius: 999px; + border: 1px solid #bea97d; + background: var(--tag-bg); + color: var(--tag-text); + text-decoration: none; + font-family: var(--font-ui); + font-size: 0.75rem; + letter-spacing: 0.05em; +} + +.tags a:hover, +.tags a:focus, +.tags-list a:hover, +.tags-list a:focus { + background: #ddcea9; +} + +.tags-list { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.tag-count { + color: #5e6b57; +} + +.posts-list article + article { + margin-top: 0.95rem; +} + +.posts-list h2 { + margin-top: 0; +} + +.pagination { + margin-top: 1.4rem; + display: flex; + flex-wrap: wrap; + gap: 0.55rem; + align-items: center; +} + +.pagination a { + display: inline-block; + padding: 0.26rem 0.7rem; + border-radius: 999px; + border: 1px solid #baa778; + background: #f0e5cd; + color: #32452b; + text-decoration: none; + font-family: var(--font-ui); + font-size: 0.75rem; + letter-spacing: 0.05em; +} + +.pagination a:hover, +.pagination a:focus { + background: #e3d2ab; +} + +.page-info { + color: var(--muted-text); + font-family: var(--font-ui); + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.07em; +} + +.related-posts { + margin-top: 1.6rem; + padding-top: 1rem; + border-top: 2px dotted #bca779; +} + +.related-posts h3 { + margin-top: 0; + font-size: 1.1rem; +} + +.related-posts-list { + display: grid; + gap: 0.7rem; +} + +.related-post { + background: var(--accent-soft); + border: 1px solid #ccb993; + border-left: 4px solid #8f7448; + border-radius: 5px; + padding: 0.62rem 0.72rem; +} + +.related-post h4 { + margin: 0 0 0.3rem; +} + +.related-post p { + margin: 0; + color: #495b3f; +} + +footer { + margin-top: 2rem; + border-top: 2px solid var(--border-color); + padding-top: 0.95rem; + color: #62705d; + font-family: var(--font-ui); + font-size: 0.76rem; + letter-spacing: 0.04em; +} + +footer p { + margin: 0.38rem 0; +} + +footer a { + color: inherit; +} + +footer a:hover, +footer a:focus { + color: #2f4b35; +} + +table { + width: 100%; + border-collapse: collapse; + margin: 0.95rem 0; +} + +th, +td { + border: 1px solid #c9ba96; + padding: 0.42rem 0.58rem; +} + +th { + background: #efe3c9; + font-family: var(--font-ui); + font-size: 0.78rem; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +@media (max-width: 780px) { + html { + font-size: 17px; + } + + .container { + padding: 1.3rem 0.9rem 2.2rem; + } + + article.post, + article.page, + .posts-list article { + padding: 1.05rem 0.9rem 0.95rem; + } + + nav { + gap: 0.38rem; + } + + nav a, + .meta, + .pagination a, + .page-info { + letter-spacing: 0.04em; + } +} diff --git a/themes/flat/style.css b/themes/flat/style.css index 55ece43..988748b 100644 --- a/themes/flat/style.css +++ b/themes/flat/style.css @@ -228,6 +228,7 @@ h2 { margin-top: 40px; } +.posts-list h2, h3 { font-size: 20px; margin-top: 30px; @@ -605,6 +606,7 @@ footer a:focus::after { font-size: 24px; } + .posts-list h2, h3 { font-size: 18px; } @@ -727,6 +729,7 @@ footer a:focus::after { margin-top: 30px; } + .posts-list h2, h3 { font-size: 17px; margin-top: 20px; @@ -794,6 +797,7 @@ footer a:focus::after { font-size: 18px; } + .posts-list h2, h3 { font-size: 16px; } diff --git a/themes/freebsd/style.css b/themes/freebsd/style.css new file mode 100644 index 0000000..4c64628 --- /dev/null +++ b/themes/freebsd/style.css @@ -0,0 +1,516 @@ +/* + * FreeBSD Theme for BSSG + * Recognizable FreeBSD-inspired look using two reds + black and the orb motif. + */ + +:root { + --freebsd-red-1: #c61f2b; + --freebsd-red-2: #9b1a22; + --freebsd-black: #101112; + --freebsd-charcoal: #1a1d1f; + --paper: #fcfcfc; + --paper-2: #f4f5f6; + --text: #1d1f22; + --muted: #5a6169; + --border: #d5d9de; + --link: #a3151f; + --link-hover: #7f0f18; + --tag-bg: #f8e7e9; + --tag-text: #7f141d; + --quote-bg: #f6f7f8; + --code-bg: #f2f4f6; + --radius: 8px; + --shadow: 0 12px 28px rgba(0, 0, 0, 0.25); + --content-width: 920px; + --font-body: "Roboto", "Helvetica Neue", Arial, sans-serif; + --font-heading: "Montserrat", "Arial Narrow", "Trebuchet MS", sans-serif; + --font-mono: "SFMono-Regular", Menlo, Consolas, "Liberation Mono", monospace; +} + +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; + } +} + +*, +*::before, +*::after { + box-sizing: border-box; +} + +html { + font-size: 17px; +} + +body { + margin: 0; + color: var(--text); + font-family: var(--font-body); + line-height: 1.7; + background: + radial-gradient(circle at 80% 10%, rgba(198, 31, 43, 0.22) 0, transparent 32%), + linear-gradient(180deg, #1b1d20 0%, #0f1011 100%); +} + +::selection { + background: #f0c8cc; + color: #1a1a1a; +} + +.container { + max-width: var(--content-width); + margin: 0 auto; + padding: 0 1.25rem 2.7rem; + background: linear-gradient(180deg, #ffffff 0%, var(--paper) 100%); + border-left: 1px solid #3c4146; + border-right: 1px solid #3c4146; + box-shadow: var(--shadow); + min-height: 100vh; +} + +header { + margin: 0 -1.25rem 2rem; + padding: 1.1rem 1.25rem 1rem; + background: + linear-gradient(135deg, rgba(198, 31, 43, 0.12) 0 14%, transparent 15%), + linear-gradient(180deg, #fefefe 0%, #ebedf0 100%); + border-top: 6px solid var(--freebsd-red-1); + border-bottom: 1px solid #c8cfd6; + position: relative; +} + +header::before { + content: "FreeBSD: The Power To Serve"; + position: absolute; + top: 12px; + right: 18px; + background: linear-gradient(180deg, #c42935 0%, #981b24 100%); + color: #fff; + border: 1px solid #7f141d; + border-radius: 4px; + padding: 0.16rem 0.56rem; + font-family: var(--font-mono); + font-size: 0.58rem; + font-weight: 700; + letter-spacing: 0.01em; + text-transform: none; + box-shadow: 0 3px 6px rgba(0, 0, 0, 0.22); +} + +header::after { + content: ""; + position: absolute; + inset: auto 0 0; + height: 2px; + background: linear-gradient(90deg, transparent, var(--freebsd-red-1), transparent); +} + +.site-title, +header h1 { + margin: 0; + color: var(--freebsd-black); + font-family: var(--font-heading); + font-weight: 700; + font-size: clamp(1.9rem, 3.8vw, 2.75rem); + letter-spacing: 0.03em; + text-transform: uppercase; + line-height: 1.2; +} + +.site-title a { + color: inherit; + text-decoration: none; +} + +.site-title a:hover, +.site-title a:focus { + color: var(--freebsd-red-2); +} + +header p { + margin: 0.42rem 0 0; + color: #4f5660; + font-size: 0.78rem; + text-transform: uppercase; + letter-spacing: 0.1em; + font-weight: 600; +} + +nav { + margin-top: 0.95rem; + display: flex; + flex-wrap: wrap; + gap: 0.45rem; +} + +nav a { + display: inline-block; + color: #eef1f4; + background: linear-gradient(180deg, #2c3035 0%, #1c1f23 100%); + border: 1px solid #4b5158; + border-bottom-color: #7a8796; + border-radius: 5px; + padding: 0.26rem 0.6rem; + text-decoration: none; + font-size: 0.73rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.07em; + transition: transform 0.16s ease, background-color 0.16s ease; +} + +nav a:hover, +nav a:focus { + background: linear-gradient(180deg, #b12430 0%, #8f1b24 100%); + border-color: #7f1a23; + color: #fff; + transform: translateY(-1px); +} + +main { + min-height: 62vh; +} + +article { + margin-bottom: 1.45rem; +} + +article.post, +article.page, +.posts-list article { + background: var(--paper); + border: 1px solid var(--border); + border-left: 5px solid var(--freebsd-red-1); + border-radius: var(--radius); + padding: 1.2rem 1rem 1rem; +} + +h1, +h2, +h3, +h4, +h5, +h6 { + color: #141619; + font-family: var(--font-heading); + margin: 1.1rem 0 0.7rem; + line-height: 1.3; + letter-spacing: 0.01em; +} + +h1 { font-size: clamp(1.75rem, 3.2vw, 2.25rem); } +h2 { font-size: clamp(1.4rem, 2.6vw, 1.8rem); } +h3 { font-size: clamp(1.16rem, 2vw, 1.42rem); } + +p, +ul, +ol { + margin-top: 0; + margin-bottom: 0.95rem; +} + +a { + color: var(--link); + text-decoration-thickness: 1px; + text-underline-offset: 0.15em; +} + +a:hover, +a:focus { + color: var(--link-hover); +} + +.page-meta { + margin-bottom: 0.75rem; +} + +.meta { + margin: 0; + color: var(--muted); + font-family: var(--font-mono); + font-size: 0.74rem; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.reading-time { + margin-top: 0.26rem; +} + +.summary { + margin-top: 0.75rem; + color: #3c4249; +} + +.post-content { + margin-top: 0.65rem; +} + +.featured-image, +.index-image, +.tag-image, +.archive-image, +.author-image { + margin: 0.95rem 0; +} + +img { + max-width: 100%; + height: auto; + display: block; + border-radius: 4px; + border: 1px solid #b9c1ca; +} + +.image-caption, +figcaption { + margin-top: 0.35rem; + color: #626a73; + font-size: 0.78rem; + font-style: italic; +} + +blockquote { + margin: 1rem 0; + padding: 0.7rem 0.85rem; + border-left: 4px solid var(--freebsd-red-1); + background: var(--quote-bg); + color: #2e343b; +} + +pre, +code { + font-family: var(--font-mono); + font-size: 0.84rem; +} + +code { + background: var(--code-bg); + border: 1px solid #d8dde3; + padding: 0.08rem 0.28rem; + border-radius: 3px; +} + +pre { + background: var(--code-bg); + border: 1px solid #d3d9df; + border-radius: 6px; + padding: 0.8rem; + overflow-x: auto; +} + +pre code { + background: none; + border: 0; + padding: 0; +} + +hr { + border: 0; + border-top: 1px solid #d0d6dd; + margin: 1.15rem 0; +} + +.tags { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; + margin-top: 0.9rem; +} + +.tags a, +.tags-list a { + display: inline-block; + text-decoration: none; + color: var(--tag-text); + background: var(--tag-bg); + border: 1px solid #debec2; + border-radius: 999px; + padding: 0.18rem 0.52rem; + font-family: var(--font-mono); + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.tags a:hover, +.tags a:focus, +.tags-list a:hover, +.tags-list a:focus { + background: #f2d4d8; +} + +.tags-list { + display: flex; + flex-wrap: wrap; + gap: 0.45rem; +} + +.tag-count { + color: #6e5d60; +} + +.posts-list article + article { + margin-top: 0.85rem; +} + +.posts-list h2 { + margin-top: 0; +} + +.pagination { + margin-top: 1.25rem; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.45rem; +} + +.pagination a { + display: inline-block; + background: #f0f3f6; + color: #1f252c; + border: 1px solid #c5cdd5; + border-radius: 5px; + text-decoration: none; + padding: 0.22rem 0.62rem; + font-family: var(--font-mono); + font-size: 0.73rem; + text-transform: uppercase; + letter-spacing: 0.03em; +} + +.pagination a:hover, +.pagination a:focus { + color: #fff; + background: linear-gradient(180deg, #ba2733 0%, #931b25 100%); + border-color: #801820; +} + +.page-info { + color: var(--muted); + font-family: var(--font-mono); + font-size: 0.73rem; + text-transform: uppercase; + letter-spacing: 0.03em; +} + +.related-posts { + margin-top: 1.35rem; + padding-top: 0.85rem; + border-top: 1px dashed #bcc5ce; +} + +.related-posts h3 { + margin-top: 0; + font-size: 1.05rem; + color: #1f252b; +} + +.related-posts-list { + display: grid; + gap: 0.62rem; +} + +.related-post { + border: 1px solid #cfd6dd; + border-left: 4px solid var(--freebsd-red-1); + background: #f8fafc; + border-radius: 5px; + padding: 0.55rem 0.66rem; +} + +.related-post h4 { + margin: 0 0 0.22rem; + font-size: 0.95rem; +} + +.related-post p { + margin: 0; + color: #4b525a; + font-size: 0.9rem; +} + +table { + width: 100%; + border-collapse: collapse; + margin: 0.92rem 0; +} + +th, +td { + border: 1px solid #d2d9e0; + padding: 0.4rem 0.52rem; +} + +th { + background: #e9edf2; + font-family: var(--font-mono); + font-size: 0.73rem; + text-transform: uppercase; + letter-spacing: 0.03em; +} + +footer { + margin-top: 1.85rem; + padding: 0.9rem 0 0.1rem; + border-top: 2px solid #d1d7de; + color: #525861; + font-family: var(--font-mono); + font-size: 0.71rem; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +footer p { + margin: 0.33rem 0; +} + +footer a { + color: var(--link); +} + +footer a:hover, +footer a:focus { + color: var(--link-hover); +} + +@media (max-width: 840px) { + .container { + padding: 0 0.82rem 2rem; + } + + header { + margin: 0 -0.82rem 1.5rem; + padding: 0.92rem 0.82rem 0.82rem; + } + + nav { + padding-right: 0; + } + + header::before { + top: 10px; + right: 10px; + padding: 0.12rem 0.42rem; + font-size: 0.5rem; + letter-spacing: 0; + } + + article.post, + article.page, + .posts-list article { + padding: 0.95rem 0.8rem 0.82rem; + } + + nav a, + .meta, + .page-info, + .pagination a { + letter-spacing: 0.02em; + } +} diff --git a/themes/glassmorphism/style.css b/themes/glassmorphism/style.css index b4b5833..67d30f7 100644 --- a/themes/glassmorphism/style.css +++ b/themes/glassmorphism/style.css @@ -262,6 +262,7 @@ h2 { font-size: 2rem; } +.posts-list h2, h3 { font-size: 1.5rem; } diff --git a/themes/ios/style.css b/themes/ios/style.css index f8144b2..982586a 100644 --- a/themes/ios/style.css +++ b/themes/ios/style.css @@ -632,16 +632,16 @@ footer a:focus { box-shadow: var(--floating-shadow); } -.posts-list h3 { +.posts-list h2 { margin-top: 0; margin-bottom: var(--spacing-xs); } -.posts-list h3 a { +.posts-list h2 a { color: var(--header-color); } -.posts-list h3 a:hover, .posts-list h3 a:active { +.posts-list h2 a:hover, .posts-list h2 a:active { color: var(--accent-color); } diff --git a/themes/liquid-glass/style.css b/themes/liquid-glass/style.css new file mode 100644 index 0000000..1ba5dd6 --- /dev/null +++ b/themes/liquid-glass/style.css @@ -0,0 +1,610 @@ +/* + * Liquid Glass Theme for BSSG + * Accessibility-first translucent design with high-contrast reading surfaces. + */ + +@import url("https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap"); + +:root { + --page-bg: #0b101a; + --page-bg-2: #15213a; + --glow-a: rgba(10, 132, 255, 0.28); + --glow-b: rgba(191, 90, 242, 0.18); + --glass-surface: rgba(18, 26, 42, 0.9); + --glass-surface-strong: rgba(21, 31, 50, 0.95); + --glass-panel: rgba(27, 39, 62, 0.9); + --glass-border: rgba(173, 202, 242, 0.26); + --glass-border-strong: rgba(199, 221, 250, 0.38); + --text: #f4f7ff; + --text-muted: #c9d4ea; + --heading: #ffffff; + --link: #9dcfff; + --link-hover: #cde7ff; + --accent: #64d2ff; + --accent-2: #0a84ff; + --shadow-lg: 0 20px 48px rgba(2, 8, 20, 0.5); + --shadow-sm: 0 10px 24px rgba(2, 8, 20, 0.35); + --quote-bg: rgba(17, 31, 52, 0.86); + --quote-border: rgba(100, 210, 255, 0.72); + --code-inline-bg: rgba(11, 18, 31, 0.84); + --code-inline-border: rgba(170, 199, 240, 0.35); + --code-inline-text: #e9f2ff; + --code-block-bg: #0f1b30; + --code-block-border: rgba(155, 189, 234, 0.36); + --code-block-text: #ecf3ff; + --content-width: 980px; + --radius-lg: 22px; + --radius-md: 14px; + --radius-sm: 10px; + --blur: 14px; + --blur-light: 8px; + --font-main: "Inter", "Segoe UI", "Helvetica Neue", Arial, sans-serif; + --font-headings: "Inter", "Segoe UI", "Helvetica Neue", Arial, sans-serif; + --font-mono: "SFMono-Regular", Menlo, Consolas, "Liberation Mono", monospace; +} + +@media (prefers-color-scheme: light) { + :root { + --page-bg: #eef4ff; + --page-bg-2: #d8e5fb; + --glow-a: rgba(0, 122, 255, 0.17); + --glow-b: rgba(88, 86, 214, 0.12); + --glass-surface: rgba(255, 255, 255, 0.9); + --glass-surface-strong: rgba(255, 255, 255, 0.95); + --glass-panel: rgba(249, 252, 255, 0.93); + --glass-border: rgba(84, 117, 171, 0.24); + --glass-border-strong: rgba(63, 97, 157, 0.34); + --text: #1d2739; + --text-muted: #52617c; + --heading: #10192b; + --link: #005fcc; + --link-hover: #0047a3; + --accent: #007aff; + --accent-2: #5856d6; + --shadow-lg: 0 20px 45px rgba(58, 92, 149, 0.18); + --shadow-sm: 0 8px 22px rgba(58, 92, 149, 0.12); + --quote-bg: rgba(232, 240, 255, 0.88); + --quote-border: rgba(0, 122, 255, 0.62); + --code-inline-bg: rgba(227, 237, 255, 0.84); + --code-inline-border: rgba(79, 111, 170, 0.34); + --code-inline-text: #18345c; + --code-block-bg: #132342; + --code-block-border: rgba(56, 93, 149, 0.44); + --code-block-text: #ebf2ff; + } +} + +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; + } +} + +*, +*::before, +*::after { + box-sizing: border-box; +} + +html { + font-size: 18px; + -webkit-text-size-adjust: 100%; +} + +body { + margin: 0; + min-height: 100vh; + color: var(--text); + font-family: var(--font-main); + line-height: 1.72; + background: + radial-gradient(circle at 12% 10%, var(--glow-a) 0, transparent 36%), + radial-gradient(circle at 84% 6%, var(--glow-b) 0, transparent 32%), + linear-gradient(165deg, var(--page-bg) 0%, var(--page-bg-2) 55%, var(--page-bg) 100%); + padding: 22px; + position: relative; +} + +body::before, +body::after { + content: ""; + position: fixed; + pointer-events: none; + z-index: 0; +} + +body::before { + inset: -12% auto auto -10%; + width: 340px; + height: 340px; + border-radius: 50%; + background: radial-gradient(circle, rgba(255, 255, 255, 0.22) 0, rgba(255, 255, 255, 0.04) 58%, transparent 74%); + filter: blur(9px); +} + +body::after { + inset: auto -8% -14% auto; + width: 380px; + height: 380px; + border-radius: 50%; + background: radial-gradient(circle, rgba(255, 255, 255, 0.16) 0, rgba(255, 255, 255, 0.03) 60%, transparent 76%); + filter: blur(11px); +} + +::selection { + background: rgba(100, 210, 255, 0.35); + color: var(--heading); +} + +:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +.container { + position: relative; + z-index: 1; + max-width: var(--content-width); + margin: 0 auto; + background: var(--glass-surface); + border: 1px solid var(--glass-border); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-lg); + overflow: hidden; + backdrop-filter: blur(var(--blur)); + -webkit-backdrop-filter: blur(var(--blur)); +} + +@supports not (backdrop-filter: blur(1px)) { + .container { + background: var(--glass-surface-strong); + } +} + +.container::before { + content: ""; + position: absolute; + inset: 0; + pointer-events: none; + background: + linear-gradient(120deg, rgba(255, 255, 255, 0.15), transparent 28%), + linear-gradient(330deg, rgba(255, 255, 255, 0.08), transparent 36%); + z-index: 0; +} + +header, +main, +footer { + position: relative; + z-index: 1; +} + +header { + padding: 30px 38px 17px; + border-bottom: 1px solid var(--glass-border); + background: var(--glass-surface-strong); +} + +.site-title, +header h1 { + margin: 0; + color: var(--heading); + font-family: var(--font-headings); + font-size: clamp(2rem, 3.7vw, 2.75rem); + font-weight: 700; + letter-spacing: -0.02em; + line-height: 1.15; +} + +.site-title a { + color: inherit; + text-decoration: none; +} + +.site-title a:hover, +.site-title a:focus { + color: var(--link-hover); +} + +header p { + margin: 0.56rem 0 0; + color: var(--text-muted); + font-size: 1rem; +} + +nav { + margin-top: 1rem; + display: flex; + flex-wrap: wrap; + gap: 0.46rem; +} + +nav a { + display: inline-flex; + align-items: center; + padding: 0.32rem 0.74rem; + border-radius: 999px; + border: 1px solid var(--glass-border-strong); + background: var(--glass-panel); + color: var(--text); + text-decoration: none; + font-size: 0.82rem; + font-weight: 600; + letter-spacing: 0.01em; + backdrop-filter: blur(var(--blur-light)); + -webkit-backdrop-filter: blur(var(--blur-light)); + transition: background-color 0.16s ease, border-color 0.16s ease, color 0.16s ease, transform 0.16s ease; +} + +nav a:hover, +nav a:focus { + transform: translateY(-1px); + background: rgba(100, 210, 255, 0.2); + border-color: rgba(100, 210, 255, 0.55); + color: var(--heading); +} + +main { + padding: 28px 38px 32px; + min-height: 62vh; +} + +article { + margin-bottom: 1.25rem; +} + +article.post, +article.page, +.posts-list article { + background: var(--glass-panel); + border: 1px solid var(--glass-border); + border-radius: var(--radius-md); + box-shadow: var(--shadow-sm); + padding: 1.16rem 1.1rem; +} + +article.post + article.post, +.posts-list article + article { + margin-top: 0.95rem; +} + +h1, +h2, +h3, +h4, +h5, +h6 { + margin: 1.08rem 0 0.72rem; + color: var(--heading); + font-family: var(--font-headings); + line-height: 1.3; + letter-spacing: -0.01em; +} + +h1 { font-size: clamp(1.72rem, 3vw, 2.24rem); } +h2 { font-size: clamp(1.4rem, 2.35vw, 1.78rem); } +h3 { font-size: clamp(1.18rem, 1.8vw, 1.4rem); } + +article.post > h1, +article.page > h1, +.posts-list h2 { + margin-top: 0; +} + +p, +ul, +ol { + margin-top: 0; + margin-bottom: 0.95rem; +} + +a { + color: var(--link); + text-decoration-thickness: 1px; + text-underline-offset: 0.14em; + text-decoration-color: rgba(157, 207, 255, 0.65); +} + +a:hover, +a:focus { + color: var(--link-hover); + text-decoration-color: currentColor; +} + +.meta, +.page-meta { + color: var(--text-muted); + font-size: 0.8rem; + font-weight: 500; + letter-spacing: 0.02em; +} + +.page-meta { + margin-bottom: 0.68rem; +} + +.reading-time { + margin-top: 0.24rem; +} + +.summary { + color: var(--text-muted); +} + +.post-content { + margin-top: 0.65rem; +} + +.featured-image, +.index-image, +.tag-image, +.archive-image, +.author-image { + margin: 0.92rem 0; +} + +img { + display: block; + max-width: 100%; + height: auto; + border-radius: 12px; + border: 1px solid var(--glass-border-strong); + box-shadow: 0 8px 20px rgba(2, 8, 20, 0.3); +} + +.image-caption, +figcaption { + margin-top: 0.42rem; + color: var(--text-muted); + font-size: 0.83rem; + font-style: italic; +} + +blockquote { + margin: 1rem 0; + padding: 0.72rem 0.92rem; + border-left: 4px solid var(--quote-border); + border-radius: var(--radius-sm); + background: var(--quote-bg); + color: var(--text); +} + +pre, +code { + font-family: var(--font-mono); + font-size: 0.84rem; +} + +code { + padding: 0.09rem 0.3rem; + border-radius: 6px; + border: 1px solid var(--code-inline-border); + background: var(--code-inline-bg); + color: var(--code-inline-text); +} + +pre { + margin: 1rem 0; + padding: 0.88rem; + border-radius: var(--radius-sm); + border: 1px solid var(--code-block-border); + background: var(--code-block-bg); + color: var(--code-block-text); + overflow-x: auto; +} + +pre code { + padding: 0; + border: 0; + background: none; + color: inherit; +} + +hr { + border: 0; + margin: 1.28rem 0; + height: 1px; + background: linear-gradient(90deg, transparent, rgba(180, 206, 242, 0.7), transparent); +} + +.tags { + display: flex; + flex-wrap: wrap; + gap: 0.36rem; + margin-top: 0.84rem; +} + +.tags a, +.tags-list a { + display: inline-block; + color: var(--text); + text-decoration: none; + background: var(--glass-panel); + border: 1px solid var(--glass-border); + border-radius: 999px; + padding: 0.16rem 0.54rem; + font-size: 0.75rem; +} + +.tags a:hover, +.tags a:focus, +.tags-list a:hover, +.tags-list a:focus { + color: var(--heading); + border-color: rgba(100, 210, 255, 0.58); + background: rgba(100, 210, 255, 0.19); +} + +.tags-list { + display: flex; + flex-wrap: wrap; + gap: 0.45rem; +} + +.tag-count { + color: var(--text-muted); +} + +.pagination { + margin-top: 1.12rem; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.42rem; +} + +.pagination a { + display: inline-block; + color: var(--text); + text-decoration: none; + background: var(--glass-panel); + border: 1px solid var(--glass-border); + border-radius: 999px; + padding: 0.18rem 0.62rem; + font-size: 0.75rem; +} + +.pagination a:hover, +.pagination a:focus { + color: var(--heading); + border-color: rgba(100, 210, 255, 0.58); + background: rgba(100, 210, 255, 0.19); +} + +.page-info { + color: var(--text-muted); + font-size: 0.77rem; +} + +.related-posts { + margin-top: 1.22rem; + padding-top: 0.8rem; + border-top: 1px dashed var(--glass-border-strong); +} + +.related-posts h3 { + margin-top: 0; + font-size: 1.04rem; +} + +.related-posts-list { + display: grid; + gap: 0.58rem; +} + +.related-post { + border: 1px solid var(--glass-border); + border-radius: var(--radius-sm); + background: var(--glass-panel); + padding: 0.5rem 0.64rem; +} + +.related-post h4 { + margin: 0 0 0.18rem; + font-size: 0.93rem; +} + +.related-post p { + margin: 0; + color: var(--text-muted); + font-size: 0.88rem; +} + +table { + width: 100%; + border-collapse: collapse; + margin: 0.92rem 0; +} + +th, +td { + border: 1px solid var(--glass-border); + padding: 0.38rem 0.52rem; +} + +th { + color: var(--heading); + background: rgba(100, 210, 255, 0.14); + font-size: 0.74rem; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +footer { + padding: 17px 38px 24px; + border-top: 1px solid var(--glass-border); + color: var(--text-muted); + font-size: 0.77rem; + background: var(--glass-surface-strong); +} + +footer p { + margin: 0.3rem 0; +} + +footer a { + color: var(--link); +} + +footer a:hover, +footer a:focus { + color: var(--link-hover); +} + +@media (max-width: 900px) { + body { + padding: 14px; + } + + header, + main, + footer { + padding-left: 22px; + padding-right: 22px; + } + + .site-title, + header h1 { + font-size: clamp(1.7rem, 7vw, 2.25rem); + } +} + +@media (max-width: 640px) { + html { + font-size: 16px; + } + + body { + padding: 10px; + } + + .container { + border-radius: 16px; + } + + header, + main, + footer { + padding-left: 14px; + padding-right: 14px; + } + + nav a { + min-height: 38px; + } + + article.post, + article.page, + .posts-list article { + padding: 0.95rem 0.85rem; + } + + .meta, + .page-info, + .pagination a { + font-size: 0.74rem; + } +} diff --git a/themes/longform/style.css b/themes/longform/style.css index d6510ab..98a8448 100644 --- a/themes/longform/style.css +++ b/themes/longform/style.css @@ -562,17 +562,17 @@ main > h1 { border-bottom: none; } -.posts-list h3 { +.posts-list h2 { margin-top: 0; font-size: 1.8rem; } -.posts-list h3 a { +.posts-list h2 a { color: var(--heading-color); text-decoration: none; } -.posts-list h3 a:hover { +.posts-list h2 a:hover { color: var(--link-color); } diff --git a/themes/macclassic/style.css b/themes/macclassic/style.css index f89a5d1..bd03156 100644 --- a/themes/macclassic/style.css +++ b/themes/macclassic/style.css @@ -577,7 +577,7 @@ footer { box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); } -.posts-list h3 { +.posts-list h2 { margin-top: 0; margin-bottom: 5px; } diff --git a/themes/macos9/style.css b/themes/macos9/style.css index d012a20..c54110e 100644 --- a/themes/macos9/style.css +++ b/themes/macos9/style.css @@ -501,7 +501,7 @@ footer { border-bottom: 1px solid var(--accent-color); } -.posts-list h3 { +.posts-list h2 { margin-top: 0; margin-bottom: 0.3em; } diff --git a/themes/mario/style.css b/themes/mario/style.css index e39ecde..5ccffcc 100644 --- a/themes/mario/style.css +++ b/themes/mario/style.css @@ -345,6 +345,7 @@ h2 { margin-top: var(--spacing-xl); } +.posts-list h2, h3 { font-size: 1.3rem; color: var(--mario-pipe-green); diff --git a/themes/material/style.css b/themes/material/style.css index 2a97731..e73f70f 100644 --- a/themes/material/style.css +++ b/themes/material/style.css @@ -312,6 +312,7 @@ nav a.active::after { font-size: 1.75rem; } + .posts-list h2, h3 { font-size: 1.35rem; } @@ -351,6 +352,7 @@ nav a.active::after { font-size: 1.5rem; } + .posts-list h2, h3 { font-size: 1.25rem; } @@ -426,6 +428,7 @@ nav a.active::after { font-size: 1.35rem; } + .posts-list h2, h3 { font-size: 1.15rem; } @@ -502,6 +505,7 @@ nav a.active::after { font-size: 1.2rem; } + .posts-list h2, h3 { font-size: 1.05rem; } @@ -583,6 +587,7 @@ h2 { letter-spacing: -0.01em; } +.posts-list h2, h3 { font-size: 1.5rem; } diff --git a/themes/microfiche/style.css b/themes/microfiche/style.css new file mode 100644 index 0000000..31858a9 --- /dev/null +++ b/themes/microfiche/style.css @@ -0,0 +1,498 @@ +/* + * Microfiche Theme for BSSG + * Monochrome projection look with scanlines and archival framing. + */ + +:root { + --bg-color: #0f1110; + --screen-color: #171a18; + --panel-color: #1d211e; + --text-color: #d8ddd7; + --muted-text: #9ca39b; + --heading-color: #edf2ea; + --link-color: #e3e8e0; + --link-hover: #ffffff; + --border-color: #4e564f; + --accent-color: #b8c1b5; + --tag-bg: #2a2f2b; + --tag-text: #d5dbd2; + --code-bg: #202522; + --quote-bg: #222723; + --overlay-line: rgba(232, 238, 230, 0.04); + --overlay-noise: rgba(240, 245, 236, 0.035); + --content-width: 900px; + --radius: 7px; + --shadow: 0 12px 24px rgba(0, 0, 0, 0.48); + --font-body: "IBM Plex Sans", "Lucida Grande", "Helvetica Neue", Arial, sans-serif; + --font-heading: "Franklin Gothic Medium", "Arial Narrow", "Trebuchet MS", sans-serif; + --font-ui: "IBM Plex Mono", "SFMono-Regular", Menlo, Consolas, "Liberation Mono", monospace; + --font-mono: "IBM Plex Mono", "SFMono-Regular", Menlo, Consolas, "Liberation Mono", monospace; +} + +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; + } +} + +*, +*::before, +*::after { + box-sizing: border-box; +} + +html { + font-size: 17px; +} + +body { + margin: 0; + color: var(--text-color); + background: radial-gradient(circle at 50% 25%, #1a1f1b 0%, #0e110f 62%); + font-family: var(--font-body); + line-height: 1.7; + position: relative; +} + +body::before, +body::after { + content: ""; + position: fixed; + inset: 0; + pointer-events: none; +} + +body::before { + background: repeating-linear-gradient( + to bottom, + transparent 0, + transparent 3px, + var(--overlay-line) 4px + ); + z-index: 1; +} + +body::after { + background-image: + radial-gradient(var(--overlay-noise) 0.65px, transparent 0.65px), + radial-gradient(var(--overlay-noise) 0.65px, transparent 0.65px); + background-size: 3px 3px, 4px 4px; + background-position: 0 0, 2px 1px; + z-index: 2; +} + +::selection { + background: #dce2d8; + color: #101311; +} + +.container { + max-width: var(--content-width); + margin: 0 auto; + padding: 1.9rem 1.1rem 2.6rem; + position: relative; + z-index: 3; + background: linear-gradient(180deg, rgba(27, 30, 28, 0.9) 0%, rgba(18, 21, 19, 0.92) 100%); + border-left: 1px solid var(--border-color); + border-right: 1px solid var(--border-color); + box-shadow: var(--shadow); +} + +header { + margin-bottom: 1.8rem; + padding: 1rem 0 0.95rem; + border: 1px solid var(--border-color); + background: linear-gradient(180deg, #202521 0%, #171b18 100%); +} + +.site-title, +header h1 { + margin: 0; + color: var(--heading-color); + font-family: var(--font-heading); + font-weight: 600; + font-size: clamp(1.9rem, 3.8vw, 2.7rem); + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.site-title a { + color: inherit; + text-decoration: none; +} + +header p { + margin: 0.42rem 0 0; + color: var(--muted-text); + font-family: var(--font-ui); + font-size: 0.74rem; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +header, +main, +footer { + padding-left: 0.85rem; + padding-right: 0.85rem; +} + +nav { + margin-top: 0.82rem; + display: flex; + flex-wrap: wrap; + gap: 0.45rem 0.5rem; +} + +nav a { + display: inline-block; + color: var(--tag-text); + background: #242926; + border: 1px solid #5b655d; + text-decoration: none; + font-family: var(--font-ui); + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.08em; + padding: 0.18rem 0.5rem; +} + +nav a:hover, +nav a:focus { + background: #303732; + color: #f4f8f2; +} + +main { + min-height: 62vh; +} + +article { + margin-bottom: 1.4rem; +} + +article.post, +article.page, +.posts-list article { + background: var(--panel-color); + border: 1px solid var(--border-color); + border-radius: var(--radius); + padding: 1.1rem 1rem 0.95rem; +} + +h1, +h2, +h3, +h4, +h5, +h6 { + color: var(--heading-color); + font-family: var(--font-heading); + margin: 1.15rem 0 0.7rem; + line-height: 1.3; + letter-spacing: 0.03em; +} + +h1 { font-size: clamp(1.75rem, 3.4vw, 2.25rem); } +h2 { font-size: clamp(1.4rem, 2.7vw, 1.75rem); } +h3 { font-size: clamp(1.18rem, 2.2vw, 1.4rem); } + +p, +ul, +ol { + margin-top: 0; + margin-bottom: 0.95rem; +} + +a { + color: var(--link-color); + text-decoration-thickness: 1px; + text-decoration-style: dotted; + text-underline-offset: 0.14em; +} + +a:hover, +a:focus { + color: var(--link-hover); +} + +.page-meta { + margin-bottom: 0.8rem; +} + +.meta { + margin: 0; + color: var(--muted-text); + font-family: var(--font-ui); + font-size: 0.72rem; + letter-spacing: 0.07em; + text-transform: uppercase; +} + +.reading-time { + margin-top: 0.25rem; +} + +.summary { + margin-top: 0.68rem; + color: #b8beb6; +} + +.post-content { + margin-top: 0.7rem; +} + +.featured-image, +.index-image, +.tag-image, +.archive-image, +.author-image { + margin: 1rem 0; +} + +img { + display: block; + max-width: 100%; + height: auto; + border: 1px solid #5b635c; + filter: grayscale(100%) contrast(1.06) brightness(0.95); +} + +.image-caption, +figcaption { + margin-top: 0.35rem; + color: var(--muted-text); + font-family: var(--font-ui); + font-size: 0.71rem; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +blockquote { + margin: 1rem 0; + padding: 0.65rem 0.85rem; + border-left: 3px solid #7f887f; + background: var(--quote-bg); + color: #ced5cd; +} + +pre, +code { + font-family: var(--font-mono); + font-size: 0.84rem; +} + +code { + background: var(--code-bg); + border: 1px solid #3f4741; + padding: 0.08rem 0.28rem; + border-radius: 3px; +} + +pre { + background: var(--code-bg); + border: 1px solid #3f4741; + border-radius: 4px; + padding: 0.8rem; + overflow-x: auto; +} + +pre code { + background: none; + border: 0; + padding: 0; +} + +hr { + border: 0; + border-top: 1px dashed #556058; + margin: 1.2rem 0; +} + +.tags { + display: flex; + flex-wrap: wrap; + gap: 0.36rem; + margin-top: 0.9rem; +} + +.tags a, +.tags-list a { + display: inline-block; + text-decoration: none; + color: var(--tag-text); + background: var(--tag-bg); + border: 1px solid #606a62; + border-radius: 3px; + padding: 0.18rem 0.45rem; + font-family: var(--font-ui); + font-size: 0.72rem; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.tags a:hover, +.tags a:focus, +.tags-list a:hover, +.tags-list a:focus { + background: #333a35; +} + +.tags-list { + display: flex; + flex-wrap: wrap; + gap: 0.44rem; +} + +.tag-count { + color: var(--muted-text); +} + +.posts-list article + article { + margin-top: 0.9rem; +} + +.posts-list h2 { + margin-top: 0; +} + +.pagination { + margin-top: 1.3rem; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem; +} + +.pagination a { + display: inline-block; + text-decoration: none; + color: var(--text-color); + border: 1px solid #68726a; + background: #2a2f2b; + padding: 0.2rem 0.55rem; + font-family: var(--font-ui); + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.07em; +} + +.pagination a:hover, +.pagination a:focus { + background: #38403a; + color: #f0f5ee; +} + +.page-info { + color: var(--muted-text); + font-family: var(--font-ui); + font-size: 0.73rem; + letter-spacing: 0.07em; + text-transform: uppercase; +} + +.related-posts { + margin-top: 1.45rem; + padding-top: 0.95rem; + border-top: 1px dashed #5e675f; +} + +.related-posts h3 { + margin-top: 0; + font-size: 1.05rem; +} + +.related-posts-list { + display: grid; + gap: 0.65rem; +} + +.related-post { + border: 1px solid #5a645c; + background: #242925; + padding: 0.55rem 0.66rem; +} + +.related-post h4 { + margin: 0 0 0.24rem; + font-size: 0.95rem; +} + +.related-post p { + margin: 0; + color: #b7beb5; + font-size: 0.9rem; +} + +table { + width: 100%; + border-collapse: collapse; + margin: 0.95rem 0; +} + +th, +td { + border: 1px solid #505850; + padding: 0.4rem 0.52rem; +} + +th { + background: #2b302d; + font-family: var(--font-ui); + font-size: 0.73rem; + letter-spacing: 0.07em; + text-transform: uppercase; +} + +footer { + margin-top: 1.8rem; + border-top: 1px solid var(--border-color); + padding-top: 0.8rem; + color: var(--muted-text); + font-family: var(--font-ui); + font-size: 0.71rem; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +footer p { + margin: 0.34rem 0; +} + +footer a { + color: inherit; +} + +footer a:hover, +footer a:focus { + color: #f5f9f1; +} + +@media (max-width: 820px) { + .container { + padding: 1.25rem 0.75rem 2rem; + } + + header, + main, + footer { + padding-left: 0.6rem; + padding-right: 0.6rem; + } + + article.post, + article.page, + .posts-list article { + padding: 0.92rem 0.8rem 0.82rem; + } + + nav a, + .meta, + .page-info, + .pagination a { + letter-spacing: 0.04em; + } +} diff --git a/themes/mid-century/style.css b/themes/mid-century/style.css index 8e20f24..b2ea0fe 100644 --- a/themes/mid-century/style.css +++ b/themes/mid-century/style.css @@ -630,7 +630,7 @@ img { transform: translateX(5px); } -.posts-list h3 { +.posts-list h2 { margin-top: 0; font-size: 1.8rem; } diff --git a/themes/minimal/style.css b/themes/minimal/style.css index 31510c7..6598089 100644 --- a/themes/minimal/style.css +++ b/themes/minimal/style.css @@ -467,18 +467,18 @@ footer a:focus { margin-bottom: 1.75rem; } -.posts-list h3 { +.posts-list h2 { margin-top: 0; margin-bottom: 0.5rem; } -.posts-list h3 a { +.posts-list h2 a { outline: 2px solid transparent; outline-offset: 2px; transition: outline-color var(--transition); } -.posts-list h3 a:focus { +.posts-list h2 a:focus { outline-color: var(--link-color); } diff --git a/themes/mondrian/style.css b/themes/mondrian/style.css index 03a2aa4..3405132 100644 --- a/themes/mondrian/style.css +++ b/themes/mondrian/style.css @@ -579,7 +579,7 @@ figcaption, margin-bottom: 1rem; } -.posts-list h3 { +.posts-list h2 { margin-top: 0; font-size: 1.8rem; text-align: left; diff --git a/themes/msdos/style.css b/themes/msdos/style.css index df4e3be..be5f5bc 100644 --- a/themes/msdos/style.css +++ b/themes/msdos/style.css @@ -264,6 +264,7 @@ h2 { margin-top: calc(var(--spacing-unit) * 3); } +.posts-list h2, h3 { font-size: 1.1rem; margin-top: calc(var(--spacing-unit) * 2.5); diff --git a/themes/museum-label/style.css b/themes/museum-label/style.css new file mode 100644 index 0000000..e9145ec --- /dev/null +++ b/themes/museum-label/style.css @@ -0,0 +1,446 @@ +/* + * Museum Label Theme for BSSG + * Gallery-inspired typography: serif reading text with restrained sans metadata. + */ + +:root { + --bg-color: #fbfaf8; + --paper-color: #ffffff; + --text-color: #1d1d1b; + --muted-text: #5a5a56; + --heading-color: #111110; + --link-color: #314f68; + --link-hover: #1f3446; + --border-color: #d9d4cb; + --accent-color: #8d7754; + --tag-bg: #f4efe5; + --tag-text: #54452e; + --quote-border: #b6aa95; + --code-bg: #f5f5f3; + --shadow: 0 3px 10px rgba(0, 0, 0, 0.06); + --radius: 6px; + --content-width: 860px; + --font-body: "Iowan Old Style", "Palatino Linotype", Palatino, "Book Antiqua", Georgia, serif; + --font-heading: Baskerville, "Times New Roman", Times, serif; + --font-ui: "Gill Sans", "Avenir Next", "Trebuchet MS", Arial, sans-serif; + --font-mono: "SFMono-Regular", Menlo, Consolas, "Liberation Mono", monospace; +} + +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; + } +} + +*, +*::before, +*::after { + box-sizing: border-box; +} + +html { + font-size: 18px; +} + +body { + margin: 0; + color: var(--text-color); + background-color: var(--bg-color); + background-image: linear-gradient(180deg, #fffdf9 0%, #f7f4ee 100%); + font-family: var(--font-body); + line-height: 1.7; +} + +::selection { + background-color: #e7dbc4; + color: #1a1a18; +} + +.container { + max-width: var(--content-width); + margin: 0 auto; + padding: 2.25rem 1.4rem 3rem; +} + +header { + border-top: 2px solid var(--border-color); + border-bottom: 1px solid var(--border-color); + padding: 1.35rem 0 1.15rem; + margin-bottom: 2.1rem; +} + +.site-title, +header h1 { + margin: 0; + color: var(--heading-color); + font-family: var(--font-heading); + font-size: clamp(2rem, 4.2vw, 2.9rem); + font-weight: 600; + letter-spacing: 0.02em; +} + +.site-title a { + color: inherit; + text-decoration: none; +} + +header p { + margin: 0.45rem 0 0; + color: var(--muted-text); + font-family: var(--font-ui); + font-size: 0.8rem; + text-transform: uppercase; + letter-spacing: 0.12em; +} + +nav { + display: flex; + flex-wrap: wrap; + gap: 0.8rem 1rem; + margin-top: 1rem; +} + +nav a { + color: var(--muted-text); + text-decoration: none; + font-family: var(--font-ui); + font-size: 0.74rem; + letter-spacing: 0.13em; + text-transform: uppercase; + border-bottom: 1px solid transparent; + padding-bottom: 0.1rem; +} + +nav a:hover, +nav a:focus { + color: var(--heading-color); + border-bottom-color: var(--heading-color); +} + +main { + min-height: 62vh; +} + +article { + margin-bottom: 1.8rem; +} + +article.post, +article.page, +.posts-list article { + background: var(--paper-color); + border: 1px solid var(--border-color); + box-shadow: var(--shadow); + border-radius: var(--radius); + padding: 1.45rem 1.25rem 1.2rem; +} + +h1, +h2, +h3, +h4, +h5, +h6 { + color: var(--heading-color); + font-family: var(--font-heading); + line-height: 1.3; + margin: 1.35rem 0 0.85rem; +} + +h1 { font-size: clamp(1.8rem, 3.8vw, 2.45rem); } +h2 { font-size: clamp(1.45rem, 3vw, 1.85rem); } +h3 { font-size: clamp(1.2rem, 2.2vw, 1.45rem); } + +p, +ul, +ol { + margin-top: 0; + margin-bottom: 1rem; +} + +a { + color: var(--link-color); + text-decoration-thickness: 1px; + text-underline-offset: 0.15em; +} + +a:hover, +a:focus { + color: var(--link-hover); +} + +.page-meta { + margin-bottom: 0.9rem; +} + +.meta { + margin: 0; + color: var(--muted-text); + font-family: var(--font-ui); + font-size: 0.73rem; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.reading-time { + margin-top: 0.35rem; +} + +.summary { + margin-top: 0.85rem; + color: #3d3d39; +} + +.post-content { + margin-top: 0.8rem; +} + +.featured-image, +.index-image, +.tag-image, +.archive-image, +.author-image { + margin: 1.1rem 0; +} + +img { + display: block; + max-width: 100%; + height: auto; + border: 1px solid var(--border-color); + border-radius: 2px; +} + +.image-caption, +figcaption { + margin-top: 0.45rem; + color: var(--muted-text); + font-family: var(--font-ui); + font-size: 0.72rem; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +blockquote { + margin: 1.2rem 0; + border-left: 3px solid var(--quote-border); + background: #faf7f1; + padding: 0.75rem 0.95rem; +} + +pre, +code { + font-family: var(--font-mono); + font-size: 0.87rem; +} + +code { + background: var(--code-bg); + padding: 0.1rem 0.3rem; + border-radius: 2px; +} + +pre { + background: var(--code-bg); + border: 1px solid var(--border-color); + border-radius: var(--radius); + padding: 0.9rem; + overflow-x: auto; +} + +pre code { + padding: 0; + background: none; +} + +.tags { + display: flex; + flex-wrap: wrap; + gap: 0.45rem; + margin-top: 1.1rem; +} + +.tags a, +.tags-list a { + display: inline-block; + color: var(--tag-text); + background: var(--tag-bg); + border: 1px solid #d8cfbe; + border-radius: 999px; + padding: 0.2rem 0.56rem; + text-decoration: none; + font-family: var(--font-ui); + font-size: 0.73rem; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.tags a:hover, +.tags a:focus, +.tags-list a:hover, +.tags-list a:focus { + background: #e8dcc7; +} + +.tags-list { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.tag-count { + color: var(--muted-text); +} + +.posts-list article + article { + margin-top: 1rem; +} + +.posts-list h2 { + margin-top: 0; +} + +.pagination { + margin: 1.6rem 0 0.6rem; + display: flex; + flex-wrap: wrap; + gap: 0.6rem; + align-items: center; +} + +.pagination a { + display: inline-block; + border: 1px solid var(--border-color); + border-radius: 999px; + padding: 0.3rem 0.75rem; + text-decoration: none; + color: var(--heading-color); + font-family: var(--font-ui); + font-size: 0.74rem; + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.pagination a:hover, +.pagination a:focus { + background: #f1ece2; +} + +.page-info { + color: var(--muted-text); + font-family: var(--font-ui); + font-size: 0.74rem; + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.related-posts { + margin-top: 1.8rem; + padding-top: 1rem; + border-top: 1px solid var(--border-color); +} + +.related-posts h3 { + margin-top: 0; + font-size: 1.1rem; +} + +.related-posts-list { + display: grid; + gap: 0.7rem; +} + +.related-post { + border-left: 3px solid var(--accent-color); + background: var(--tag-bg); + padding: 0.65rem 0.75rem; +} + +.related-post h4 { + margin: 0 0 0.3rem; +} + +.related-post p { + margin: 0; + color: #4a4a44; + font-size: 0.95rem; +} + +footer { + margin-top: 2.2rem; + border-top: 1px solid var(--border-color); + padding-top: 1rem; + color: var(--muted-text); + font-family: var(--font-ui); + font-size: 0.72rem; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +footer p { + margin: 0.4rem 0; +} + +footer a { + color: var(--muted-text); +} + +footer a:hover, +footer a:focus { + color: var(--heading-color); +} + +table { + width: 100%; + border-collapse: collapse; + margin: 1rem 0; +} + +th, +td { + border: 1px solid var(--border-color); + padding: 0.45rem 0.6rem; +} + +th { + font-family: var(--font-ui); + font-size: 0.78rem; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +hr { + border: 0; + border-top: 1px solid var(--border-color); + margin: 1.4rem 0; +} + +@media (max-width: 760px) { + html { + font-size: 17px; + } + + .container { + padding: 1.4rem 0.9rem 2.3rem; + } + + article.post, + article.page, + .posts-list article { + padding: 1.1rem 0.95rem; + } + + nav { + gap: 0.45rem 0.7rem; + } + + nav a, + .meta, + .page-info, + .pagination a { + letter-spacing: 0.06em; + } +} diff --git a/themes/mynotes/style.css b/themes/mynotes/style.css new file mode 100644 index 0000000..38b37a1 --- /dev/null +++ b/themes/mynotes/style.css @@ -0,0 +1,573 @@ +/* + * MyNotes Theme for BSSG + * Quiet, warm, text-first journal aesthetic. + */ + +@import url("https://fonts.googleapis.com/css2?family=Literata:opsz,wght@7..72,400;7..72,500;7..72,600&family=Source+Sans+3:wght@400;500;600&display=swap"); + +:root { + --bg: #1c1917; + --bg-soft: #221e1b; + --surface: #26211e; + --text: #e8e0d4; + --text-soft: #d2c7b5; + --muted: #afa08b; + --accent: #c89452; + --accent-strong: #ddb175; + --accent-subtle: rgba(200, 148, 82, 0.32); + --border: #4a4037; + --quote-bg: #24201d; + --quote-border: #a7773d; + --code-bg: #171411; + --code-text: #f1e8db; + --code-border: #3d342c; + --shadow-soft: 0 3px 16px rgba(0, 0, 0, 0.24); + --content-width: 700px; + --font-body: "Literata", Georgia, "Times New Roman", serif; + --font-ui: "Source Sans 3", system-ui, -apple-system, "Segoe UI", sans-serif; + --font-mono: "SFMono-Regular", Menlo, Consolas, "Liberation Mono", monospace; +} + +@media (prefers-color-scheme: light) { + :root { + --bg: #f5f0e8; + --bg-soft: #f1e9de; + --surface: #fcf8f1; + --text: #2c2520; + --text-soft: #3b322c; + --muted: #726459; + --accent: #a96d2f; + --accent-strong: #8d5520; + --accent-subtle: rgba(169, 109, 47, 0.26); + --border: #d9cdbd; + --quote-bg: #f3ebdf; + --quote-border: #b67a3f; + --code-bg: #2a241f; + --code-text: #f5ecdd; + --code-border: #3a3129; + --shadow-soft: 0 2px 12px rgba(60, 42, 22, 0.12); + } +} + +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; + } +} + +*, +*::before, +*::after { + box-sizing: border-box; +} + +html { + font-size: 19px; + -webkit-text-size-adjust: 100%; +} + +body { + margin: 0; + color: var(--text); + background: + radial-gradient(circle at 15% 8%, rgba(201, 150, 91, 0.1), transparent 34%), + linear-gradient(180deg, var(--bg-soft) 0%, var(--bg) 100%); + font-family: var(--font-body); + line-height: 1.7; +} + +::selection { + background: var(--accent-subtle); + color: var(--text); +} + +.container { + max-width: calc(var(--content-width) + 2.8rem); + margin: 0 auto; + padding: 2.2rem 1.4rem 3.2rem; +} + +header { + border-bottom: 1px solid var(--border); + margin-bottom: 2.3rem; + padding-bottom: 1.15rem; +} + +.site-title, +header h1 { + margin: 0; + color: var(--text); + font-family: var(--font-body); + font-weight: 600; + font-size: clamp(2rem, 4vw, 2.75rem); + line-height: 1.2; + letter-spacing: 0.01em; +} + +.site-title a { + color: inherit; + text-decoration: none; +} + +.site-title a:hover, +.site-title a:focus { + color: var(--accent-strong); +} + +header p { + margin: 0.45rem 0 0; + color: var(--muted); + font-size: 0.95rem; + font-style: italic; + line-height: 1.5; +} + +nav { + margin-top: 1rem; + display: flex; + flex-wrap: wrap; + gap: 0.45rem 0.75rem; +} + +nav a { + display: inline-block; + padding: 0.28rem 0.1rem; + color: var(--text-soft); + text-decoration: none; + font-family: var(--font-ui); + font-size: 0.82rem; + text-transform: uppercase; + letter-spacing: 0.09em; + border-bottom: 1px solid transparent; +} + +nav a:hover, +nav a:focus { + color: var(--accent-strong); + border-bottom-color: var(--accent-subtle); +} + +main { + max-width: var(--content-width); + margin: 0 auto; + min-height: 68vh; +} + +article { + margin-bottom: 2.2rem; +} + +article.post, +article.page { + padding-bottom: 1rem; +} + +.posts-list article { + margin: 0; + padding: 0 0 1.55rem; + border-bottom: 1px solid var(--border); +} + +.posts-list article + article { + margin-top: 1.55rem; +} + +h1, +h2, +h3, +h4, +h5, +h6 { + color: var(--text); + font-family: var(--font-body); + line-height: 1.3; + margin: 1.45rem 0 0.8rem; + letter-spacing: 0.01em; + font-weight: 600; +} + +h1 { + font-size: clamp(1.95rem, 3.3vw, 2.45rem); +} + +h2 { + font-size: clamp(1.55rem, 2.7vw, 1.92rem); +} + +h3 { + font-size: clamp(1.25rem, 2vw, 1.48rem); +} + +article.post > h1, +article.page > h1, +.posts-list h2 { + margin-top: 0; +} + +p, +ul, +ol { + margin-top: 0; + margin-bottom: 1.25rem; +} + +li + li { + margin-top: 0.38rem; +} + +a { + color: var(--accent); + text-decoration-line: underline; + text-decoration-thickness: 1px; + text-underline-offset: 0.16em; + text-decoration-color: rgba(200, 148, 82, 0.55); + transition: color 0.14s ease, text-decoration-color 0.14s ease; +} + +a:hover, +a:focus { + color: var(--accent-strong); + text-decoration-color: rgba(200, 148, 82, 0.95); +} + +.page-meta { + margin-bottom: 1rem; +} + +.meta { + margin: 0; + color: var(--muted); + font-family: var(--font-ui); + font-size: 0.83rem; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.reading-time { + margin-top: 0.28rem; +} + +.summary { + color: var(--text-soft); + margin-top: 0.6rem; +} + +.posts-list .summary { + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.post-content { + margin-top: 0.75rem; +} + +.featured-image, +.index-image, +.tag-image, +.archive-image, +.author-image { + margin: 1rem 0 1.05rem; +} + +.index-image { + display: none; +} + +img { + display: block; + max-width: 100%; + height: auto; + border-radius: 3px; + box-shadow: var(--shadow-soft); +} + +.image-caption, +figcaption { + margin-top: 0.45rem; + color: var(--muted); + font-size: 0.85rem; + font-style: italic; + line-height: 1.5; +} + +blockquote { + margin: 1.5rem 0; + padding: 0.9rem 1rem; + background: var(--quote-bg); + border-left: 3px solid var(--quote-border); + color: var(--text-soft); + font-style: italic; +} + +pre, +code { + font-family: var(--font-mono); + font-size: 0.83rem; +} + +code { + color: var(--code-text); + background: var(--code-bg); + border: 1px solid var(--code-border); + border-radius: 4px; + padding: 0.08rem 0.3rem; +} + +pre { + margin: 1.2rem 0; + color: var(--code-text); + background: var(--code-bg); + border: 1px solid var(--code-border); + border-radius: 6px; + padding: 0.9rem 0.95rem; + overflow-x: auto; +} + +pre code { + border: 0; + background: transparent; + padding: 0; + color: inherit; +} + +hr { + border: 0; + margin: 2.2rem 0; + text-align: center; +} + +hr::before { + content: "\00b7\00b7\00b7"; + color: var(--muted); + letter-spacing: 0.6rem; + font-size: 1rem; +} + +.tags { + display: flex; + flex-wrap: wrap; + gap: 0.36rem; + margin-top: 1.2rem; +} + +.tags a, +.tags-list a { + display: inline-block; + color: var(--text-soft); + background: color-mix(in srgb, var(--accent) 10%, transparent); + border: 1px solid var(--border); + border-radius: 999px; + padding: 0.14rem 0.52rem; + text-decoration: none; + font-family: var(--font-ui); + font-size: 0.74rem; + letter-spacing: 0.04em; +} + +.tags a:hover, +.tags a:focus, +.tags-list a:hover, +.tags-list a:focus { + color: var(--accent-strong); + border-color: var(--accent-subtle); + background: color-mix(in srgb, var(--accent) 16%, transparent); +} + +.tags-list { + display: flex; + flex-wrap: wrap; + gap: 0.45rem; +} + +.tag-count { + color: var(--muted); +} + +.pagination { + margin-top: 1.8rem; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.45rem; + font-family: var(--font-ui); +} + +.pagination a { + display: inline-block; + color: var(--text-soft); + background: color-mix(in srgb, var(--surface) 70%, transparent); + border: 1px solid var(--border); + border-radius: 999px; + padding: 0.16rem 0.62rem; + text-decoration: none; + font-size: 0.78rem; +} + +.pagination a:hover, +.pagination a:focus { + color: var(--accent-strong); + border-color: var(--accent-subtle); +} + +.page-info { + color: var(--muted); + font-size: 0.78rem; + letter-spacing: 0.03em; +} + +.related-posts { + margin-top: 2rem; + padding-top: 1.1rem; + border-top: 1px dashed var(--border); +} + +.related-posts h3 { + margin-top: 0; + font-size: 1.1rem; +} + +.related-posts-list { + display: grid; + gap: 0.8rem; +} + +.related-post { + border-left: 2px solid var(--quote-border); + background: color-mix(in srgb, var(--quote-bg) 85%, transparent); + padding: 0.55rem 0.72rem; + border-radius: 4px; +} + +.related-post h4 { + margin: 0 0 0.25rem; + font-size: 0.98rem; +} + +.related-post p { + margin: 0; + color: var(--muted); + font-size: 0.88rem; + line-height: 1.55; +} + +table { + width: 100%; + border-collapse: collapse; + margin: 1.25rem 0; +} + +th, +td { + border: 1px solid var(--border); + padding: 0.46rem 0.56rem; +} + +th { + text-align: left; + font-family: var(--font-ui); + font-size: 0.79rem; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--muted); +} + +footer { + max-width: var(--content-width); + margin: 2.5rem auto 0; + padding-top: 0.95rem; + border-top: 1px solid var(--border); + color: var(--muted); + font-family: var(--font-ui); + font-size: 0.77rem; + line-height: 1.5; +} + +footer p { + margin: 0.34rem 0; +} + +footer p:last-child { + display: none; +} + +footer a { + color: var(--accent); +} + +footer a:hover, +footer a:focus { + color: var(--accent-strong); +} + +@media (max-width: 760px) { + html { + font-size: 17px; + } + + .container { + padding: 1.4rem 1rem 2.4rem; + } + + header { + margin-bottom: 1.7rem; + padding-bottom: 0.95rem; + } + + nav { + gap: 0.24rem 0.6rem; + } + + nav a { + padding: 0.35rem 0.04rem; + font-size: 0.8rem; + letter-spacing: 0.06em; + } + + article { + margin-bottom: 1.8rem; + } + + .posts-list article { + padding-bottom: 1.2rem; + } +} + +@media (max-width: 520px) { + html { + font-size: 16px; + } + + .container { + padding: 1.1rem 0.82rem 2rem; + } + + nav { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.3rem 0.55rem; + } + + nav a { + min-height: 40px; + display: flex; + align-items: center; + justify-content: flex-start; + } + + .site-title, + header h1 { + font-size: clamp(1.72rem, 8.4vw, 2.2rem); + } + + .meta, + .pagination a, + .page-info { + font-size: 0.76rem; + letter-spacing: 0.02em; + } +} diff --git a/themes/nes/style.css b/themes/nes/style.css index 0040caf..f279201 100644 --- a/themes/nes/style.css +++ b/themes/nes/style.css @@ -607,23 +607,23 @@ blockquote p:last-child { transform: scale(1.01); } -.posts-list h3 { +.posts-list h2 { margin-top: 0; font-size: 1.1rem; margin-bottom: var(--spacing-sm); } -.posts-list h3 a { +.posts-list h2 a { color: var(--nes-light-blue); text-decoration: none; } -.posts-list h3 a:hover { +.posts-list h2 a:hover { color: var(--nes-light-yellow); } /* ACCESSIBILITY: Focus states for post list links */ -.posts-list h3 a:focus { +.posts-list h2 a:focus { outline: 2px solid var(--nes-light-yellow); outline-offset: 2px; color: var(--nes-light-yellow); diff --git a/themes/netbsd/style.css b/themes/netbsd/style.css new file mode 100644 index 0000000..b58c1e1 --- /dev/null +++ b/themes/netbsd/style.css @@ -0,0 +1,530 @@ +/* + * NetBSD Theme for BSSG + * NetBSD-recognizable orange engineering look with flag-inspired geometry. + */ + +:root { + --netbsd-navy: #003c78; + --netbsd-deep: #002a54; + --netbsd-sky: #2d5d90; + --netbsd-orange: #f4821f; + --paper: #fbfdff; + --paper-2: #f1f5f9; + --text: #163046; + --muted: #506578; + --border: #cfdae4; + --link: #004a90; + --link-hover: #00366a; + --tag-bg: #edf4fb; + --tag-text: #184b78; + --quote-bg: #f2f7fc; + --code-bg: #edf3f8; + --radius: 8px; + --shadow: 0 12px 28px rgba(0, 28, 52, 0.2); + --content-width: 920px; + --font-body: "Source Sans 3", "Segoe UI", "Helvetica Neue", Arial, sans-serif; + --font-heading: "Avenir Next", "Segoe UI", "Trebuchet MS", sans-serif; + --font-mono: "SFMono-Regular", Menlo, Consolas, "Liberation Mono", monospace; +} + +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; + } +} + +*, +*::before, +*::after { + box-sizing: border-box; +} + +html { + font-size: 17px; +} + +body { + margin: 0; + color: var(--text); + font-family: var(--font-body); + line-height: 1.72; + background: + radial-gradient(circle at 0 0, rgba(244, 130, 31, 0.1) 0 18%, transparent 19%), + linear-gradient(180deg, #f3f8fc 0%, #e7eef5 100%); +} + +::selection { + background: #cfe2f3; + color: #0f2a3e; +} + +.container { + max-width: var(--content-width); + margin: 0 auto; + padding: 0 1.2rem 2.7rem; + background: linear-gradient(180deg, #ffffff 0%, var(--paper) 100%); + border-left: 1px solid #c6d2dd; + border-right: 1px solid #c6d2dd; + box-shadow: var(--shadow); + min-height: 100vh; +} + +header { + margin: 0 -1.2rem 1.95rem; + padding: 1rem 1.2rem 0.95rem; + color: #f2f7fb; + background: + linear-gradient(135deg, rgba(244, 130, 31, 0.2) 0 14%, transparent 15%), + linear-gradient(180deg, var(--netbsd-navy) 0%, var(--netbsd-deep) 100%); + border-top: 6px solid var(--netbsd-orange); + border-bottom: 1px solid #0e2942; + position: relative; + overflow: hidden; +} + +header::before { + content: "Of course it runs NetBSD!"; + position: absolute; + right: 18px; + top: 12px; + background: linear-gradient(180deg, #0f447a 0%, #06315a 100%); + color: #ffe1be; + border: 1px solid rgba(255, 255, 255, 0.4); + border-bottom-color: var(--netbsd-orange); + border-radius: 4px; + padding: 0.16rem 0.55rem; + font-family: var(--font-mono); + font-size: 0.62rem; + font-weight: 700; + letter-spacing: 0.02em; + text-transform: none; + box-shadow: 0 3px 7px rgba(0, 0, 0, 0.25); +} + +header::after { + content: ""; + position: absolute; + left: 0; + right: 0; + bottom: 0; + height: 3px; + background: linear-gradient(90deg, transparent, var(--netbsd-orange), transparent); +} + +.site-title, +header h1 { + margin: 0; + color: #f4f8fc; + font-family: var(--font-heading); + font-size: clamp(1.9rem, 3.6vw, 2.65rem); + font-weight: 700; + letter-spacing: 0.03em; + text-transform: uppercase; + line-height: 1.2; +} + +.site-title a { + color: inherit; + text-decoration: none; +} + +.site-title a:hover, +.site-title a:focus { + color: #ffd7b3; +} + +header p { + margin: 0.42rem 0 0; + color: #c7d9e8; + font-family: var(--font-mono); + font-size: 0.74rem; + text-transform: uppercase; + letter-spacing: 0.08em; +} + +nav { + margin-top: 0.92rem; + display: flex; + flex-wrap: wrap; + gap: 0.45rem; +} + +nav a { + display: inline-block; + color: #e9f2f9; + background: rgba(0, 0, 0, 0.2); + border: 1px solid rgba(255, 255, 255, 0.22); + border-bottom: 2px solid rgba(244, 130, 31, 0.55); + border-radius: 5px; + padding: 0.24rem 0.6rem; + text-decoration: none; + font-size: 0.72rem; + font-weight: 600; + letter-spacing: 0.07em; + text-transform: uppercase; +} + +nav a:hover, +nav a:focus { + background: rgba(244, 130, 31, 0.18); + border-color: rgba(255, 255, 255, 0.45); + color: #fff; +} + +main { + min-height: 62vh; +} + +article { + margin-bottom: 1.4rem; +} + +article.post, +article.page, +.posts-list article { + background: #fff; + border: 1px solid var(--border); + border-top: 4px solid var(--netbsd-navy); + border-radius: var(--radius); + padding: 1.14rem 1rem 0.95rem; +} + +article.post::before, +article.page::before, +.posts-list article::before { + content: ""; + display: block; + height: 2px; + margin: -0.25rem -1rem 0.85rem; + background: linear-gradient(90deg, transparent, rgba(244, 130, 31, 0.62), transparent); +} + +h1, +h2, +h3, +h4, +h5, +h6 { + color: #102f48; + font-family: var(--font-heading); + margin: 1.08rem 0 0.68rem; + line-height: 1.3; + letter-spacing: 0.01em; +} + +h1 { font-size: clamp(1.7rem, 3.1vw, 2.22rem); } +h2 { font-size: clamp(1.38rem, 2.5vw, 1.78rem); } +h3 { font-size: clamp(1.14rem, 1.9vw, 1.38rem); } + +p, +ul, +ol { + margin-top: 0; + margin-bottom: 0.92rem; +} + +a { + color: var(--link); + text-decoration-thickness: 1px; + text-underline-offset: 0.14em; +} + +a:hover, +a:focus { + color: var(--link-hover); +} + +.page-meta { + margin-bottom: 0.74rem; +} + +.meta { + margin: 0; + color: var(--muted); + font-family: var(--font-mono); + font-size: 0.73rem; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.reading-time { + margin-top: 0.24rem; +} + +.summary { + margin-top: 0.72rem; + color: #39576f; +} + +.post-content { + margin-top: 0.64rem; +} + +.featured-image, +.index-image, +.tag-image, +.archive-image, +.author-image { + margin: 0.94rem 0; +} + +img { + max-width: 100%; + height: auto; + display: block; + border: 1px solid #b9c8d6; + border-radius: 4px; +} + +.image-caption, +figcaption { + margin-top: 0.34rem; + color: #61778a; + font-size: 0.78rem; + font-style: italic; +} + +blockquote { + margin: 0.98rem 0; + padding: 0.68rem 0.85rem; + border-left: 4px solid var(--netbsd-sky); + background: var(--quote-bg); + color: #2d4a61; +} + +pre, +code { + font-family: var(--font-mono); + font-size: 0.84rem; +} + +code { + background: var(--code-bg); + border: 1px solid #d3dfeb; + border-radius: 3px; + padding: 0.08rem 0.26rem; +} + +pre { + background: var(--code-bg); + border: 1px solid #d0dde8; + border-radius: 6px; + padding: 0.78rem; + overflow-x: auto; +} + +pre code { + background: none; + border: 0; + padding: 0; +} + +hr { + border: 0; + border-top: 1px solid #d2dee8; + margin: 1.1rem 0; +} + +.tags { + display: flex; + flex-wrap: wrap; + gap: 0.34rem; + margin-top: 0.84rem; +} + +.tags a, +.tags-list a { + display: inline-block; + text-decoration: none; + color: var(--tag-text); + background: var(--tag-bg); + border: 1px solid #cadcec; + border-radius: 999px; + padding: 0.17rem 0.52rem; + font-family: var(--font-mono); + font-size: 0.71rem; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.tags a:hover, +.tags a:focus, +.tags-list a:hover, +.tags-list a:focus { + background: #deebf8; + border-color: #b8d0e7; +} + +.tags-list { + display: flex; + flex-wrap: wrap; + gap: 0.42rem; +} + +.tag-count { + color: #61798f; +} + +.posts-list article + article { + margin-top: 0.82rem; +} + +.posts-list h2 { + margin-top: 0; +} + +.pagination { + margin-top: 1.2rem; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.44rem; +} + +.pagination a { + display: inline-block; + color: #17364f; + text-decoration: none; + border: 1px solid #c0d2e1; + border-radius: 5px; + padding: 0.22rem 0.6rem; + background: #eff5fb; + font-family: var(--font-mono); + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.03em; +} + +.pagination a:hover, +.pagination a:focus { + background: var(--netbsd-navy); + color: #fff; + border-color: #1f4e7e; +} + +.page-info { + color: #5f7488; + font-family: var(--font-mono); + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.03em; +} + +.related-posts { + margin-top: 1.3rem; + padding-top: 0.8rem; + border-top: 1px dashed #c2d2df; +} + +.related-posts h3 { + margin-top: 0; + font-size: 1.05rem; +} + +.related-posts-list { + display: grid; + gap: 0.6rem; +} + +.related-post { + border: 1px solid #c5d5e4; + border-left: 4px solid var(--netbsd-orange); + background: #f6fafe; + border-radius: 5px; + padding: 0.52rem 0.64rem; +} + +.related-post h4 { + margin: 0 0 0.2rem; + font-size: 0.94rem; +} + +.related-post p { + margin: 0; + color: #4c6980; + font-size: 0.9rem; +} + +table { + width: 100%; + border-collapse: collapse; + margin: 0.9rem 0; +} + +th, +td { + border: 1px solid #d0dde8; + padding: 0.38rem 0.5rem; +} + +th { + background: #edf4fb; + font-family: var(--font-mono); + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.03em; +} + +footer { + margin-top: 1.78rem; + padding: 0.84rem 0 0.08rem; + border-top: 2px solid #d1dbe5; + color: #577084; + font-family: var(--font-mono); + font-size: 0.71rem; + text-transform: uppercase; + letter-spacing: 0.05em; + position: relative; +} + +footer p { + margin: 0.32rem 0; +} + +footer a { + color: var(--link); +} + +footer a:hover, +footer a:focus { + color: var(--link-hover); +} + +@media (max-width: 840px) { + .container { + padding: 0 0.82rem 2rem; + } + + header { + margin: 0 -0.82rem 1.45rem; + padding: 0.88rem 0.82rem 0.8rem; + } + + header::before { + right: 10px; + top: 10px; + padding: 0.12rem 0.42rem; + font-size: 0.55rem; + letter-spacing: 0.01em; + } + + nav { + padding-right: 0; + } + + article.post, + article.page, + .posts-list article { + padding: 0.96rem 0.8rem 0.8rem; + } + + nav a, + .meta, + .page-info, + .pagination a { + letter-spacing: 0.02em; + } +} diff --git a/themes/newspaper/style.css b/themes/newspaper/style.css index aa2fec4..fc64a97 100644 --- a/themes/newspaper/style.css +++ b/themes/newspaper/style.css @@ -529,7 +529,7 @@ footer::after { margin-bottom: var(--spacing-lg); } -.posts-list h3 { +.posts-list h2 { margin-top: var(--spacing-xs); font-size: 1.6rem; text-transform: uppercase; @@ -538,18 +538,18 @@ footer::after { text-align: center; } -.posts-list h3 a { +.posts-list h2 a { color: var(--header-color); text-decoration: none; transition: color 0.2s; } -.posts-list h3 a:hover { +.posts-list h2 a:hover { color: var(--link-color); } /* ACCESSIBILITY: Focus states for post list links */ -.posts-list h3 a:focus { +.posts-list h2 a:focus { outline: 2px solid var(--link-color); outline-offset: 2px; color: var(--link-color); diff --git a/themes/nextstep/style.css b/themes/nextstep/style.css index 830bc67..46411ef 100644 --- a/themes/nextstep/style.css +++ b/themes/nextstep/style.css @@ -275,6 +275,7 @@ h2 { font-size: 1.6rem; } +.posts-list h2, h3 { font-size: 1.4rem; } diff --git a/themes/nordic-clean/style.css b/themes/nordic-clean/style.css index c0214ed..b8bae61 100644 --- a/themes/nordic-clean/style.css +++ b/themes/nordic-clean/style.css @@ -544,7 +544,7 @@ footer a:focus { padding-bottom: 0; } -.posts-list h3 { +.posts-list h2 { margin-top: 0; font-size: 1.8rem; margin-bottom: var(--spacing-sm); diff --git a/themes/openbsd/style.css b/themes/openbsd/style.css new file mode 100644 index 0000000..9d3f3cf --- /dev/null +++ b/themes/openbsd/style.css @@ -0,0 +1,530 @@ +/* + * OpenBSD Theme for BSSG + * Puffy-inspired yellow/black visual language with bold outlines. + */ + +:root { + --obsd-yellow: #d7c06f; + --obsd-yellow-deep: #b79a4a; + --obsd-black: #121212; + --obsd-ink: #212121; + --obsd-paper: #f8f4e7; + --obsd-paper-2: #efe6c9; + --text: #1f2326; + --muted: #5f6468; + --border: #2a2a2a; + --link: #0d4f9e; + --link-hover: #08396f; + --tag-bg: #e9ddb5; + --tag-text: #2b2b2b; + --quote-bg: #f1e8cb; + --code-bg: #ece1bd; + --radius: 9px; + --shadow: 0 14px 30px rgba(0, 0, 0, 0.22); + --content-width: 900px; + --font-body: "Verdana", "Segoe UI", "Helvetica Neue", Arial, sans-serif; + --font-heading: "Trebuchet MS", "Verdana", "Segoe UI", sans-serif; + --font-mono: "SFMono-Regular", Menlo, Consolas, "Liberation Mono", monospace; +} + +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; + } +} + +*, +*::before, +*::after { + box-sizing: border-box; +} + +html { + font-size: 17px; +} + +body { + margin: 0; + color: var(--text); + font-family: var(--font-body); + line-height: 1.7; + background: + radial-gradient(circle at 2px 2px, rgba(18, 18, 18, 0.12) 1px, transparent 1px), + linear-gradient(180deg, #eee5c8 0%, var(--obsd-yellow) 52%, #c5af66 100%); + background-size: 12px 12px, 100% 100%; +} + +::selection { + background: #101010; + color: #f5edc9; +} + +.container { + max-width: var(--content-width); + margin: 0 auto; + padding: 0 1rem 2.5rem; + background: linear-gradient(180deg, #fbf8ef 0%, var(--obsd-paper) 100%); + border-left: 3px solid var(--obsd-black); + border-right: 3px solid var(--obsd-black); + box-shadow: var(--shadow); + min-height: 100vh; +} + +header { + margin: 0 -1rem 1.8rem; + padding: 1rem 1rem 0.92rem; + background: + repeating-linear-gradient( + -45deg, + #2c2c2c 0 8px, + #202020 8px 16px + ); + color: #fff5ce; + border-top: 8px solid #000; + border-bottom: 4px solid #000; + position: relative; + overflow: hidden; +} + +header::before { + content: "OpenBSD: Secure By Default"; + position: absolute; + right: 12px; + top: 12px; + background: linear-gradient(180deg, #e9d896 0%, #c8ad5f 100%); + color: #141414; + border: 2px solid #000; + border-radius: 6px; + padding: 0.16rem 0.54rem; + font-family: var(--font-mono); + font-size: 0.58rem; + font-weight: 700; + text-transform: none; + letter-spacing: 0.01em; + box-shadow: 0 3px 8px rgba(0, 0, 0, 0.28); +} + +header::after { + content: ""; + position: absolute; + inset: auto 0 0; + height: 3px; + background: linear-gradient(90deg, transparent, #e7d189, transparent); +} + +.site-title, +header h1 { + margin: 0; + color: #f2e4b8; + font-family: var(--font-heading); + font-size: clamp(1.9rem, 3.7vw, 2.6rem); + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.03em; + line-height: 1.2; + text-shadow: + -1px -1px 0 #000, + 1px -1px 0 #000, + -1px 1px 0 #000, + 1px 1px 0 #000; +} + +.site-title a { + color: inherit; + text-decoration: none; +} + +.site-title a:hover, +.site-title a:focus { + color: #fff; +} + +header p { + margin: 0.4rem 0 0; + color: #e4d093; + font-family: var(--font-mono); + font-size: 0.73rem; + text-transform: uppercase; + letter-spacing: 0.08em; +} + +nav { + margin-top: 0.88rem; + display: flex; + flex-wrap: wrap; + gap: 0.44rem; +} + +nav a { + display: inline-block; + color: #1a1a1a; + background: linear-gradient(180deg, #e8d790 0%, #bfa357 100%); + border: 2px solid #000; + border-radius: 999px; + padding: 0.2rem 0.58rem; + text-decoration: none; + font-family: var(--font-mono); + font-size: 0.7rem; + text-transform: uppercase; + letter-spacing: 0.06em; + font-weight: 700; +} + +nav a:hover, +nav a:focus { + background: linear-gradient(180deg, #f2e5ba 0%, #cbb06a 100%); + transform: translateY(-1px); +} + +main { + min-height: 62vh; +} + +article { + margin-bottom: 1.35rem; +} + +article.post, +article.page, +.posts-list article { + background: var(--obsd-paper); + border: 2px solid var(--obsd-black); + border-radius: var(--radius); + box-shadow: 4px 4px 0 rgba(0, 0, 0, 0.22); + padding: 1.06rem 0.95rem 0.9rem; + position: relative; +} + +article.post::before, +article.page::before, +.posts-list article::before { + content: none; +} + +h1, +h2, +h3, +h4, +h5, +h6 { + color: #111; + font-family: var(--font-heading); + margin: 1rem 0 0.64rem; + line-height: 1.3; +} + +h1 { font-size: clamp(1.7rem, 3vw, 2.22rem); } +h2 { font-size: clamp(1.36rem, 2.4vw, 1.76rem); } +h3 { font-size: clamp(1.12rem, 1.9vw, 1.34rem); } + +p, +ul, +ol { + margin-top: 0; + margin-bottom: 0.9rem; +} + +a { + color: var(--link); + text-decoration-thickness: 1px; + text-underline-offset: 0.13em; +} + +a:hover, +a:focus { + color: var(--link-hover); +} + +.page-meta { + margin-bottom: 0.72rem; +} + +.meta { + margin: 0; + color: var(--muted); + font-family: var(--font-mono); + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.reading-time { + margin-top: 0.22rem; +} + +.summary { + margin-top: 0.68rem; + color: #434a50; +} + +.post-content { + margin-top: 0.62rem; +} + +.featured-image, +.index-image, +.tag-image, +.archive-image, +.author-image { + margin: 0.9rem 0; +} + +img { + max-width: 100%; + height: auto; + display: block; + border: 2px solid #111; + border-radius: 6px; +} + +.image-caption, +figcaption { + margin-top: 0.32rem; + color: #5f676e; + font-size: 0.77rem; + font-style: italic; +} + +blockquote { + margin: 0.95rem 0; + padding: 0.65rem 0.8rem; + border-left: 5px solid #111; + background: var(--quote-bg); + color: #2e3438; +} + +pre, +code { + font-family: var(--font-mono); + font-size: 0.83rem; +} + +code { + background: var(--code-bg); + border: 1px solid #b89d55; + border-radius: 3px; + padding: 0.08rem 0.24rem; +} + +pre { + background: var(--code-bg); + border: 2px solid #111; + border-radius: 6px; + padding: 0.75rem; + overflow-x: auto; +} + +pre code { + background: none; + border: 0; + padding: 0; +} + +hr { + border: 0; + border-top: 2px dashed #3b3b3b; + margin: 1.08rem 0; +} + +.tags { + display: flex; + flex-wrap: wrap; + gap: 0.34rem; + margin-top: 0.82rem; +} + +.tags a, +.tags-list a { + display: inline-block; + text-decoration: none; + color: var(--tag-text); + background: var(--tag-bg); + border: 2px solid #111; + border-radius: 999px; + padding: 0.16rem 0.5rem; + font-family: var(--font-mono); + font-size: 0.7rem; + text-transform: uppercase; + letter-spacing: 0.04em; + font-weight: 700; +} + +.tags a:hover, +.tags a:focus, +.tags-list a:hover, +.tags-list a:focus { + background: #e4d091; +} + +.tags-list { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; +} + +.tag-count { + color: #3f3f3f; +} + +.posts-list article + article { + margin-top: 0.78rem; +} + +.posts-list h2 { + margin-top: 0; +} + +.pagination { + margin-top: 1.12rem; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.42rem; +} + +.pagination a { + display: inline-block; + color: #131313; + text-decoration: none; + border: 2px solid #000; + border-radius: 999px; + padding: 0.2rem 0.56rem; + background: linear-gradient(180deg, #e8d994 0%, #c2a85e 100%); + font-family: var(--font-mono); + font-size: 0.7rem; + text-transform: uppercase; + letter-spacing: 0.03em; + font-weight: 700; +} + +.pagination a:hover, +.pagination a:focus { + background: linear-gradient(180deg, #f1e4bc 0%, #cfb471 100%); +} + +.page-info { + color: #52595f; + font-family: var(--font-mono); + font-size: 0.71rem; + text-transform: uppercase; + letter-spacing: 0.03em; +} + +.related-posts { + margin-top: 1.2rem; + padding-top: 0.74rem; + border-top: 2px dashed #222; +} + +.related-posts h3 { + margin-top: 0; + font-size: 1.02rem; +} + +.related-posts-list { + display: grid; + gap: 0.56rem; +} + +.related-post { + border: 2px solid #111; + border-left-width: 6px; + border-radius: 6px; + background: #f3ead1; + padding: 0.5rem 0.62rem; +} + +.related-post h4 { + margin: 0 0 0.18rem; + font-size: 0.93rem; +} + +.related-post p { + margin: 0; + color: #474e53; + font-size: 0.89rem; +} + +table { + width: 100%; + border-collapse: collapse; + margin: 0.88rem 0; +} + +th, +td { + border: 2px solid #111; + padding: 0.36rem 0.48rem; +} + +th { + background: #ddca88; + font-family: var(--font-mono); + font-size: 0.7rem; + text-transform: uppercase; + letter-spacing: 0.03em; +} + +footer { + margin-top: 1.72rem; + padding: 0.82rem 0 0.1rem; + border-top: 3px solid #111; + color: #4f565c; + font-family: var(--font-mono); + font-size: 0.7rem; + text-transform: uppercase; + letter-spacing: 0.05em; + position: relative; +} + +footer p { + margin: 0.31rem 0; +} + +footer a { + color: var(--link); +} + +footer a:hover, +footer a:focus { + color: var(--link-hover); +} + +@media (max-width: 840px) { + .container { + padding: 0 0.78rem 1.95rem; + } + + header { + margin: 0 -0.78rem 1.42rem; + padding: 0.85rem 0.78rem 0.74rem; + } + + header::before { + right: 10px; + top: 10px; + padding: 0.12rem 0.42rem; + font-size: 0.5rem; + letter-spacing: 0; + } + + nav { + padding-right: 0; + } + + article.post, + article.page, + .posts-list article { + padding: 0.92rem 0.78rem 0.76rem; + } + + nav a, + .meta, + .page-info, + .pagination a { + letter-spacing: 0.02em; + } +} diff --git a/themes/osx/style.css b/themes/osx/style.css index f04d772..fd1f2a5 100644 --- a/themes/osx/style.css +++ b/themes/osx/style.css @@ -502,16 +502,16 @@ footer { border-color: var(--border-color); } -.posts-list h3 { +.posts-list h2 { margin-top: 0; margin-bottom: var(--spacing-xs); } -.posts-list h3 a { +.posts-list h2 a { color: var(--header-color); } -.posts-list h3 a:hover { +.posts-list h2 a:hover { color: var(--accent-color); text-decoration: none; } diff --git a/themes/reader-mode/style.css b/themes/reader-mode/style.css index 4415143..4d14d96 100644 --- a/themes/reader-mode/style.css +++ b/themes/reader-mode/style.css @@ -525,13 +525,13 @@ figcaption { border-bottom: none; } -.posts-list h3 { +.posts-list h2 { margin-top: 0; text-align: center; margin-bottom: var(--spacing-sm); } -.posts-list h3 a { +.posts-list h2 a { color: var(--heading-color); text-decoration: none; transition: color var(--transition-base) ease; @@ -539,13 +539,13 @@ figcaption { outline: none; } -.posts-list h3 a:hover { +.posts-list h2 a:hover { color: var(--link-color); text-decoration: underline; } /* ACCESSIBILITY: Focus states for post list links */ -.posts-list h3 a:focus { +.posts-list h2 a:focus { outline: 2px solid var(--link-color); outline-offset: 2px; } diff --git a/themes/skeuomorphic/style.css b/themes/skeuomorphic/style.css index afd0efc..5192c52 100644 --- a/themes/skeuomorphic/style.css +++ b/themes/skeuomorphic/style.css @@ -408,6 +408,7 @@ h2 { text-shadow: 0 1px 0 rgba(255, 255, 255, 0.8); } +.posts-list h2, h3 { font-size: 1.4rem; color: var(--leather-medium); diff --git a/themes/stefano/style.css b/themes/stefano/style.css index a7b236f..2f0e252 100644 --- a/themes/stefano/style.css +++ b/themes/stefano/style.css @@ -522,21 +522,21 @@ footer { box-shadow: 0 15px 35px rgba(0, 0, 0, 0.1); } -.posts-list h3 { +.posts-list h2 { margin-top: 0; margin-bottom: var(--spacing-xs); } -.posts-list h3 a { +.posts-list h2 a { color: var(--header-color); } -.posts-list h3 a:hover { +.posts-list h2 a:hover { color: var(--accent); } /* ACCESSIBILITY: Focus states for post list links */ -.posts-list h3 a:focus { +.posts-list h2 a:focus { outline: 2px solid var(--link-color); outline-offset: 2px; color: var(--accent); diff --git a/themes/swiss-design/style.css b/themes/swiss-design/style.css index 2e198fc..77ece6b 100644 --- a/themes/swiss-design/style.css +++ b/themes/swiss-design/style.css @@ -488,7 +488,7 @@ footer a:focus { padding-bottom: 0; } -.posts-list h3 { +.posts-list h2 { margin-top: 0; font-size: var(--text-2xl); margin-bottom: var(--spacing-sm); diff --git a/themes/terminal/style.css b/themes/terminal/style.css index 3bd2787..0f74845 100644 --- a/themes/terminal/style.css +++ b/themes/terminal/style.css @@ -698,13 +698,13 @@ footer::before { color: var(--command-prompt); } -.posts-list h3 { +.posts-list h2 { margin-top: var(--spacing-xs); margin-bottom: var(--spacing-xs); text-transform: none; } -.posts-list h3::before { +.posts-list h2::before { content: ""; } diff --git a/themes/text-only/style.css b/themes/text-only/style.css index fbf8c3e..8027936 100644 --- a/themes/text-only/style.css +++ b/themes/text-only/style.css @@ -244,7 +244,7 @@ hr { border-bottom: 1px solid var(--color-border); } -.posts-list h3 { +.posts-list h2 { margin: 0; } diff --git a/themes/thoughtful/style.css b/themes/thoughtful/style.css index 8a61de6..7ed0eeb 100644 --- a/themes/thoughtful/style.css +++ b/themes/thoughtful/style.css @@ -719,25 +719,25 @@ figcaption { border-bottom: 1px solid var(--border-light); } -.posts-list h3 { +.posts-list h2 { margin: 0 0 var(--space-6); font-size: var(--text-2xl); text-align: center; line-height: var(--line-height-tight); } -.posts-list h3 a { +.posts-list h2 a { color: var(--text-primary); text-decoration: none; transition: color var(--transition-normal); display: block; } -.posts-list h3 a:hover { +.posts-list h2 a:hover { color: var(--accent-primary); } -.posts-list h3 a:focus { +.posts-list h2 a:focus { outline: 2px solid var(--link-color); outline-offset: 2px; } diff --git a/themes/tty/style.css b/themes/tty/style.css index a9df816..daf8f65 100644 --- a/themes/tty/style.css +++ b/themes/tty/style.css @@ -642,16 +642,14 @@ figcaption { margin-bottom: var(--spacing-xs); } -.posts-list h2, -.posts-list h3 { +.posts-list h2 { margin-top: 0; font-size: var(--text-md); padding-left: 1.5em; /* Increased to prevent overlap */ position: relative; } -.posts-list h2::before, -.posts-list h3::before { +.posts-list h2::before { position: absolute; left: 0; top: 0; diff --git a/themes/vaporwave/style.css b/themes/vaporwave/style.css index 056c457..ae7cb89 100644 --- a/themes/vaporwave/style.css +++ b/themes/vaporwave/style.css @@ -364,6 +364,7 @@ h2 { text-shadow: 0 0 5px var(--neon-blue); } +.posts-list h2, h3 { font-size: var(--text-xl); color: var(--neon-pink); @@ -704,6 +705,7 @@ footer a:hover { font-size: var(--text-xl); } + .posts-list h2, h3 { font-size: var(--text-lg); } @@ -743,6 +745,7 @@ footer a:hover { font-size: var(--text-lg); } + .posts-list h2, h3 { font-size: var(--text-md); } diff --git a/themes/web1/style.css b/themes/web1/style.css index 8d489d6..00dfc1f 100644 --- a/themes/web1/style.css +++ b/themes/web1/style.css @@ -352,6 +352,7 @@ h2 { text-shadow: 1px 1px 1px #ff00ff; } +.posts-list h2, h3 { font-size: var(--text-xl); color: #00cc00; diff --git a/themes/web2/style.css b/themes/web2/style.css index 0e8780d..9474aaa 100644 --- a/themes/web2/style.css +++ b/themes/web2/style.css @@ -283,6 +283,7 @@ h2 { color: #444; } +.posts-list h2, h3 { font-size: var(--text-xl); color: #555; diff --git a/themes/win311/style.css b/themes/win311/style.css index f446f49..9ccfe29 100644 --- a/themes/win311/style.css +++ b/themes/win311/style.css @@ -513,7 +513,7 @@ footer { margin-bottom: var(--spacing-md); } -.posts-list h3 { +.posts-list h2 { margin-top: 0; margin-bottom: var(--spacing-xs); color: var(--highlight-color); diff --git a/themes/win7/style.css b/themes/win7/style.css index a18186d..7b3f5ec 100644 --- a/themes/win7/style.css +++ b/themes/win7/style.css @@ -355,6 +355,7 @@ h2 { color: #1a73e8; } +.posts-list h2, h3 { font-size: var(--text-xl); } diff --git a/themes/win95/style.css b/themes/win95/style.css index 7e6cca6..01a705c 100644 --- a/themes/win95/style.css +++ b/themes/win95/style.css @@ -399,7 +399,7 @@ a:hover { border-bottom: 1px solid var(--border-color); } -.posts-list h3 { +.posts-list h2 { margin-top: var(--spacing-lg); margin-bottom: var(--spacing-sm); } diff --git a/themes/winxp/style.css b/themes/winxp/style.css index 6c182cf..d642ef1 100644 --- a/themes/winxp/style.css +++ b/themes/winxp/style.css @@ -280,6 +280,7 @@ h1, h2, h3, h4, h5, h6 { h1 { font-size: var(--text-2xl); } h2 { font-size: var(--text-xl); } +.posts-list h2, h3 { font-size: var(--text-lg); } h4 { font-size: var(--text-md); } @@ -731,6 +732,7 @@ footer a:focus { h1 { font-size: var(--text-3xl); } h2 { font-size: var(--text-2xl); } + .posts-list h2, h3 { font-size: var(--text-xl); } h4 { font-size: var(--text-lg); } diff --git a/themes/y2k/style.css b/themes/y2k/style.css index 6de4790..2a0d9c4 100644 --- a/themes/y2k/style.css +++ b/themes/y2k/style.css @@ -328,6 +328,7 @@ h2 { } } +.posts-list h2, h3 { font-size: var(--text-lg); color: var(--accent-blue); @@ -778,6 +779,7 @@ hr { font-size: var(--text-lg); } + .posts-list h2, h3 { font-size: var(--text-md); } diff --git a/themes/zxspectrum/style.css b/themes/zxspectrum/style.css index 2c87add..5168883 100644 --- a/themes/zxspectrum/style.css +++ b/themes/zxspectrum/style.css @@ -268,11 +268,16 @@ h2::before { color: var(--bright-yellow); } +.posts-list h2, h3 { font-size: var(--font-size-md); color: var(--bright-green); } +.posts-list h2::before { + content: none; +} + p { margin-bottom: var(--spacing-md); line-height: var(--line-height-normal); @@ -670,4 +675,4 @@ ul, ol { li { margin-bottom: var(--spacing-xs); -} \ No newline at end of file +}