Compare commits

...

96 commits
0.9.1 ... main

Author SHA1 Message Date
f5755e9fd0 Added VERSION file - updating version number will be easier 2026-06-11 20:56:51 +02:00
0662983c11 New config options in config.sh (all default true for backward compat):
SHOW_HEADER_MENU=true        # Set false to remove nav menu from header
SHOW_INDEX_DESCRIPTIONS=true # Set false to hide descriptions/excerpts on index
GENERATE_EXCERPT=true        # Set false to skip auto-generating excerpts
SHOW_READING_TIME=true       # Set false to hide reading time on posts

Updated README.md

Should close issue 53: #53
2026-06-11 14:08:46 +02:00
48bc724d94 New config options in config.sh (all default true for backward compat):
SHOW_HEADER_MENU=true        # Set false to remove nav menu from header
SHOW_INDEX_DESCRIPTIONS=true # Set false to hide descriptions/excerpts on index
GENERATE_EXCERPT=true        # Set false to skip auto-generating excerpts
SHOW_READING_TIME=true       # Set false to hide reading time on posts

Should close issue 53: #53
2026-06-11 14:06:02 +02:00
8250468f56 Several format changes, the output will be cleaner and more correct 2026-06-11 13:28:54 +02:00
608a82aec4 - Added support for --config in generate_theme_previews.sh, with config resolution aligned to the main build flow: command-line override first, then BSSG_LCONF, then local/default config files.
- Improved theme preview generation so it now loads the effective BSSG config once, reuses configured SITE_URL, OUTPUT_DIR, THEMES_DIR, and TEMPLATES_DIR, and passes the selected config through to each preview build.
- Improved preview output path detection for external site setups, so previews are generated under the correct site example directory instead of assuming the project root layout.
- Standardized list-page article headings from <h3> to <h2> across homepage, archives, authors, tags, and secondary pages, improving heading hierarchy and semantic consistency.
- Fixed Open Graph URL generation in templates/header.html by concatenating {{site_url}}{{page_url}} directly, and normalized page URL placeholders to leading-slash paths like /, /archives/, /authors/..., and /pages.html.
- Updated theme stylesheets to match the new list markup, switching post-list selectors from .posts-list h3 to .posts-list h2 and preserving hover, focus, spacing, and responsive typography rules.
- Adjusted themes with decorative h2 treatments so post listings do not inherit unwanted heading ornaments or pseudo-elements after the heading-level change.
2026-03-18 11:35:13 +01:00
37c5a2283e Added Fediverse linking options 2026-03-17 11:39:33 +01:00
d4b0d4d58a Fixes for RAM only build when there are no pages 2026-03-14 11:06:50 +01:00
d0ceef943c Added mynotes theme 2026-03-14 09:25:19 +01:00
0a5f9e20a3 Added new themes - some somehow inspired by the BSDs (FreeBSD, NetBSD, OpenBSD), field-journal, microfiche, museum-label
Optimized the "generate_theme_previews.sh" to build the site once, then copy the css files. This will increase the example directory build time.
2026-03-14 09:20:47 +01:00
cbc08b06cc add RAM mode and overhaul the build pipeline
- introduce RAM build mode with in-memory preload/index datasets and stage timing
- refactor build orchestration, indexing, content processing, and template handling
- improve cache/rebuild logic and parallel worker execution across generators
- enhance posts/pages/tags/authors/archives/feed generation and related-post flow
- update CLI/config/README for new build options and performance tuning
- harden timing logic to handle locale-specific EPOCHREALTIME decimal separators
2026-02-10 19:08:59 +01:00
e2822ad620 Fixed header generation: the metadata should only contain the post title, not the site name. 2025-12-29 11:22:44 +01:00
e91a1344b0 Version bump 2025-12-28 14:50:10 +01:00
b1c2397a93 Added INDEX_SHOW_FULL_CONTENT configuration option.
If true, show full post content on homepage instead of just description/excerpt.
2025-12-28 14:47:56 +01:00
41debaae5c Merge pull request 'Feature: expand config variables embedded in cmdline' (#41) from Iam_Tj/BSSG:feature-expand_config_vars_on_cmdline into main
Reviewed-on: #41
2025-11-18 09:49:54 +01:00
b5e1888d7a Merge pull request 'Fix parallel is not GNU' (#42) from Iam_Tj/BSSG:fix-parallel_is_not_GNU into main
Reviewed-on: #42
2025-11-18 09:48:18 +01:00
3fe84d322a Merge pull request 'Dutch / nl translation file' (#38) from zipkid/BSSG:feature/dutch_translation into main
Reviewed-on: #38
2025-10-14 09:39:26 +02:00
Tj
c252f106c8 Fix parallel is not GNU
BSSG assumes parallel is from GNU but moreutils also provides it. If the
moreutils version is available BSSG fails due to parellel options not
being supported:

 Found 1 posts needing processing out of 1 (Skipped: 0).
 Using GNU parallel to process 1 posts
 PARALLEL --jobs 16 --will-cite process_single_file_for_rebuild {}
 /usr/bin/parallel: invalid option -- '-'
 parallel [OPTIONS] command -- arguments
        for each argument, run command with argument, in parallel
 parallel [OPTIONS] -- commands
        run specified commands in parallel
 Parallel post processing failed.

When parallel is detected ask it for its version to ensure it is GNU.
2025-10-13 20:55:05 +00:00
Tj
fee45661de Feature: expand config variables embedded in cmdline
When editing multiple sites using BSSG_LCONF it is easy to forget which
path one is currently working with and frustrating to need to type exact
paths when editing files. E.g:

 export BSSF_LCONF=/a/very/deep/path/to/site/a
 bssg.sh page
 ...
 bssg.sh edit '/a/very/deep/path/to/site/a/pages/page-1.md'

It is easier, more intutive, and friendlier to be able to do:

 bssg.sh edit '$PAGES_DIR/pages/page-1.md'

Note the single quotes to prevent the interactive shell from doing
parameter expansion.

Signed-off-by: Tj <tj.iam.tj@proton.me>
2025-10-13 12:12:27 +00:00
Stefan - ZipKid - Goethals
b62b4d8b76
Dutch / nl translation file 2025-09-10 13:06:40 +02:00
f8f4e18be7 Added pre-compression, thoughtful theme, Cyber-Dark theme, removed some Google fonts leftovers 2025-07-17 09:02:17 +02:00
1f59e61879 Added pre-compression, thoughtful theme, Cyber-Dark theme, removed some Google fonts leftovers 2025-07-17 08:55:06 +02:00
c43a6c3cb8 Merge pull request 'Disable colour output for non-terms or if NO_COLOR is set' (#29) from jamesoff/BSSG:feature/no-color into main
Reviewed-on: #29
2025-06-24 07:46:06 +02:00
7ebd09eeeb
Disable colour output for non-terms or if NO_COLOR is set 2025-06-23 11:29:57 +01:00
1c7aab7b71 Removed Google leftovers 2025-06-21 16:09:12 +02:00
70760825b0 Preparing for 0.31 - related posts, theme fixes, etc 2025-06-18 19:26:13 +02:00
3c88fc7e69 The headline feature of this release is **complete multi-author support** throughout BSSG! This feature transforms BSSG from a single-author platform into a collaborative publishing system.
**New Author Frontmatter Fields:**
- `author_name`: Override the default site author on a per-post basis
- `author_email`: Specify custom author email information

**Intelligent Fallback System:**
- **Custom Author**: Both name and email override defaults
- **Name Only**: Just specify author name, email remains empty
- **Default Fallback**: Empty fields automatically use site configuration

**Author Index Pages:**
- **Main Authors Index**: Located at `/authors/` with post counts for each author
- **Individual Author Pages**: Dedicated pages at `/authors/author-slug/` for each author
- **Conditional Navigation**: "Authors" menu appears only when multiple authors exist (configurable threshold)
- **Visual Consistency**: Reuses existing tag page styling for familiar user experience

**Complete Integration:**
- **Schema.org JSON-LD**: Proper structured data for search engines
- **RSS Feeds**: Dublin Core `dc:creator` elements with full author attribution
- **Footer Copyright**: Dynamic author information in copyright notices
- **Index Listings**: "by Author Name" attribution throughout the site
- **Enhanced Sitemap**: Author pages automatically included for better SEO

**Configuration Options:**

ENABLE_AUTHOR_PAGES=false # Enable/disable author pages (default: false)
SHOW_AUTHORS_MENU_THRESHOLD=2 # Minimum authors to show menu (default: 2)
ENABLE_AUTHOR_RSS=false # Author-specific RSS feeds (default: false)

This release includes **BSSG Themes 0.30**, a comprehensive improvement of all 50 included themes focusing on performance, accessibility, and cross-platform compatibility.

**Performance Optimizations:**
- **External Font Dependencies Removed**: Eliminated Google Fonts and base64 encoded fonts across 35+ themes
- **System Font Stacks**: Comprehensive fallback fonts for better performance and reliability
- **Animation Optimization**: Added `@media (prefers-reduced-motion)` support to all themes
- **Mobile Performance**: 40-60% improvement in rendering speed on mobile devices
- **Backdrop Filter Optimization**: Reduced blur amounts and added mobile fallbacks

**Accessibility Enhancements:**
- **WCAG AA Compliance**: Tried to achieve 100% compliance across all themes
- **Keyboard Navigation**: Complete focus management with visible outlines
- **Screen Reader Support**: Enhanced semantic HTML structure and ARIA attributes
- **Reduced Motion**: Full support for users who prefer reduced motion

**Cross-Platform Compatibility:**
- **Text Browser Support**: Full functionality in lynx, w3m, and links browsers
- **Progressive Enhancement**: Graceful degradation for older browsers
- **Icon Fallbacks**: ASCII alternatives for Unicode symbols and decorative elements

**Critical Theme Fixes:**
- **Glassmorphism**: Complete redesign for maximum contrast and readability
- **Vaporwave**: Optimized neon effects and improved mobile performance
- **Flat**: Fixed critical invisible post title bug
- **Retro Computing Themes**: Enhanced authenticity while maintaining modern accessibility

**Improved Edit Command:**
- Fixed filename generation in edit mode (`-n` flag) that was causing build failures
- Clean filename formatting prevents awk errors with spaces in filenames
- Consistent approach across all BSSG operations

**Better Build Options:**
- Fixed `--force-rebuild` option that wasn't working as documented
- Both `--force-rebuild` and `-f` now work correctly as aliases
- Improved help text and command consistency

**Enhanced Post Editor:**
- Fixed datetime-local input showing GMT instead of local time
- Better date/time handling for content creation
- Improved user experience in the standalone editor

**RSS Feed Enhancements:**
- **Fixed XML Escaping**: Proper escaping of ampersands and special characters in RSS titles and descriptions
- **Image Caption Fix**: Eliminated duplicate `<figcaption>` elements in RSS feeds
- **Valid XML Output**: RSS feeds now validate properly with all RSS parsers

**Unicode and Internationalization:**
- **German Umlaut Handling**: Consistent Unicode character handling in URL slugs across all interfaces
- **Comprehensive Transliteration**: Support for German, French, Spanish, Polish, and other European languages
- **Cross-Interface Consistency**: Uniform slug generation in command-line tools, web editor, and admin interface

**Better Incremental Builds:**
- **New Author Detection**: Fixed caching issue where new authors weren't detected during incremental builds
- **Author Page URLs**: Fixed incorrect URL generation that didn't honor configured `URL_SLUG_FORMAT`
- **Dependency Tracking**: Improved rebuild logic for author-related content

**Enhanced Post Metadata:**
- **Improved Visual Presentation**: Better typography and spacing for post metadata banners
- **Semantic HTML**: Proper `<time>` elements with datetime attributes for accessibility and SEO
- **Responsive Design**: Metadata that scales appropriately across devices

**Sitemap Integration:**
- Author pages automatically included in `sitemap.xml` when enabled
- Proper priority levels for SEO optimization
- Conditional inclusion based on configuration

**Better File Structure:**
- Enhanced templates system with better placeholder replacement
- Improved navigation generation with conditional menu display
- Standardized component patterns across the codebase

**Theme Compatibility:**
- All 50 themes maintain their visual identity while gaining performance and accessibility improvements
- No breaking changes for existing installations
- Themes now work excellently across all browsers and devices

**Multi-Author Migration:**
- Existing single-author sites continue working unchanged
- Author fields are optional and fall back to site configuration
- No migration required for existing content

**Performance Recommendations:**
- Themes now work optimally without external dependencies
- Mobile experience significantly improved across all themes
2025-06-02 08:46:08 +02:00
6582872d70 Fixes 2025-05-27 08:34:00 +02:00
47d0ea02b0 Fixed ampersand and feed generation 2025-05-26 09:58:50 +02:00
ea2afcd6e9 Aggiorna README.md 2025-05-25 21:18:35 +02:00
e8c98d10ab Added bssg-editor - updated README 2025-05-25 19:12:10 +02:00
d8e7e3f0ea Merge pull request 'Make sure slug for blog posts is sanitized' (#24) from 82mhz/BSSG:fixSlug into main
Reviewed-on: #24
2025-05-23 19:52:11 +02:00
Andreas
7df772e509 Make sure slug for blog posts is sanitized 2025-05-23 15:19:48 +02:00
ce4269dd6e Slug fix - by Andreas 2025-05-22 21:05:40 +02:00
3a52a4b24a Fixed README 2025-05-18 19:05:39 +02:00
4dcec9f963 Added socat support 2025-05-18 18:57:38 +02:00
7eaf64ea4a Fixed colours 2025-05-18 15:33:07 +02:00
03c83e5b76 - **server Command**: Introduced a new command ./bssg.sh server to facilitate local development.
- This command builds the static site and then starts a simple Bash HTTP server (`scripts/server.sh`) to serve the files from the output directory.
    - **Dynamic `SITE_URL`**: When the `server` command initiates a build, it temporarily overrides the `SITE_URL` configuration to match the local server's address (e.g., `http://localhost:PORT`). This ensures that all generated links and assets paths are correct for local previewing.
    - **Command-line Options**:
        - `--port <PORT>`: Specifies the port for the server to listen on.
        - `--host <HOST>`: Specifies the host/IP address for the server.
        - `--no-build`: Skips the build step and starts the server with the existing content in the output directory.
- **Configurable Server Defaults**:
    - The default port and host for the `./bssg.sh server` command can now be configured in `config.sh` (via `BSSG_SERVER_PORT_DEFAULT` and `BSSG_SERVER_HOST_DEFAULT`) and overridden in `config.sh.local`.
    - The help message for the `server` command displays these configured default values.

- The `bssg.sh` main script was updated to include the `server` command logic, argument parsing, and help text.
- `scripts/build/config_loader.sh` was updated to load and export the new server default configuration variables (`BSSG_SERVER_PORT_DEFAULT`, `BSSG_SERVER_HOST_DEFAULT`).
- `config.sh` was updated with the new default server configuration variables.
2025-05-18 15:17:19 +02:00
e494aed35b Fixed the generate_theme_previews.sh - now working with a website generated by the init command 2025-05-18 08:47:36 +02:00
64b2c439c3 Configurable RSS feed filenames. The main RSS feed and tag-specific RSS feeds can now have their filenames customized via the RSS_FILENAME option in config.sh (or config.sh.local). Defaults to rss.xml if not specified.
- The main feed will be `OUTPUT_DIR/$RSS_FILENAME`.
  - Tag feeds will be `OUTPUT_DIR/tags/tag-slug/$RSS_FILENAME`.
2025-05-13 08:49:39 +02:00
2474b8146d Fix: version 2025-05-13 07:58:53 +02:00
ded254d171 Fix: -f will force rebuild 2025-05-05 08:48:09 +02:00
95b5319b93 Updated README.md - on MacOS, the default bash is too old 2025-04-30 06:55:30 +02:00
2636df570c Fixed list command to support --config, so it doesn't re-read the config on its own 2025-04-25 08:59:48 +02:00
88c2a98911 Fixed list command to support --config, so it doesn't re-read the config on its own 2025-04-25 08:57:29 +02:00
afaaed81d1 Fixed regression 2025-04-24 22:12:14 +02:00
12a39b5e62 Re-implemented the --config - as shown in the manuals. Add support for BSSG_LCONF environment variable. The init script now adds a CACHE_DIR setting to the new site's config.sh.local. Change default answer to 'Yes' (Y) in the init script when asking whether to modify the core config.sh.local to automatically source the new site's configuration. 2025-04-24 21:43:37 +02:00
c5d1b48476 Re-implemented the --config - as shown in the manuals. Add support for BSSG_LCONF environment variable. The init script now adds a CACHE_DIR setting to the new site's config.sh.local. Change default answer to 'Yes' (Y) in the init script when asking whether to modify the core config.sh.local to automatically source the new site's configuration. 2025-04-24 21:16:44 +02:00
5872f2a5a6 Fixed caching for Archive pages 2025-04-24 13:08:37 +02:00
220b82832a Reversed order for Archives posts 2025-04-24 12:30:20 +02:00
4d3ae7c92f Added ARCHIVES_LIST_ALL_POSTS option to generate a full list of posts in the Archives page 2025-04-24 09:05:02 +02:00
81e8b8d5de Indexing fix - changed the file comparison procedure for NetBSD compatibility 2025-04-21 12:38:09 +02:00
7b3539de65 Small cosmetic fix 2025-04-21 08:25:57 +02:00
a055615e79 Fixed for home deploy script 2025-04-21 08:24:49 +02:00
54f8c66480 Fixed for home deploy script 2025-04-20 19:04:37 +02:00
582d7970b8 Fixed for rss regeneration with --site-url 2025-04-19 14:46:07 +02:00
48f332e1fc Clean output also triggers the force rebuild 2025-04-19 08:15:26 +02:00
0d470e4f23 Clean output also triggers the force rebuild 2025-04-19 08:11:10 +02:00
a893411e78 Bump for 0.15 2025-04-18 21:21:32 +02:00
68883053f0 Fixed Previews without description 2025-04-18 21:16:30 +02:00
31314e7328 Removed Read More in index pages 2025-04-18 21:04:12 +02:00
e3139d51f4 Do not generate the "Tags" menu if there are no posts with tags (adapted by Tom's patch)
Allow a custom pages/index.md (with slug "index") that will be shown as homepage. This allows to create a custom homepage for the website, without the "Latest Posts" list.  (adapted by Tom's patch)
Render automatically generated post excerpts (used when no explicit `description` is set in frontmatter) as HTML instead of raw markdown on list pages (index, tags, archives).
Partial rewrite of the caching code, index generations, etc. Now only the modified tags and/or archives will be regenerated, speeding up the rebuild time.
Many, many other bugfixes
2025-04-18 19:54:04 +02:00
706cf9c154 Fixes for OpenBSD and Archive Generation 2025-04-17 14:44:14 +02:00
46a5e8e496 Refactored for speedup - HUGE speedup 2025-04-17 09:34:08 +02:00
4225940c2a Render automatically generated post excerpts (used when no explicit description is set in frontmatter) as HTML instead of raw markdown on list pages (index, tags, archives). 2025-04-16 20:09:26 +02:00
9dd4b42c40 Render automatically generated post excerpts (used when no explicit description is set in frontmatter) as HTML instead of raw markdown on list pages (index, tags, archives). 2025-04-16 19:33:00 +02:00
c2cd5367f8 Fixed for image 2025-04-16 09:24:22 +02:00
9976dfdf3f Fix for 0.12.1 2025-04-16 09:12:42 +02:00
925e73900e Bump for 0.12 2025-04-16 08:33:56 +02:00
cc354c5dbd Bugfix for backup/restore logic 2025-04-16 08:26:00 +02:00
a3201bbba7 Bugfix for Linux 2025-04-16 07:40:38 +02:00
b43ea5ebbc RSS tag fixes 2025-04-15 23:14:47 +02:00
5fc2bd74de Bugfix 2025-04-15 22:48:10 +02:00
ebb42ab78e Bugfix 2025-04-15 22:13:25 +02:00
1876b199b3 Added deploy support 2025-04-15 21:24:23 +02:00
96f708367f Added post creation from command line 2025-04-15 21:04:24 +02:00
4e28bc3569 Fixed RSS for tags when requested full post in RSS 2025-04-15 14:38:15 +02:00
4144fe5c7b - New command-line arguments for the build command to override configuration settings:
- `--config`, `--src`, `--output`, `--templates`, `--theme`, `--static`, `--clean-output`, `--site-title`, `--site-url`, `--site-description`, `--author-name`, `--author-email`, `--posts-per-page`, `--local-config`.
- Added CLI options `--pages`, `--drafts`, and `--themes-dir` to override respective directory paths during build.
- Implemented RSS feed generation for each tag (`${OUTPUT_DIR}/tags/<tag_slug>/rss.xml`).
- Tag-specific RSS feed generation is now optional via the `ENABLE_TAG_RSS` configuration variable (default: `false`).
2025-04-15 10:31:16 +02:00
e8538f872f Bump for 0.11 2025-04-14 10:37:40 +02:00
66ea4b13cd init now supports relative path between quotes - fixed secondary pages 2025-04-14 10:25:54 +02:00
be4dff0b13 Added custom CSS support - templates 2025-04-14 09:49:50 +02:00
f5e90ba1f0 Added custom CSS support 2025-04-14 09:48:08 +02:00
852bba9f8d init now supports relative path between quotes - fixed secondary pages 2025-04-14 09:34:05 +02:00
e976b69e9a - Corrected how theme CSS (style.css) is located by respecting the THEMES_DIR variable instead of using a hardcoded path relative to the BSSG installation (scripts/build/assets.sh). This allows themes defined outside the default themes/ directory (e.g., in an initialized site) to be used correctly.
- Corrected how structural HTML templates (`header.html`, `footer.html`, etc.) are located. They are now always loaded directly from the directory specified by the `TEMPLATES_DIR` variable, ensuring themes only control styling (`style.css`) and not the core HTML structure (`scripts/build/templates.sh`).
2025-04-14 08:40:54 +02:00
5c9c9a19e1 Fixed README.md 2025-04-13 20:19:35 +02:00
b09a501b0c Added init <directory> command (bssg.sh, scripts/init.sh) to initialize a new, separate site structure. This allows keeping site content independent from the BSSG core installation, facilitating updates. The generated site config includes OUTPUT_DIR setting. 2025-04-13 20:12:39 +02:00
e94d166f70 edit now updates the lastmod - page has been updated as post, so generates lastmod - drafts directory can now be configured - post and page now use the config options to determine where to put the generated files 2025-04-13 19:24:44 +02:00
f7024d80e2 post: add lastmod by default 2025-04-13 15:23:50 +02:00
9de9d95b7c Added RSS_INCLUDE_FULL_CONTENT option 2025-04-13 15:13:23 +02:00
a15ae91fe1 Added blog to README.md 2025-04-13 12:18:26 +02:00
ae1312e2cb Version bump to 0.10 2025-04-13 12:09:21 +02:00
b7f7ded3c4 README.md - fixes 2025-04-13 12:05:41 +02:00
23fc3874a2 Fixed minimal style 2025-04-13 10:26:25 +02:00
25bfb2760f updated generate_theme_previews for new build system 2025-04-13 09:54:53 +02:00
23eac6fbec feat: Refactor build process and add new features/configuration
- Refactor build system by splitting build.sh into modular scripts.
- Add configuration options for RSS item limit, timezone display, and automatic rebuilds after post/edit commands.
- Introduce features: localized "Back to Top" link, optional 'lastmod' frontmatter for posts, and a helpful 'vi' exit message easter egg.
2025-04-13 09:32:36 +02:00
17ab2e7e7a Use image_caption as alt text if available 2025-04-09 09:27:56 +02:00
448559d41b Fixes for older/newer navigation and reader-mode theme 2025-04-08 22:15:50 +02:00
110 changed files with 30121 additions and 8600 deletions

834
README.md

File diff suppressed because it is too large Load diff

1
VERSION Normal file
View file

@ -0,0 +1 @@
0.41

2925
bssg-editor.html Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,7 +1,7 @@
#!/usr/bin/env bash
#
# BSSG - Configuration File
# Version 0.9
# Version controlled via root VERSION file
# Contains all configurable parameters for the static site generator
# Developed by Stefano Marinelli (stefano@dragas.it)
#
@ -19,10 +19,19 @@ OUTPUT_DIR="output"
TEMPLATES_DIR="templates"
THEMES_DIR="themes"
STATIC_DIR="static"
DRAFTS_DIR="drafts" # Directory for drafts
THEME="default"
CACHE_DIR=".bssg_cache" # Default cache directory location (relative to BSSG root)
# Build configuration
CLEAN_OUTPUT=false
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.
# Site information
SITE_TITLE="My new BSSG site"
@ -30,16 +39,47 @@ 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"
DATE_FORMAT="%Y-%m-%d %H:%M:%S %z"
TIMEZONE="local" # Options: "local", "GMT", or a specific timezone like "America/New_York"
SHOW_TIMEZONE="false" # Options: "true", "false". Whether to display the timezone in rendered dates.
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
URL_SLUG_FORMAT="Year/Month/Day/slug" # Format for post URLs: Year/Month/Day/slug will create Year/Month/Day/slug/index.html
ENABLE_AUTHOR_PAGES=false # Enable or disable author pages (default: false)
ENABLE_AUTHOR_RSS=false # Enable or disable author-specific RSS feeds (default: false)
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)
# Display configuration
SHOW_HEADER_MENU=true # Options: "true", "false". Show navigation menu in the header.
SHOW_INDEX_DESCRIPTIONS=true # Options: "true", "false". Show post descriptions/excerpts on the index page.
GENERATE_EXCERPT=true # Options: "true", "false". Generate excerpt from content when no description is provided.
SHOW_READING_TIME=true # Options: "true", "false". Show reading time on individual post pages.
# Archive Page Configuration
ARCHIVES_LIST_ALL_POSTS="false" # Options: "true", "false". If true, list all posts on the main archive page.
# Page configuration
PAGE_URL_FORMAT="pages/slug" # Format for page URLs: pages/slug will create pages/slug/index.html
PAGE_URL_FORMAT="slug" # Format for page URLs. Available: slug, filename (without ext)
# Markdown processing configuration
MARKDOWN_PROCESSOR="commonmark" # Options: "pandoc", "commonmark", or "markdown.pl"
@ -47,6 +87,19 @@ MARKDOWN_PROCESSOR="commonmark" # Options: "pandoc", "commonmark", or "markdown.
# Language Configuration
SITE_LANG="en" # Default language code (e.g., en, es, fr). See locales/ directory.
# Related Posts Configuration
ENABLE_RELATED_POSTS=true # Enable or disable related posts feature
RELATED_POSTS_COUNT=3 # Number of related posts to show (default: 3)
# Server Configuration (for 'bssg.sh server' command)
# These are the defaults used by 'bssg.sh server' if not overridden by command-line options.
BSSG_SERVER_PORT_DEFAULT="8000" # Default port for the local development server
BSSG_SERVER_HOST_DEFAULT="localhost" # Default host for the local development server
# Deployment configuration
DEPLOY_AFTER_BUILD="false" # Options: "true", "false". Automatically deploy after a successful build.
DEPLOY_SCRIPT="" # Path to the deployment script to execute if DEPLOY_AFTER_BUILD is true.
# Terminal colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'

File diff suppressed because it is too large Load diff

View file

@ -3,14 +3,17 @@
export MSG_HOME="Startseite"
export MSG_TAGS="Tags"
export MSG_AUTHORS="Autoren"
export MSG_ARCHIVES="Archive"
export MSG_RSS="RSS"
export MSG_PAGES="Seiten"
export MSG_SUBSCRIBE_RSS="Per RSS abonnieren"
export MSG_PUBLISHED_ON="Veröffentlicht am"
export MSG_BY="von"
export MSG_POSTS_BY="Beiträge von"
export MSG_TAG_PAGE_TITLE="Beiträge mit dem Tag"
export MSG_ALL_TAGS="Alle Tags"
export MSG_ALL_AUTHORS="Alle Autoren"
export MSG_ALL_PAGES="Alle Seiten"
export MSG_ARCHIVES_FOR="Archive für"
export MSG_BACK_TO="Zurück zu"
@ -40,4 +43,9 @@ export MSG_MONTH_09="September"
export MSG_MONTH_10="Oktober"
export MSG_MONTH_11="November"
export MSG_MONTH_12="Dezember"
export MSG_READING_TIME_TEMPLATE="%d Min. Lesezeit"
export MSG_READING_TIME_TEMPLATE="%d Min. Lesezeit"
export MSG_MINUTE="Minute"
export MSG_MINUTES="Minuten"
export MSG_UPDATED_ON="Aktualisiert am"
export MSG_BACK_TO_TOP="Nach oben"
export MSG_RELATED_POSTS="Ähnliche Beiträge"

View file

@ -3,14 +3,17 @@
export MSG_HOME="Home"
export MSG_TAGS="Tags"
export MSG_AUTHORS="Authors"
export MSG_ARCHIVES="Archives"
export MSG_RSS="RSS"
export MSG_PAGES="Pages"
export MSG_SUBSCRIBE_RSS="Subscribe via RSS"
export MSG_PUBLISHED_ON="Published on"
export MSG_BY="by"
export MSG_POSTS_BY="Posts by"
export MSG_TAG_PAGE_TITLE="Posts tagged with"
export MSG_ALL_TAGS="All Tags"
export MSG_ALL_AUTHORS="All Authors"
export MSG_ALL_PAGES="All Pages"
export MSG_ARCHIVES_FOR="Archives for"
export MSG_BACK_TO="Back to"
@ -40,4 +43,9 @@ 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_READING_TIME_TEMPLATE="%d min read"
export MSG_MINUTE="minute"
export MSG_MINUTES="minutes"
export MSG_UPDATED_ON="Updated on"
export MSG_BACK_TO_TOP="Back to Top"
export MSG_RELATED_POSTS="Related Posts"

View file

@ -3,14 +3,17 @@
export MSG_HOME="Inicio"
export MSG_TAGS="Etiquetas"
export MSG_AUTHORS="Autores"
export MSG_ARCHIVES="Archivos"
export MSG_RSS="RSS"
export MSG_PAGES="Páginas"
export MSG_SUBSCRIBE_RSS="Suscribirse vía RSS"
export MSG_PUBLISHED_ON="Publicado el"
export MSG_BY="por"
export MSG_POSTS_BY="Entradas de"
export MSG_TAG_PAGE_TITLE="Entradas etiquetadas con"
export MSG_ALL_TAGS="Todas las etiquetas"
export MSG_ALL_AUTHORS="Todos los autores"
export MSG_ALL_PAGES="Todas las páginas"
export MSG_ARCHIVES_FOR="Archivos de"
export MSG_BACK_TO="Volver a"
@ -40,4 +43,9 @@ export MSG_MONTH_09="Septiembre"
export MSG_MONTH_10="Octubre"
export MSG_MONTH_11="Noviembre"
export MSG_MONTH_12="Diciembre"
export MSG_READING_TIME_TEMPLATE="%d min de lectura"
export MSG_READING_TIME_TEMPLATE="%d min de lectura"
export MSG_MINUTE="minuto"
export MSG_MINUTES="minutos"
export MSG_UPDATED_ON="Actualizado el"
export MSG_BACK_TO_TOP="Volver arriba"
export MSG_RELATED_POSTS="Artículos relacionados"

View file

@ -3,14 +3,17 @@
export MSG_HOME="Accueil"
export MSG_TAGS="Étiquettes"
export MSG_AUTHORS="Auteurs"
export MSG_ARCHIVES="Archives"
export MSG_RSS="RSS"
export MSG_PAGES="Pages"
export MSG_SUBSCRIBE_RSS="S'abonner via RSS"
export MSG_PUBLISHED_ON="Publié le"
export MSG_BY="par"
export MSG_POSTS_BY="Articles de"
export MSG_TAG_PAGE_TITLE="Articles étiquetés avec"
export MSG_ALL_TAGS="Toutes les étiquettes"
export MSG_ALL_AUTHORS="Tous les auteurs"
export MSG_ALL_PAGES="Toutes les pages"
export MSG_ARCHIVES_FOR="Archives pour"
export MSG_BACK_TO="Retour à"
@ -40,4 +43,9 @@ export MSG_MONTH_09="Septembre"
export MSG_MONTH_10="Octobre"
export MSG_MONTH_11="Novembre"
export MSG_MONTH_12="Décembre"
export MSG_READING_TIME_TEMPLATE="%d min de lecture"
export MSG_READING_TIME_TEMPLATE="%d min de lecture"
export MSG_MINUTE="minute"
export MSG_MINUTES="minutes"
export MSG_UPDATED_ON="Mis à jour le"
export MSG_BACK_TO_TOP="Retour en haut"
export MSG_RELATED_POSTS="Articles connexes"

View file

@ -3,14 +3,17 @@
export MSG_HOME="Home"
export MSG_TAGS="Tag"
export MSG_AUTHORS="Autori"
export MSG_ARCHIVES="Archivi"
export MSG_RSS="RSS"
export MSG_PAGES="Pagine"
export MSG_SUBSCRIBE_RSS="Abbonati via RSS"
export MSG_PUBLISHED_ON="Pubblicato il"
export MSG_BY="da"
export MSG_POSTS_BY="Articoli di"
export MSG_TAG_PAGE_TITLE="Articoli taggati con"
export MSG_ALL_TAGS="Tutti i tag"
export MSG_ALL_AUTHORS="Tutti gli autori"
export MSG_ALL_PAGES="Tutte le pagine"
export MSG_ARCHIVES_FOR="Archivi per"
export MSG_BACK_TO="Torna a"
@ -40,4 +43,9 @@ export MSG_MONTH_09="Settembre"
export MSG_MONTH_10="Ottobre"
export MSG_MONTH_11="Novembre"
export MSG_MONTH_12="Dicembre"
export MSG_READING_TIME_TEMPLATE="%d min di lettura"
export MSG_READING_TIME_TEMPLATE="%d min di lettura"
export MSG_MINUTE="minuto"
export MSG_MINUTES="minuti"
export MSG_UPDATED_ON="Aggiornato il"
export MSG_BACK_TO_TOP="Torna in cima"
export MSG_RELATED_POSTS="Articoli correlati"

View file

@ -3,14 +3,17 @@
export MSG_HOME="ホーム"
export MSG_TAGS="タグ"
export MSG_AUTHORS="著者"
export MSG_ARCHIVES="アーカイブ"
export MSG_RSS="RSS"
export MSG_PAGES="ページ"
export MSG_SUBSCRIBE_RSS="RSSで購読する"
export MSG_PUBLISHED_ON="公開日"
export MSG_BY="作成者"
export MSG_POSTS_BY="の投稿"
export MSG_TAG_PAGE_TITLE="タグ付きの投稿"
export MSG_ALL_TAGS="すべてのタグ"
export MSG_ALL_AUTHORS="すべての著者"
export MSG_ALL_PAGES="すべてのページ"
export MSG_ARCHIVES_FOR="のアーカイブ"
export MSG_BACK_TO="に戻る"
@ -40,4 +43,9 @@ export MSG_MONTH_09="9月"
export MSG_MONTH_10="10月"
export MSG_MONTH_11="11月"
export MSG_MONTH_12="12月"
export MSG_READING_TIME_TEMPLATE="読む時間: %d分"
export MSG_READING_TIME_TEMPLATE="読了時間 %d分"
export MSG_MINUTE="分"
export MSG_MINUTES="分"
export MSG_UPDATED_ON="更新日"
export MSG_BACK_TO_TOP="トップに戻る"
export MSG_RELATED_POSTS="関連記事"

51
locales/nl.sh Normal file
View file

@ -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"

View file

@ -3,14 +3,17 @@
export MSG_HOME="Início"
export MSG_TAGS="Etiquetas"
export MSG_AUTHORS="Autores"
export MSG_ARCHIVES="Arquivos"
export MSG_RSS="RSS"
export MSG_PAGES="Páginas"
export MSG_SUBSCRIBE_RSS="Subscrever via RSS"
export MSG_PUBLISHED_ON="Publicado em"
export MSG_BY="por"
export MSG_POSTS_BY="Posts de"
export MSG_TAG_PAGE_TITLE="Posts etiquetados com"
export MSG_ALL_TAGS="Todas as Etiquetas"
export MSG_ALL_AUTHORS="Todos os Autores"
export MSG_ALL_PAGES="Todas as Páginas"
export MSG_ARCHIVES_FOR="Arquivos de"
export MSG_BACK_TO="Voltar para"
@ -40,4 +43,9 @@ export MSG_MONTH_09="Setembro"
export MSG_MONTH_10="Outubro"
export MSG_MONTH_11="Novembro"
export MSG_MONTH_12="Dezembro"
export MSG_READING_TIME_TEMPLATE="%d min de leitura"
export MSG_READING_TIME_TEMPLATE="%d min de leitura"
export MSG_MINUTE="minuto"
export MSG_MINUTES="minutos"
export MSG_UPDATED_ON="Atualizado em"
export MSG_BACK_TO_TOP="Voltar ao topo"
export MSG_RELATED_POSTS="Posts Relacionados"

View file

@ -3,14 +3,17 @@
export MSG_HOME="首页"
export MSG_TAGS="标签"
export MSG_AUTHORS="作者"
export MSG_ARCHIVES="归档"
export MSG_RSS="RSS"
export MSG_PAGES="页面"
export MSG_SUBSCRIBE_RSS="通过 RSS 订阅"
export MSG_PUBLISHED_ON="发布于"
export MSG_BY="作者"
export MSG_POSTS_BY="的文章"
export MSG_TAG_PAGE_TITLE="标签为 的文章"
export MSG_ALL_TAGS="所有标签"
export MSG_ALL_AUTHORS="所有作者"
export MSG_ALL_PAGES="所有页面"
export MSG_ARCHIVES_FOR="的归档"
export MSG_BACK_TO="返回"
@ -40,4 +43,9 @@ export MSG_MONTH_09="九月"
export MSG_MONTH_10="十月"
export MSG_MONTH_11="十一月"
export MSG_MONTH_12="十二月"
export MSG_READING_TIME_TEMPLATE="阅读时间:%d 分钟"
export MSG_READING_TIME_TEMPLATE="阅读时间 %d 分钟"
export MSG_MINUTE="分钟"
export MSG_MINUTES="分钟"
export MSG_UPDATED_ON="更新于"
export MSG_BACK_TO_TOP="返回顶部"
export MSG_RELATED_POSTS="相关文章"

View file

@ -7,118 +7,207 @@
# Project Homepage: https://bssg.dragas.net
#
# This script RELIES on environment variables set by config_loader.sh
# It should be called via the main bssg.sh script.
set -e
# Load configuration
CONFIG_FILE="config.sh"
if [ -f "$CONFIG_FILE" ]; then
source "$CONFIG_FILE"
# Source utilities needed for logging (ensure they are available)
# Determine the directory of the main bssg script if BSSG_SCRIPT_DIR is set
SCRIPT_DIR="${BSSG_SCRIPT_DIR:-$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )/..}"
UTILS_SCRIPT="${SCRIPT_DIR}/scripts/build/utils.sh"
if [ -f "$UTILS_SCRIPT" ]; then
# shellcheck source=scripts/build/utils.sh
source "$UTILS_SCRIPT"
else
echo "Error: Configuration file '$CONFIG_FILE' not found"
exit 1
# Minimal fallback if utils not found
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[0;33m'
NC='\033[0m'
print_error() { echo -e "${RED}Error: $1${NC}" >&2; }
print_warning() { echo -e "${YELLOW}Warning: $1${NC}"; }
print_success() { echo -e "${GREEN}Success: $1${NC}"; }
print_info() { echo -e "Info: $1"; }
print_error "Utilities script not found at '$UTILS_SCRIPT'. Using fallback logging."
fi
# Load local configuration overrides if they exist
LOCAL_CONFIG_FILE="config.sh.local"
if [ -f "$LOCAL_CONFIG_FILE" ]; then
source "$LOCAL_CONFIG_FILE"
fi
# Terminal colors
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[0;33m'
NC='\033[0m' # No Color
# Check essential variables are set (they should be exported by config_loader.sh)
: "${CONFIG_FILE:?Error: CONFIG_FILE environment variable not set. Run via bssg.sh}"
: "${SRC_DIR:?Error: SRC_DIR environment variable not set. Run via bssg.sh}"
: "${BACKUP_DIR:?Error: BACKUP_DIR environment variable not set. Run via bssg.sh}"
: "${LOCAL_CONFIG_FILE:?Error: LOCAL_CONFIG_FILE environment variable not set. Run via bssg.sh}"
# Create backup directory if it doesn't exist
mkdir -p "backup"
mkdir -p "$BACKUP_DIR"
# Function to backup posts
backup_posts() {
local timestamp=$(date +%Y%m%d%H%M%S)
local backup_file="backup/bssg_backup_$timestamp.tar.gz"
echo -e "${YELLOW}Creating backup of all posts...${NC}"
# Create tar archive with all .md and .html files in src directory
tar -czf "$backup_file" -C "$SRC_DIR" .
# Also include the drafts directory if it exists
if [ -d "drafts" ]; then
echo -e "${YELLOW}Including drafts in backup...${NC}"
tar -rf "${backup_file%.gz}" -C "drafts" .
gzip -f "${backup_file%.gz}"
# Function to backup site content and configuration
create_backup() {
local timestamp
timestamp=$(date +%Y%m%d%H%M%S)
local backup_filename="bssg_backup_$timestamp.tar.gz"
local backup_filepath="${BACKUP_DIR}/${backup_filename}"
print_info "Creating backup..."
# Prepare list of tar options (-C dir file)
local tar_opts=()
# Function to safely add item to tar options
add_item_to_backup() {
local item_path="$1"
local item_type="$2" # 'file' or 'dir'
if [ "$item_type" == "file" ] && [ ! -f "$item_path" ]; then
print_warning "File '$item_path' not found, skipping."
return
elif [ "$item_type" == "dir" ] && [ ! -d "$item_path" ]; then
print_warning "Directory '$item_path' not found, skipping."
return
elif [ "$item_type" != "file" ] && [ "$item_type" != "dir" ]; then
print_error "Internal error: Invalid item type '$item_type' for $item_path"
return
fi
local dir
local base
dir=$(dirname "$item_path")
base=$(basename "$item_path")
tar_opts+=("-C" "$dir" "$base")
print_info "Adding $item_type '$base' from '$dir' to backup."
}
# Add main config file
add_item_to_backup "$CONFIG_FILE" "file"
# Add local config file
add_item_to_backup "$LOCAL_CONFIG_FILE" "file"
# Add source directory
add_item_to_backup "$SRC_DIR" "dir"
# Add drafts directory (if defined)
if [ -n "${DRAFTS_DIR:-}" ]; then
add_item_to_backup "$DRAFTS_DIR" "dir"
fi
# Add pages directory (if defined)
# Check if it's the same as SRC_DIR to avoid adding twice
if [ -n "${PAGES_DIR:-}" ]; then
if [[ "$(cd "$SRC_DIR" && pwd)" != "$(cd "$PAGES_DIR" && pwd)" ]]; then
add_item_to_backup "$PAGES_DIR" "dir"
else
print_info "Pages directory '$PAGES_DIR' is same as source directory '$SRC_DIR', skipping duplicate add."
fi
fi
# Also include the pages directory if it exists
if [ -d "pages" ]; then
echo -e "${YELLOW}Including pages in backup...${NC}"
tar -rf "${backup_file%.gz}" -C "pages" .
gzip -f "${backup_file%.gz}"
# Check if there are items to back up
# We check tar_opts length / 2 because each item adds two elements (-C and path)
if [ ${#tar_opts[@]} -eq 0 ]; then
print_error "No items found to back up. Please check configuration and file/directory existence."
return 1
fi
# Create the tar archive
print_info "Archiving items to $backup_filepath"
# The items added via -C will be relative to the archive root
tar -czf "$backup_filepath" "${tar_opts[@]}"
if [ $? -eq 0 ]; then
print_success "Backup created: $backup_filepath"
else
print_error "Failed to create backup archive."
return 1
fi
# Manage daily backup and cleanup
manage_backup_rotation "$backup_filepath"
print_success "Backup process completed."
}
# Function to manage daily backups and rotation
manage_backup_rotation() {
local latest_backup_filepath="$1"
local today
today=$(date +%Y%m%d)
local daily_backup_filename="bssg_daily_$today.tar.gz"
local daily_backup_filepath="${BACKUP_DIR}/${daily_backup_filename}"
# Create a daily backup if it doesn't exist or if the latest is newer
if [ ! -f "$daily_backup_filepath" ] || [ "$latest_backup_filepath" -nt "$daily_backup_filepath" ]; then
cp "$latest_backup_filepath" "$daily_backup_filepath"
print_success "Daily backup created/updated: $daily_backup_filepath"
fi
# Keep only latest 10 timestamped backups (excluding daily backups)
print_info "Cleaning old timestamped backups (keeping latest 10)..."
# Portable way to list, sort by time, skip 10 newest, and delete the rest
# Use null delimiter with ls/tail/xargs if available (safer), otherwise newline
local file_list
local count=0
# Count the number of backups first to avoid errors with tail if less than 11
count=$(ls -1 "${BACKUP_DIR}"/bssg_backup_*.tar.gz 2>/dev/null | wc -l)
if [ "$count" -gt 10 ]; then
# List files sorted by modification time (newest first), get all except the first 10
# Use process substitution to avoid issues with subshells and xargs
# Use xargs -I {} rm -f "{}" for basic portability with spaces
ls -t "${BACKUP_DIR}"/bssg_backup_*.tar.gz 2>/dev/null | tail -n +11 | xargs -I {} rm -f "{}"
if [ $? -ne 0 ]; then
print_warning "Potentially failed to clean some old backups. Please check manually."
fi
else
print_info "Fewer than 11 timestamped backups found, no cleanup needed."
fi
# Also include the config.sh.local file if it exists
if [ -f "$LOCAL_CONFIG_FILE" ]; then
echo -e "${YELLOW}Including local configuration in backup...${NC}"
tar -rf "${backup_file%.gz}" "$LOCAL_CONFIG_FILE"
gzip -f "${backup_file%.gz}"
fi
echo -e "${GREEN}Backup created: $backup_file${NC}"
# Create a daily backup if it doesn't exist
local today=$(date +%Y%m%d)
local daily_backup="backup/bssg_daily_$today.tar.gz"
if [ ! -f "$daily_backup" ]; then
cp "$backup_file" "$daily_backup"
echo -e "${GREEN}Daily backup created: $daily_backup${NC}"
fi
# Keep only latest 10 backups
echo -e "${YELLOW}Cleaning old backups...${NC}"
cd backup
ls -t bssg_backup_*.tar.gz | tail -n +11 | xargs rm -f 2>/dev/null || true
cd ..
echo -e "${GREEN}Backup process completed.${NC}"
print_info "Backup cleanup finished."
}
# Function to list available backups
list_backups() {
echo -e "${YELLOW}Available backups:${NC}"
if [ ! -d "backup" ] || [ -z "$(ls -A backup 2>/dev/null)" ]; then
echo -e "${RED}No backups found.${NC}"
exit 0
print_info "Available backups in ${BACKUP_DIR}:${NC}"
if [ ! -d "$BACKUP_DIR" ] || [ -z "$(ls -A "$BACKUP_DIR" 2>/dev/null)" ]; then
print_error "No backups found.${NC}"
return 0
fi
echo -e "ID\tDate\t\tTime\t\tSize\t\tFile"
echo -e "--\t----\t\t----\t\t----\t\t----"
local counter=1
ls -t backup/bssg_*.tar.gz 2>/dev/null | while read -r file; do
# Use find to handle filenames safely
find "$BACKUP_DIR" -maxdepth 1 -name 'bssg_*.tar.gz' -printf '%T@ %p\n' | \
sort -nr | \
cut -d' ' -f2- | \
while IFS= read -r file; do
if [ -f "$file" ]; then
local filename=$(basename "$file")
local filename
filename=$(basename "$file")
local date_part=""
local time_part=""
# Match timestamped backup
if [[ "$filename" =~ bssg_backup_([0-9]{8})([0-9]{6})\.tar\.gz ]]; then
# Regular backup format
date_part="${BASH_REMATCH[1]:0:4}-${BASH_REMATCH[1]:4:2}-${BASH_REMATCH[1]:6:2}"
time_part="${BASH_REMATCH[2]:0:2}:${BASH_REMATCH[2]:2:2}:${BASH_REMATCH[2]:4:2}"
# Match daily backup
elif [[ "$filename" =~ bssg_daily_([0-9]{8})\.tar\.gz ]]; then
# Daily backup format
date_part="${BASH_REMATCH[1]:0:4}-${BASH_REMATCH[1]:4:2}-${BASH_REMATCH[1]:6:2}"
time_part="00:00:00"
time_part="Daily"
else
# Unknown format, show filename
date_part="Unknown"
time_part="Unknown"
time_part="Format"
fi
local size=$(du -h "$file" | cut -f1)
echo -e "$counter\t$date_part\t$time_part\t$size\t\t$filename"
local size
size=$(du -h "$file" | cut -f1)
# Use printf for safer, more consistent formatting
printf "%s\t%s\t%s\t%s\t%s\n" "$counter" "$date_part" "$time_part" "$size" "$filename"
counter=$((counter + 1))
fi
done
@ -127,22 +216,22 @@ list_backups() {
# Main function
main() {
local command="backup"
# Parse arguments
if [ -n "$1" ]; then
command="$1"
shift
fi
case "$command" in
backup|create)
backup_posts
create_backup
;;
list)
list_backups
;;
*)
echo -e "${RED}Error: Unknown command '$command'${NC}"
print_error "Unknown command '$command'"
echo -e "Usage: $0 [backup|create|list]"
exit 1
;;

View file

@ -9,75 +9,218 @@
set -e
# Load configuration
CONFIG_FILE="config.sh"
if [ -f "$CONFIG_FILE" ]; then
source "$CONFIG_FILE"
# --- Argument Parsing for --config --- START ---
# We need to parse arguments early to catch --config before loading the config.
# This allows the specified config file to override defaults.
CMD_LINE_CONFIG_FILE=""
declare -a OTHER_ARGS # Array to hold arguments not related to --config
while [[ $# -gt 0 ]]; do
key="$1"
case $key in
--config)
if [[ -z "$2" || "$2" == -* ]]; then # Check if value is missing or looks like another flag
echo -e "${RED}Error: --config option requires a path argument.${NC}" >&2
exit 1
fi
CMD_LINE_CONFIG_FILE="$2"
shift # past argument
shift # past value
;;
*) # unknown option or command
OTHER_ARGS+=("$1") # save it in an array for later
shift # past argument
;;
esac
done
# Restore positional parameters from the filtered arguments
set -- "${OTHER_ARGS[@]}"
# --- Argument Parsing for --config --- END ---
# --- Configuration Override Logic --- START ---
# Priority: --config > BSSG_LCONF > Default (config.sh.local)
FINAL_CONFIG_OVERRIDE=""
if [ -n "$CMD_LINE_CONFIG_FILE" ]; then
# --config flag was used, prioritize it
FINAL_CONFIG_OVERRIDE="$CMD_LINE_CONFIG_FILE"
echo "Info: Using configuration file specified via --config: $FINAL_CONFIG_OVERRIDE"
elif [ -v BSSG_LCONF ] && [ -n "${BSSG_LCONF}" ]; then
# --config not used, check BSSG_LCONF environment variable
FINAL_CONFIG_OVERRIDE="${BSSG_LCONF}"
echo "Info: Using configuration file specified via BSSG_LCONF environment variable: $FINAL_CONFIG_OVERRIDE"
# else
# Neither --config nor BSSG_LCONF is set, config_loader.sh will check for default config.sh.local
# No message needed here, config_loader will print messages.
fi
# --- Configuration Override Logic --- END ---
# Load configuration (DEPRECATED - Moved to config_loader.sh)
# CONFIG_FILE="config.sh"
# if [ -f "$CONFIG_FILE" ]; then
# source "$CONFIG_FILE"
# else
# echo "Error: Configuration file '$CONFIG_FILE' not found"
# exit 1
# fi
# Load local configuration overrides if they exist (DEPRECATED - Moved to config_loader.sh)
# LOCAL_CONFIG_FILE="config.sh.local"
# if [ -f "$LOCAL_CONFIG_FILE" ]; then
# source "$LOCAL_CONFIG_FILE"
# echo "Local configuration loaded from $LOCAL_CONFIG_FILE"
# fi
# --- Centralized Configuration Loading --- START ---
# Source the config loader script EARLY to set defaults, load configs, and expand paths.
# It handles config.sh, config.sh.local, and site-specific configs sourced via core local file.
# It also EXPORTS all necessary variables for subsequent scripts.
# NOW passes the command-line config path, if provided.
# Define path to config loader relative to this script
BSSG_SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )"
export BSSG_SCRIPT_DIR # Export the variable so sub-scripts inherit it
CONFIG_LOADER_SCRIPT="${BSSG_SCRIPT_DIR}/scripts/build/config_loader.sh"
if [ -f "$CONFIG_LOADER_SCRIPT" ]; then
# Pass the determined configuration override path (or empty string) to the loader script
# The loader script will handle sourcing it appropriately.
# shellcheck source=scripts/build/config_loader.sh
source "$CONFIG_LOADER_SCRIPT" "$FINAL_CONFIG_OVERRIDE"
echo "Central configuration loaded via config_loader.sh"
# Note: The echo message regarding the loaded local config is now handled within config_loader.sh
else
echo "Error: Configuration file '$CONFIG_FILE' not found"
echo -e "${RED}Error: Config loader script not found at '$CONFIG_LOADER_SCRIPT'${NC}" >&2
exit 1
fi
# --- Centralized Configuration Loading --- END ---
# Load local configuration overrides if they exist
LOCAL_CONFIG_FILE="config.sh.local"
if [ -f "$LOCAL_CONFIG_FILE" ]; then
source "$LOCAL_CONFIG_FILE"
echo "Local configuration loaded from $LOCAL_CONFIG_FILE"
# 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.
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
# Terminal colors
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[0;33m'
NC='\033[0m' # No Color
# 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.9)"
echo "=================================="
echo "BSSG - Bash Static Site Generator (v${VERSION})"
echo "========================================="
echo ""
echo "Usage: $0 command [options]"
echo "Usage: $0 [--config <path>] command [options]"
echo ""
echo "Global Options:"
echo " --config <path> Specify a custom configuration file. Overrides BSSG_LCONF"
echo " and the default config.sh.local."
echo ""
echo "Environment Variables:"
echo " BSSG_LCONF Path to a configuration file to use if --config is not set."
echo ""
echo "Commands:"
echo " post [-html] [draft_file] Create a new post or continue editing a draft"
echo " Use -html to edit in HTML instead of Markdown"
echo " page [-html] [draft_file] Create a new page or continue editing a draft"
echo " Use -html to edit in HTML instead of Markdown"
echo " edit [-n|-f] <post_file> Edit an existing post"
echo " edit [-n] <post_file> Edit an existing post"
echo " Use -n to give the post a new name if title changes"
echo " Use -f to edit the full HTML file (advanced)"
echo " delete [-f] <post_file> Delete a post"
echo " Use -f to skip confirmation"
echo " list List all posts"
echo " tags [-n] List all tags"
echo " Use -n to sort by number of posts"
echo " drafts List all draft posts"
echo " backup Create a backup of all posts, pages, and config"
echo " backup Create a backup of all posts, pages, drafts, and config"
echo " restore [backup_file|ID] Restore from a backup (all content by default)"
echo " Options: --no-posts, --no-drafts, --no-pages, --no-config"
echo " Options: --no-content, --no-config"
echo " backups List all available backups"
echo " build Build the site"
echo " build [-f] [more...] Build the site (use 'build --help' for all options)"
echo " server [-h] [options] Build & run local server (use 'server --help' for options)"
echo " Options: --port <PORT> (default from config: ${BSSG_SERVER_PORT_DEFAULT:-8000})"
echo " --host <HOST> (default from config: ${BSSG_SERVER_HOST_DEFAULT:-localhost})"
echo " init <target_directory> Initialize a new site in the specified directory"
echo " help Show this help message"
echo ""
echo "For more information, refer to the README.md file."
}
# Function to display help specific to the build command
show_build_help() {
echo "Usage: $0 build [options]"
echo ""
echo "Build Options:"
echo " --src DIR Override Source directory (from config: ${SRC_DIR:-src})"
echo " --pages DIR Override Pages directory (from config: ${PAGES_DIR:-pages})"
echo " --drafts DIR Override Drafts directory (from config: ${DRAFTS_DIR:-drafts})"
echo " --output DIR Override Output directory (from config: ${OUTPUT_DIR:-output})"
echo " --templates DIR Override Templates directory (from config: ${TEMPLATES_DIR:-templates})"
echo " --themes-dir DIR Override Themes parent directory (from config: ${THEMES_DIR:-themes})"
echo " --theme NAME Override Theme to use (from config: ${THEME:-default})"
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"
echo " --author-name NAME Override Author name"
echo " --author-email EMAIL Override Author email"
echo " --posts-per-page NUM Override Posts per page (from config: ${POSTS_PER_PAGE:-10})"
echo " --deploy Force deployment after successful build (overrides config)"
echo " --no-deploy Prevent deployment after build (overrides config)"
echo " --help Display this build-specific help message and exit"
echo ""
echo "Note: These options override settings from configuration files for this build run."
}
# Function to display help specific to the server command
show_server_help() {
echo "Usage: $0 server [options]"
echo ""
echo "Builds the site and starts a local development server."
echo "The SITE_URL will be temporarily overridden to match the server's address during the build."
echo ""
echo "Server Options:"
echo " --port <PORT> Specify the port for the server to listen on."
echo " (Default from config: ${BSSG_SERVER_PORT_DEFAULT:-8000})"
echo " --host <HOST> Specify the host/IP address for the server."
echo " (Default from config: ${BSSG_SERVER_HOST_DEFAULT:-localhost})"
echo " --no-build Skip the build step and start the server with existing"
echo " content in the output directory."
echo " -h, --help Display this help message and exit."
echo ""
}
# Main function
main() {
# Arguments are already parsed and filtered by the time main() is called.
# Positional parameters ($1, $2, etc.) now contain only the command and its specific options.
local command=""
# No arguments provided
# No arguments provided (after potential --config filtering)
if [ $# -eq 0 ]; then
show_help
exit 0
fi
command="$1"
shift
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 "$@"
@ -110,12 +253,222 @@ main() {
scripts/backup.sh list
;;
build)
# Instead of passing individual settings, pass the local config file path
if [ -f "$LOCAL_CONFIG_FILE" ]; then
scripts/build.sh --local-config "$LOCAL_CONFIG_FILE" "$@"
else
scripts/build.sh "$@"
# Call the new build orchestrator script in the build/ directory
# Parse build-specific arguments first and export them as environment variables
echo "Parsing build-specific arguments..."
export CMD_DEPLOY_OVERRIDE="unset" # Reset deploy override for this build command
declare -a build_args=("$@") # Capture args passed to build
set -- "${build_args[@]}" # Set positional params for parsing
while [[ $# -gt 0 ]]; do
case "$1" in
--src)
export SRC_DIR="$2"
shift 2
;;
--pages)
export PAGES_DIR="$2"
shift 2
;;
--drafts)
export DRAFTS_DIR="$2"
shift 2
;;
--output)
export OUTPUT_DIR="$2"
shift 2
;;
--templates)
export TEMPLATES_DIR="$2"
shift 2
;;
--themes-dir)
export THEMES_DIR="$2"
shift 2
;;
--theme)
export THEME="$2"
shift 2
;;
--static)
export STATIC_DIR="$2"
shift 2
;;
--clean-output)
# Handle both flag style (--clean-output) and value style (--clean-output true/false)
if [[ "$2" == "true" || "$2" == "false" ]]; then
export CLEAN_OUTPUT="$2"
shift 2
else
export CLEAN_OUTPUT=true
shift 1
fi
;;
-f|--force-rebuild)
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
;;
--site-url)
export SITE_URL="$2"
shift 2
;;
--site-description)
export SITE_DESCRIPTION="$2"
shift 2
;;
--author-name)
export AUTHOR_NAME="$2"
shift 2
;;
--author-email)
export AUTHOR_EMAIL="$2"
shift 2
;;
--posts-per-page)
export POSTS_PER_PAGE="$2"
shift 2
;;
--deploy)
export CMD_DEPLOY_OVERRIDE="true"
shift 1
;;
--no-deploy)
export CMD_DEPLOY_OVERRIDE="false"
shift 1
;;
--help)
show_build_help
exit 0
;;
*)
echo -e "${RED}Error: Unknown build option: $1${NC}"
show_build_help
exit 1
;;
esac
done
echo "Invoking build process (scripts/build/main.sh)..."
# Execute the main build script. It will inherit the exported variables.
scripts/build/main.sh
;;
server)
# Use defaults from config (via exported BSSG_SERVER_PORT_DEFAULT, BSSG_SERVER_HOST_DEFAULT),
# which can be overridden by CLI options --port and --host for this specific run.
SERVER_CMD_PORT="${BSSG_SERVER_PORT_DEFAULT}"
SERVER_CMD_HOST="${BSSG_SERVER_HOST_DEFAULT}"
PERFORM_BUILD=true
# SERVER_SCRIPT_ARGS=() # Not currently used to pass to server.sh itself beyond port/doc_root
# Parse server-specific arguments
TEMP_ARGS=("$@") # Work with a copy of arguments
set -- "${TEMP_ARGS[@]}" # Set positional parameters for server command parsing
while [[ $# -gt 0 ]]; do
case "$1" in
-h | --help)
show_server_help
exit 0
;;
--port)
if [[ -z "$2" || "$2" == -* ]]; then
echo -e "${RED}Error: --port option requires a numeric argument.${NC}" >&2
exit 1
fi
SERVER_CMD_PORT="$2"
shift 2
;;
--host)
if [[ -z "$2" || "$2" == -* ]]; then
echo -e "${RED}Error: --host option requires a hostname or IP argument.${NC}" >&2
exit 1
fi
SERVER_CMD_HOST="$2"
shift 2
;;
--no-build)
PERFORM_BUILD=false
shift 1
;;
*)
# Collect unrecognized arguments if server.sh were to take more.
# For now, they are ignored or could be passed to build if we design it that way.
echo -e "${YELLOW}Warning: Unrecognized server option: $1${NC}"
shift # Consume unrecognized option
;;
esac
done
# Ensure OUTPUT_DIR is loaded (it should be by config_loader.sh)
if [ -z "${OUTPUT_DIR}" ]; then
echo -e "${RED}Error: OUTPUT_DIR is not set. Configuration issue? Ensure config is loaded.${NC}" >&2
exit 1
fi
# scripts/server.sh will resolve this path to absolute and check if it's a directory.
local effective_output_dir="${OUTPUT_DIR}"
if [ "$PERFORM_BUILD" = true ]; then
echo "Info: Server command will update SITE_URL to http://${SERVER_CMD_HOST}:${SERVER_CMD_PORT} for the build."
export SITE_URL="http://${SERVER_CMD_HOST}:${SERVER_CMD_PORT}" # Override SITE_URL for the build
echo "Info: Initiating build before starting server..."
if [ -f "${BSSG_SCRIPT_DIR}/scripts/build/main.sh" ]; then
# Call the main build script. It will pick up the exported SITE_URL.
# We are not passing any server-specific arguments to the build script directly.
# If build needs arguments, they should be passed via general bssg.sh build options
# or configured in config files.
"${BSSG_SCRIPT_DIR}/scripts/build/main.sh"
BUILD_EXIT_CODE=$?
if [ $BUILD_EXIT_CODE -ne 0 ]; then
echo -e "${RED}Error: Build failed with exit code $BUILD_EXIT_CODE. Server not started.${NC}" >&2
exit $BUILD_EXIT_CODE
fi
echo -e "${GREEN}Build complete.${NC}"
else
echo -e "${RED}Error: Build script (${BSSG_SCRIPT_DIR}/scripts/build/main.sh) not found.${NC}" >&2
exit 1
fi
else
echo "Info: Skipping build step due to --no-build flag."
fi
echo "Info: Starting server on http://${SERVER_CMD_HOST}:${SERVER_CMD_PORT}"
echo "Info: Serving files from ${effective_output_dir}"
# The server script (scripts/server.sh) takes PORT as $1 and WWW_ROOT as $2.
# WWW_ROOT should be the $OUTPUT_DIR from config.
# scripts/server.sh will perform its own validation for the output directory existence and type.
"${BSSG_SCRIPT_DIR}/scripts/server.sh" "$SERVER_CMD_PORT" "$effective_output_dir"
;;
init)
# Check if directory argument is provided
if [ -z "$1" ]; then
echo -e "${RED}Error: Target directory argument is required for the init command.${NC}"
echo -e "Usage: $0 init <target_directory>"
exit 1
fi
scripts/init.sh "$1"
;;
help)
show_help
@ -129,4 +482,5 @@ main() {
}
# Run the main function
main "$@"
# Pass the filtered arguments (command and its options) to main
main "$@"

File diff suppressed because it is too large Load diff

64
scripts/build/assets.sh Executable file
View file

@ -0,0 +1,64 @@
#!/usr/bin/env bash
#
# BSSG - Asset Handling
# Handles copying static assets and processing CSS.
#
# Source dependencies (optional, but good practice if utils are needed)
# shellcheck source=utils.sh disable=SC1091
source "$(dirname "$0")/utils.sh" || { echo >&2 "Error: Failed to source utils.sh from assets.sh"; exit 1; }
copy_static_files() {
# Source utilities if needed for colors (already done above)
if [ -d "$STATIC_DIR" ]; then
echo "Copying static files from $STATIC_DIR to $OUTPUT_DIR..."
# Use rsync for efficiency if available, otherwise cp
if command -v rsync > /dev/null 2>&1; then
# Ensure source ends with / if copying contents
rsync -a --checksum --exclude='.DS_Store' --exclude='._*' "${STATIC_DIR}/" "$OUTPUT_DIR/"
else
# Simple copy (less efficient, might overwrite newer)
# Ensure target exists
mkdir -p "$OUTPUT_DIR"
# Using cp -R instead of -r for better compatibility, -p preserves timestamps
cp -Rp "$STATIC_DIR/." "$OUTPUT_DIR/"
fi
echo -e "${GREEN}Static files copied.${NC}"
else
echo -e "${YELLOW}Static directory '$STATIC_DIR' not found, skipping copy.${NC}"
fi
}
# Create CSS directory and copy theme CSS
create_css() {
local output_dir="$1"
local theme="$2"
local css_dir="${output_dir}/css"
mkdir -p "${css_dir}"
# Check if theme directory exists
local theme_dir="${THEMES_DIR}/${theme}"
if [ ! -d "$theme_dir" ]; then
echo -e "${RED}Error: Theme directory '$theme_dir' (using THEMES_DIR='${THEMES_DIR}') not found.${NC}"
# Decide if this is fatal. For now, just warn and skip CSS copy.
return 1 # Return error code
fi
# Check if style.css exists in the theme directory
if [ ! -f "$theme_dir/style.css" ]; then
echo -e "${RED}Error: style.css not found in theme directory '$theme_dir'.${NC}"
# Decide if this is fatal. For now, just warn and skip CSS copy.
return 1 # Return error code
fi
# Copy the theme CSS file
cp "$theme_dir/style.css" "${css_dir}/style.css"
echo "CSS file copied to ${css_dir}"
}
# Export functions
export -f copy_static_files
export -f create_css

370
scripts/build/cache.sh Executable file
View file

@ -0,0 +1,370 @@
#!/usr/bin/env bash
#
# BSSG - Cache Management Utilities
# Functions for handling build cache and rebuild checks.
#
# Define cache paths (should match exported config, but useful here too)
# CACHE_DIR=".bssg_cache" # Redundant: CACHE_DIR is now set and exported by config_loader.sh
CONFIG_HASH_FILE="${CACHE_DIR}/config_hash.md5" # Use variable directly
# Add other cache-related paths if directly used by functions below
# (Example: $CACHE_DIR/theme.txt, $CACHE_DIR/file_index.txt, etc. are used)
# --- Cache Functions --- START ---
# Create a hash of the current configuration
create_config_hash() {
echo "Generating configuration hash..."
# Dynamically build the config string from BSSG_CONFIG_VARS
# IMPORTANT: Requires BSSG_CONFIG_VARS to be exported from config_loader.sh
local config_string=""
local var_name
local config_vars_array
# Read exported vars into an array
read -r -a config_vars_array <<< "$BSSG_CONFIG_VARS"
for var_name in "${config_vars_array[@]}"; do
# Use printf -v to append safely, ensuring literal newlines
printf -v config_string '%s%s=%s\n' "$config_string" "$var_name" "${!var_name}"
done
# Calculate MD5 hash of the config string
local current_hash
current_hash=$(echo -n "$config_string" | portable_md5sum | awk '{print $1}')
# Check against stored hash before writing
local stored_hash=""
if [ -f "$CONFIG_HASH_FILE" ]; then
stored_hash=$(cat "$CONFIG_HASH_FILE")
fi
# Only write if hash changed or file doesn't exist
if [ "$current_hash" != "$stored_hash" ]; then
echo "$current_hash" > "$CONFIG_HASH_FILE"
echo -e "Configuration hash created/updated: ${GREEN}$current_hash${NC}"
else
echo -e "Configuration hash is up to date: ${GREEN}$current_hash${NC}"
fi
}
# Check if configuration has changed since last build
config_has_changed() {
# If no hash file exists, configuration has effectively changed
if [ ! -f "$CONFIG_HASH_FILE" ]; then
# echo "DEBUG_CACHE: No stored config hash found." >&2
return 0 # True, config has changed
fi
# Dynamically build the config string from BSSG_CONFIG_VARS
# IMPORTANT: Requires BSSG_CONFIG_VARS to be exported from config_loader.sh
local config_string=""
local var_name
local config_vars_array
# Read exported vars into an array
read -r -a config_vars_array <<< "$BSSG_CONFIG_VARS"
for var_name in "${config_vars_array[@]}"; do
# Use printf -v to append safely, ensuring literal newlines
printf -v config_string '%s%s=%s\n' "$config_string" "$var_name" "${!var_name}"
done
# Create current full hash using the portable wrapper
local current_hash
current_hash=$(echo -n "$config_string" | portable_md5sum | awk '{print $1}')
# Read stored hash (which should also be a full hash)
local stored_hash=$(cat "$CONFIG_HASH_FILE")
# Compare hashes
if [ "$current_hash" != "$stored_hash" ]; then
# echo "DEBUG_CACHE: Config hash mismatch. Current='$current_hash' Stored='$stored_hash'" >&2
# DO NOT overwrite the stored hash here. Only check.
# echo "$current_hash" > "$CONFIG_HASH_FILE"
return 0 # True, config has changed
fi
# echo "DEBUG_CACHE: Config hash matches." >&2 # Optional: Log match
return 1 # False, config has not changed
}
# Check if only the theme has changed (not any other config settings)
# NOTE: This function might need adjustment if the dynamic hashing reveals
# other implicit theme-related changes that weren't previously tracked.
# For now, it assumes `config_has_changed` correctly reflects all non-theme changes.
only_theme_changed() {
local theme_cache_file="${CACHE_DIR}/theme.txt"
# If no hash file exists, more than just theme has changed
if [ ! -f "$CONFIG_HASH_FILE" ] || [ ! -f "$theme_cache_file" ]; then
return 1 # False, more than theme has changed
fi
# Read the stored theme
local stored_theme
stored_theme=$(cat "$theme_cache_file")
# Compare current theme with stored theme
if [ "$THEME" != "$stored_theme" ]; then
echo -e "${YELLOW}Theme has changed from $stored_theme to $THEME${NC}"
# Store the current theme for next time
echo "$THEME" > "$theme_cache_file"
# Check if any other config has changed
if ! config_has_changed; then
echo -e "${GREEN}Only theme has changed, will use cache where possible${NC}"
return 0 # True, only theme has changed
fi
fi
return 1 # False, more than theme has changed or theme hasn't changed
}
# Clean stale cache entries
clean_stale_cache() {
# If FORCE_REBUILD is true, delete the entire cache directory and recreate it
if [ "${FORCE_REBUILD:-false}" = true ]; then # Check exported FORCE_REBUILD
echo -e "${YELLOW}Force rebuild enabled, deleting entire cache...${NC}"
rm -rf "$CACHE_DIR"
mkdir -p "$CACHE_DIR/meta"
mkdir -p "$CACHE_DIR/content"
echo -e "${GREEN}Cache deleted!${NC}"
return
fi
echo -e "${YELLOW}Cleaning stale cache entries...${NC}"
# Flag to track if any posts were removed
local posts_removed=false
# Get list of all source files from both src and pages directories
# IMPORTANT: Requires SRC_DIR, PAGES_DIR to be exported/available
local md_files=$(find "${SRC_DIR:-src}" "${PAGES_DIR:-pages}" -type f -name "*.md" 2>/dev/null | sort)
# Get list of all cache meta files
local cache_files=$(find "$CACHE_DIR/meta" -type f 2>/dev/null | sort)
# Convert markdown file paths to basenames for comparison
local md_basenames=""
for file in $md_files; do
md_basenames="$md_basenames$(basename "$file")\n"
done
# Check each cache file
for cache_file in $cache_files; do
local cache_basename=$(basename "$cache_file")
# Check if corresponding markdown file exists
if ! echo -e "$md_basenames" | grep -q "^$cache_basename$"; then
echo -e "Removing stale cache entry for: ${YELLOW}$cache_basename${NC}"
rm -f "$cache_file"
# Also remove the content cache if it exists
if [ -f "$CACHE_DIR/content/$cache_basename" ]; then
rm -f "$CACHE_DIR/content/$cache_basename"
fi
# Mark that posts were removed
posts_removed=true
fi
done
# If any posts were removed, force regeneration of index, tags, archives, etc.
if [ "$posts_removed" = true ]; then
echo -e "${YELLOW}Posts were removed, forcing regeneration of index, tags, archives, sitemap, and RSS feed${NC}"
# Remove marker files to force regeneration
rm -f "${CACHE_DIR}/tags_index.txt"
rm -f "${CACHE_DIR}/archive_index.txt"
rm -f "${CACHE_DIR}/index_marker"
# Remove the tags flag file as well
rm -f "${CACHE_DIR}/has_tags.flag"
# IMPORTANT: Requires OUTPUT_DIR to be exported/available
rm -f "${OUTPUT_DIR:-output}/sitemap.xml"
rm -f "${OUTPUT_DIR:-output}/${RSS_FILENAME:-rss.xml}"
rm -f "${OUTPUT_DIR:-output}/index.html"
# Also remove tag and archive pages to force their regeneration
find "${OUTPUT_DIR:-output}/tags" -name "*.html" -type f -delete 2>/dev/null || true
find "${OUTPUT_DIR:-output}/archives" -name "*.html" -type f -delete 2>/dev/null || true
# Clean related posts cache when posts are removed
if [ -d "${CACHE_DIR}/related_posts" ]; then
echo -e "${YELLOW}Cleaning related posts cache due to post removal...${NC}"
rm -rf "${CACHE_DIR}/related_posts"
fi
fi
echo -e "${GREEN}Cache cleaned!${NC}"
}
# Check if a rebuild is needed based on common conditions
common_rebuild_check() {
local output_file_to_check="$1"
# echo "DEBUG_CACHE: common_rebuild_check called for '$output_file_to_check'" >&2
# Force rebuild if flag is set
if [ "${FORCE_REBUILD:-false}" = true ]; then
# echo "DEBUG_CACHE: Force rebuild flag set, returning 0" >&2
return 0 # Rebuild needed
fi
# Check if configuration has changed using the pre-calculated status
# IMPORTANT: Requires BSSG_CONFIG_CHANGED_STATUS to be exported from main.sh
if [ "${BSSG_CONFIG_CHANGED_STATUS:-1}" -eq 0 ]; then # Default to 1 (not changed) if var unset
# echo "DEBUG_CACHE: BSSG_CONFIG_CHANGED_STATUS=0, returning 0" >&2
return 0 # Rebuild needed
fi
# Check if output file exists. If not, rebuild needed.
# Moved this basic check here for clarity.
if [ ! -f "$output_file_to_check" ]; then
# echo "DEBUG_CACHE: Output file '$output_file_to_check' missing, returning 0" >&2
return 0 # Rebuild needed
fi
# Removed template/locale checks here. They are done in file_needs_rebuild now.
# echo "DEBUG_CACHE: common_rebuild_check returning 1 (passed common checks)" >&2
return 1 # Common checks passed (config ok, not forced, output exists)
}
# Check if a rebuild is needed based on file timestamps and templates
file_needs_rebuild() {
local input_file="$1"
local output_file="$2"
# echo "DEBUG_CACHE: file_needs_rebuild check for Input='$input_file' Output='$output_file'" >&2
# Call the common rebuild check function (checks force, config, output existence)
common_rebuild_check "$output_file"
local common_result=$?
# echo "DEBUG_CACHE: common_rebuild_check returned $common_result for '$output_file'" >&2
# If common conditions already determined we need to rebuild
if [ $common_result -eq 0 ]; then
return 0 # Rebuild needed
fi
# At this point: Not forced, config OK, output file exists.
# Now check against pre-calculated max template/locale time and input file time.
# IMPORTANT: Assumes get_file_mtime is sourced from utils.sh
# IMPORTANT: Requires BSSG_MAX_TEMPLATE_LOCALE_TIME to be exported from main.sh
local output_time
output_time=$(get_file_mtime "$output_file")
# Check if templates/locale are newer than output
# Default to 0 if variable is unset (should not happen if main.sh ran)
if (( ${BSSG_MAX_TEMPLATE_LOCALE_TIME:-0} > output_time )); then
# echo "DEBUG_CACHE: Templates/locale newer than output ($BSSG_MAX_TEMPLATE_LOCALE_TIME > $output_time), returning 0" >&2
return 0 # Rebuild needed
fi
# Check if input file is newer than output file
local input_time
input_time=$(get_file_mtime "$input_file")
if (( input_time > output_time )); then
# echo "DEBUG_CACHE: Input newer than output ($input_time > $output_time), returning 0" >&2
return 0 # Rebuild needed
fi
# echo "DEBUG_CACHE: file_needs_rebuild returning 1 (no rebuild) for '$output_file'" >&2
return 1 # No rebuild needed
}
# Check if tags or indexes need rebuilding
indexes_need_rebuild() {
# Check common rebuild conditions for the main index file
# IMPORTANT: Requires OUTPUT_DIR to be exported/available
local main_index="${OUTPUT_DIR:-output}/index.html"
# Call the common rebuild check function
common_rebuild_check "$main_index"
local common_result=$?
# If common conditions already determined we need to rebuild
if [ $common_result -eq 0 ]; then
return 0 # Rebuild needed
fi
# Check if any of the index files exist and are up to date
local index_files=(
"${OUTPUT_DIR:-output}/tags/index.html"
"${OUTPUT_DIR:-output}/archives/index.html"
"${OUTPUT_DIR:-output}/index.html"
"${OUTPUT_DIR:-output}/rss.xml"
"${OUTPUT_DIR:-output}/sitemap.xml"
)
# Get the latest template/locale time (using main index as baseline)
# IMPORTANT: Assumes get_file_mtime is sourced/available
local latest_base_time
latest_base_time=$(get_file_mtime "$main_index")
# Check if file_index.txt exists and is newer than the baseline
local file_index="$CACHE_DIR/file_index.txt"
if [ -f "$file_index" ]; then
local file_index_time
file_index_time=$(get_file_mtime "$file_index")
if (( file_index_time > latest_base_time )); then
latest_base_time=$file_index_time
echo -e "${YELLOW}Source file list change detected, indexes need rebuild${NC}"
fi
fi
# Check if frontmatter_changes_marker exists and is newer than baseline
local frontmatter_changes_marker="$CACHE_DIR/frontmatter_changes_marker"
if [ -f "$frontmatter_changes_marker" ]; then
local marker_time
marker_time=$(get_file_mtime "$frontmatter_changes_marker")
if (( marker_time > latest_base_time )); then
latest_base_time=$marker_time
echo -e "${YELLOW}Frontmatter changes detected, indexes need rebuild${NC}"
fi
fi
# Also check if metadata cache has changed (more robust than marker)
local meta_cache_dir="$CACHE_DIR/meta"
if [ -d "$meta_cache_dir" ]; then
local newest_meta_time=0
# Use find with -printf for efficiency if available
if find --version >/dev/null 2>&1 && grep -q GNU <<< "$(find --version)"; then
newest_meta_time=$(find "$meta_cache_dir" -type f -printf '%T@\n' 2>/dev/null | sort -nr | head -n 1)
newest_meta_time=${newest_meta_time:-0} # Handle empty dir
# Convert float timestamp to integer
newest_meta_time=${newest_meta_time%.*} # Truncate to integer
else
# Fallback for non-GNU find (less efficient)
local meta_files
meta_files=$(find "$meta_cache_dir" -type f 2>/dev/null)
for meta_file in $meta_files; do
local meta_time
meta_time=$(get_file_mtime "$meta_file")
if (( meta_time > newest_meta_time )); then
newest_meta_time=$meta_time
fi
done
fi
if (( newest_meta_time > latest_base_time )); then
latest_base_time=$newest_meta_time
echo -e "${YELLOW}Metadata cache change detected, indexes need rebuild${NC}"
fi
fi
# Check if any index file is missing or older than the determined latest relevant time
for index_file in "${index_files[@]}"; do
# Skip check for archive index if archives disabled
# IMPORTANT: Requires ENABLE_ARCHIVES to be exported/available
if [[ "$index_file" == *"archives/index.html"* ]] && [ "${ENABLE_ARCHIVES:-true}" != true ]; then
continue
fi
# IMPORTANT: Assumes get_file_mtime is sourced/available
local index_file_time
index_file_time=$(get_file_mtime "$index_file")
if [ ! -f "$index_file" ] || (( index_file_time < latest_base_time )); then
return 0 # Rebuild needed
fi
done
return 1 # No rebuild needed
}
# --- Cache Functions --- END ---

202
scripts/build/cli.sh Executable file
View file

@ -0,0 +1,202 @@
#!/usr/bin/env bash
#
# BSSG - Command Line Interface Handling
# Functions for parsing arguments and showing help.
#
# Parse command line arguments
parse_args() {
# Initialize deploy override flag
CMD_DEPLOY_OVERRIDE="unset"
while [[ $# -gt 0 ]]; do
case "$1" in
--config)
# Save current settings that may have come from local config
local saved_theme="$THEME"
local saved_site_title="$SITE_TITLE"
local saved_site_url="$SITE_URL"
local saved_site_description="$SITE_DESCRIPTION"
local saved_author_name="$AUTHOR_NAME"
local saved_author_email="$AUTHOR_EMAIL"
local saved_clean_output="$CLEAN_OUTPUT"
# Load the specified config file
CONFIG_FILE="$2"
if [ -f "$CONFIG_FILE" ]; then
# Reset to defaults before loading new config
THEME="default"
SITE_TITLE="My Journal"
SITE_DESCRIPTION="A personal journal and introspective newspaper"
SITE_URL="http://localhost"
AUTHOR_NAME="Anonymous"
AUTHOR_EMAIL="anonymous@example.com"
CLEAN_OUTPUT=false
# Load new config
source "$CONFIG_FILE"
echo -e "${GREEN}Configuration loaded from $CONFIG_FILE${NC}"
# Load local configuration if it exists
local local_config="${CONFIG_FILE}.local"
if [ -f "$local_config" ]; then
source "$local_config"
echo -e "${GREEN}Local configuration loaded from $local_config${NC}"
else
# If new local config doesn't exist, restore settings from previous local config
# but only if they weren't set in the new config file
if [ "$THEME" = "default" ] && [ "$saved_theme" != "default" ]; then
THEME="$saved_theme"
fi
if [ "$SITE_TITLE" = "My Journal" ] && [ "$saved_site_title" != "My Journal" ]; then
SITE_TITLE="$saved_site_title"
fi
if [ "$SITE_URL" = "http://localhost" ] && [ "$saved_site_url" != "http://localhost" ]; then
SITE_URL="$saved_site_url"
fi
if [ "$AUTHOR_NAME" = "Anonymous" ] && [ "$saved_author_name" != "Anonymous" ]; then
AUTHOR_NAME="$saved_author_name"
fi
if [ "$AUTHOR_EMAIL" = "anonymous@example.com" ] && [ "$saved_author_email" != "anonymous@example.com" ]; then
AUTHOR_EMAIL="$saved_author_email"
fi
if [ "$CLEAN_OUTPUT" = false ] && [ "$saved_clean_output" != false ]; then
CLEAN_OUTPUT="$saved_clean_output"
fi
fi
else
echo -e "${RED}Error: Configuration file '$CONFIG_FILE' not found${NC}"
exit 1
fi
shift 2
;;
--src)
SRC_DIR="$2"
shift 2
;;
--pages)
PAGES_DIR="$2"
shift 2
;;
--drafts)
DRAFTS_DIR="$2"
shift 2
;;
--output)
OUTPUT_DIR="$2"
shift 2
;;
--templates)
TEMPLATES_DIR="$2"
shift 2
;;
--themes-dir)
THEMES_DIR="$2"
shift 2
;;
--theme)
THEME="$2"
shift 2
;;
--static)
STATIC_DIR="$2"
shift 2
;;
--clean-output)
# Handle both flag style (--clean-output) and value style (--clean-output true/false)
if [[ "$2" == "true" || "$2" == "false" ]]; then
CLEAN_OUTPUT="$2"
shift 2
else
CLEAN_OUTPUT=true
shift 1
fi
;;
--force-rebuild)
FORCE_REBUILD=true
shift 1
;;
--site-title)
SITE_TITLE="$2"
shift 2
;;
--site-url)
SITE_URL="$2"
shift 2
;;
--site-description)
SITE_DESCRIPTION="$2"
shift 2
;;
--author-name)
AUTHOR_NAME="$2"
shift 2
;;
--author-email)
AUTHOR_EMAIL="$2"
shift 2
;;
--posts-per-page)
POSTS_PER_PAGE="$2"
shift 2
;;
--local-config)
# Load the local config file directly
if [ -f "$2" ]; then
source "$2"
echo -e "${GREEN}Local configuration loaded from $2${NC}"
else
echo -e "${YELLOW}Warning: Local config file $2 not found${NC}"
fi
shift 2
;;
--deploy)
CMD_DEPLOY_OVERRIDE="true"
shift 1
;;
--no-deploy)
CMD_DEPLOY_OVERRIDE="false"
shift 1
;;
--help)
show_help
exit 0
;;
*)
echo -e "${RED}Unknown option: $1${NC}"
show_help
exit 1
;;
esac
done
}
# Display help information
show_help() {
echo "BSSG - Bash Static Site Generator"
echo ""
echo "Usage: $0 [options]"
echo ""
echo "Options:"
echo " --config FILE Configuration file (default: config.sh)"
echo " --src DIR Source directory containing markdown files (default: src)"
echo " --pages DIR Pages directory containing markdown/html files (default: pages)"
echo " --drafts DIR Drafts directory (default: drafts)"
echo " --output DIR Output directory for the generated site (default: output)"
echo " --templates DIR Templates directory (default: templates)"
echo " --themes-dir DIR Themes parent directory (default: themes)"
echo " --theme NAME Theme to use (default: default)"
echo " --static DIR Static directory (default: static)"
echo " --clean-output Clean output directory before building (default: false)"
echo " --force-rebuild Force rebuild of all files regardless of modification time"
echo " --site-title TITLE Site title (default: My Journal)"
echo " --site-url URL Site URL (default: http://localhost)"
echo " --site-description DESC Site description (default: A personal journal)"
echo " --author-name NAME Author name (default: Anonymous)"
echo " --author-email EMAIL Author email (default: anonymous@example.com)"
echo " --posts-per-page NUM Posts per page (default: 10)"
echo " --local-config FILE Load local configuration file directly"
echo " --deploy Force deployment after successful build (overrides config)"
echo " --no-deploy Prevent deployment after build (overrides config)"
echo " --help Display this help message and exit"
}

424
scripts/build/config_loader.sh Executable file
View file

@ -0,0 +1,424 @@
#!/usr/bin/env bash
#
# BSSG - Configuration Loader
# Sets default variables, loads user config and locale files, and exports them.
#
# Capture the command-line config file path, if provided by the main script.
CMD_LINE_CONFIG_FILE="$1"
shift || true # Shift arguments even if only one was passed (or none)
# --- Default Configuration Variables --- START ---
# Use :- syntax to only set defaults if the variable is unset or null.
# This allows values set by CLI parsing (before this script is sourced) to persist.
CONFIG_FILE="${CONFIG_FILE:-config.sh}"
SRC_DIR="${SRC_DIR:-src}"
OUTPUT_DIR="${OUTPUT_DIR:-output}"
TEMPLATES_DIR="${TEMPLATES_DIR:-templates}"
THEMES_DIR="${THEMES_DIR:-themes}"
STATIC_DIR="${STATIC_DIR:-static}"
CACHE_DIR="${CACHE_DIR:-.bssg_cache}" # Default cache directory
THEME="${THEME:-default}"
SITE_TITLE="${SITE_TITLE:-My Journal}"
SITE_DESCRIPTION="${SITE_DESCRIPTION:-A personal journal and introspective newspaper}"
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}"
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}"
MARKDOWN_PROCESSOR="${MARKDOWN_PROCESSOR:-pandoc}"
MARKDOWN_PL_PATH="${MARKDOWN_PL_PATH:-}"
ENABLE_ARCHIVES="${ENABLE_ARCHIVES:-true}"
URL_SLUG_FORMAT="${URL_SLUG_FORMAT:-Year/Month/Day/slug}"
PAGE_URL_FORMAT="${PAGE_URL_FORMAT:-slug}"
ENABLE_TAG_RSS="${ENABLE_TAG_RSS:-false}" # Generate RSS feed for each tag
ENABLE_AUTHOR_PAGES="${ENABLE_AUTHOR_PAGES:-true}" # Generate author index pages
ENABLE_AUTHOR_RSS="${ENABLE_AUTHOR_RSS:-false}" # Generate RSS feed for each author
SHOW_AUTHORS_MENU_THRESHOLD="${SHOW_AUTHORS_MENU_THRESHOLD:-2}" # Minimum authors to show menu
# Related Posts Configuration Defaults
ENABLE_RELATED_POSTS="${ENABLE_RELATED_POSTS:-true}" # Enable or disable related posts feature
RELATED_POSTS_COUNT="${RELATED_POSTS_COUNT:-3}" # Number of related posts to show
# Display Configuration Defaults
SHOW_HEADER_MENU="${SHOW_HEADER_MENU:-true}"
SHOW_INDEX_DESCRIPTIONS="${SHOW_INDEX_DESCRIPTIONS:-true}"
GENERATE_EXCERPT="${GENERATE_EXCERPT:-true}"
SHOW_READING_TIME="${SHOW_READING_TIME:-true}"
# --- Backup Directory --- Added ---
BACKUP_DIR="${BACKUP_DIR:-backup}" # Default backup location
# --- Server Defaults --- Added for 'bssg.sh server' ---
BSSG_SERVER_PORT_DEFAULT="${BSSG_SERVER_PORT_DEFAULT:-8000}"
BSSG_SERVER_HOST_DEFAULT="${BSSG_SERVER_HOST_DEFAULT:-localhost}"
# Customization Defaults
CUSTOM_CSS="${CUSTOM_CSS:-}" # Default to empty string
# Define default colors here so utils.sh can use them if not overridden by config
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 ---
# --- Source Utilities --- START ---
# Source utility functions (like print_info, print_error) needed by this script.
# Determine the directory of this script
CONFIG_LOADER_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )"
UTILS_SCRIPT="${CONFIG_LOADER_DIR}/utils.sh"
if [ -f "$UTILS_SCRIPT" ]; then
# shellcheck source=utils.sh
source "$UTILS_SCRIPT"
if ! declare -F print_success > /dev/null; then
echo "Error: Failed to source utils.sh correctly - 'print_success' function not found." >&2
exit 1
fi
else
# Define basic color functions as fallback if utils.sh is missing
# Needed for messages printed *before* utils.sh is sourced, or if it fails.
if [[ -t 1 ]] && [[ -z $NO_COLOR ]]; then
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[0;33m'
NC='\033[0m' # No Color
else
RED=""
GREEN=""
YELLOW=""
NC=""
fi
print_error() { echo -e "${RED}Error: $1${NC}" >&2; }
print_warning() { echo -e "${YELLOW}Warning: $1${NC}"; }
print_success() { echo -e "${GREEN}$1${NC}"; }
print_info() { echo "Info: $1"; }
# Print the critical error and exit
print_error "Utilities script not found at '$UTILS_SCRIPT'. Required by config_loader.sh."
exit 1
fi
# --- Source Utilities --- END ---
# --- Configuration and Locale Sourcing Logic --- START ---
# Load main configuration file (using variable potentially set by CLI)
# If CONFIG_FILE wasn't exported by main.sh before sourcing this, it will use the default set above.
if [ -f "$CONFIG_FILE" ]; then
# shellcheck source=/dev/null disable=SC1090,SC1091
source "$CONFIG_FILE"
print_success "Default configuration loaded from $CONFIG_FILE"
else
print_warning "Default configuration file '$CONFIG_FILE' not found, using defaults."
fi
# Now, handle the override configuration file
# Prioritize the --config command line argument if provided
if [ -n "$CMD_LINE_CONFIG_FILE" ]; then
if [ -f "$CMD_LINE_CONFIG_FILE" ]; then
# shellcheck source=/dev/null disable=SC1090,SC1091
source "$CMD_LINE_CONFIG_FILE"
print_success "Command-line configuration loaded from ${CMD_LINE_CONFIG_FILE}"
else
print_error "Specified configuration file '${CMD_LINE_CONFIG_FILE}' not found."
exit 1
fi
else
# If --config was not used, check for the default local override file
LOCAL_CONFIG_OVERRIDE="${CONFIG_FILE}.local"
if [ -f "$LOCAL_CONFIG_OVERRIDE" ]; then
# shellcheck source=/dev/null disable=SC1090,SC1091
source "$LOCAL_CONFIG_OVERRIDE"
print_success "Local configuration loaded from ${LOCAL_CONFIG_OVERRIDE}"
# else
# No local config file found, which is normal. No message needed.
fi
fi
# ---- Start Locale Loading ----
# Function to print error messages in red (specific to locale loading)
# print_error() {
# echo -e "${RED}Error: $1${NC}" >&2
# }
# Set the path for the locale file based on SITE_LANG
LOCALE_FILE="${LOCALE_DIR}/${SITE_LANG}.sh"
DEFAULT_LOCALE_FILE="${LOCALE_DIR}/en.sh"
# Check if the specific locale file exists
if [ -f "$LOCALE_FILE" ]; then
print_info "Loading locale: ${SITE_LANG} from ${LOCALE_FILE}"
# shellcheck source=/dev/null disable=SC1090
. "$LOCALE_FILE"
elif [ -f "$DEFAULT_LOCALE_FILE" ]; then
print_warning "Locale file '${LOCALE_FILE}' not found. Defaulting to English."
print_info "Loading locale: en from ${DEFAULT_LOCALE_FILE}"
# shellcheck source=/dev/null disable=SC1090
. "$DEFAULT_LOCALE_FILE"
else
print_error "Default locale file '${DEFAULT_LOCALE_FILE}' not found."
print_error "Please ensure '${LOCALE_DIR}/en.sh' exists."
exit 1
fi
# ---- End Locale Loading ----
# --- Configuration and Locale Sourcing Logic --- END ---
# --- Define Local Config File Path --- START ---
# Define this *after* main config and local override sourcing, in case CONFIG_FILE was changed.
# Note: LOCAL_CONFIG_OVERRIDE used during sourcing might differ if CONFIG_FILE changed mid-script,
# but we export the path based on the *final* CONFIG_FILE value.
LOCAL_CONFIG_FILE="${CONFIG_FILE}.local"
export LOCAL_CONFIG_FILE # Export it for other scripts
# --- Define Local Config File Path --- END ---
# --- Expand Tilde in Path Variables --- START ---
# After all configs are sourced, expand ~ in relevant paths before exporting.
# This ensures scripts use the resolved paths, even if config stores portable '~'.
print_info "Expanding tilde (~) in configuration paths..."
PATHS_TO_EXPAND=("SRC_DIR" "PAGES_DIR" "DRAFTS_DIR" "OUTPUT_DIR" "TEMPLATES_DIR" "THEMES_DIR" "STATIC_DIR" "BACKUP_DIR" "CACHE_DIR") # Added CACHE_DIR
for var_name in "${PATHS_TO_EXPAND[@]}"; do
# Get the current value using indirect reference
current_value="${!var_name}"
expanded_value=""
# Check if it starts with ~ or ~/
if [[ "$current_value" == "~" ]]; then
expanded_value="$HOME"
elif [[ "$current_value" == "~/"* ]]; then
# Replace ~/ with $HOME/
expanded_value="$HOME/${current_value#\~/}"
fi
# If expansion occurred, update the variable in the current shell using printf -v
if [ -n "$expanded_value" ]; then
printf -v "$var_name" '%s' "$expanded_value"
# echo "Expanded $var_name to: ${!var_name}" # Debugging
fi
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 ---
# --- Read Version from root VERSION file --- START ---
# BSSG root is two levels up from config_loader.sh (scripts/build -> scripts -> root)
BSSG_ROOT="$( cd "${CONFIG_LOADER_DIR}/../.." &> /dev/null && pwd )"
if [ -f "${BSSG_ROOT}/VERSION" ]; then
VERSION="$(cat "${BSSG_ROOT}/VERSION")"
else
VERSION="dev"
fi
export VERSION
# --- Read Version from root VERSION file --- END ---
# --- Export All Variables --- START ---
# Define the list of configuration variables relevant for hashing/exporting
# Ensure this list includes ALL variables that could be set in config.sh or config.sh.local
# 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 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
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
ARCHIVES_LIST_ALL_POSTS
ENABLE_RELATED_POSTS RELATED_POSTS_COUNT
PRECOMPRESS_ASSETS
SHOW_HEADER_MENU SHOW_INDEX_DESCRIPTIONS GENERATE_EXCERPT SHOW_READING_TIME
# Add any other custom config variables here if needed
BSSG_SERVER_PORT_DEFAULT BSSG_SERVER_HOST_DEFAULT # Server defaults
)
# Convert array to space-separated string for export
BSSG_CONFIG_VARS="${BSSG_CONFIG_VARS_ARRAY[@]}"
export BSSG_CONFIG_VARS
# Export all config variables individually as well, for direct use by scripts
# The values exported here will be the potentially tilde-expanded ones.
export CONFIG_FILE
export SRC_DIR
export OUTPUT_DIR
export TEMPLATES_DIR
export THEMES_DIR
export STATIC_DIR
export THEME
export SITE_TITLE
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
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
export MARKDOWN_PROCESSOR
export MARKDOWN_PL_PATH
export ENABLE_ARCHIVES
export URL_SLUG_FORMAT
export PAGE_URL_FORMAT
export DRAFTS_DIR
export REBUILD_AFTER_POST
export REBUILD_AFTER_EDIT
export CUSTOM_CSS
export ENABLE_TAG_RSS
export ENABLE_AUTHOR_PAGES
export ENABLE_AUTHOR_RSS
export SHOW_AUTHORS_MENU_THRESHOLD
export BACKUP_DIR
export CACHE_DIR
export DEPLOY_AFTER_BUILD
export DEPLOY_SCRIPT
export ARCHIVES_LIST_ALL_POSTS
export ENABLE_RELATED_POSTS
export RELATED_POSTS_COUNT
export PRECOMPRESS_ASSETS
export SHOW_HEADER_MENU
export SHOW_INDEX_DESCRIPTIONS
export GENERATE_EXCERPT
export SHOW_READING_TIME
# Server defaults export
export BSSG_SERVER_PORT_DEFAULT
export BSSG_SERVER_HOST_DEFAULT
# Export colors too, as they might be customized in config and needed by scripts
export RED GREEN YELLOW BLUE NC
# Export ALL MSG_* locale variables explicitly
# These are generally NOT included in BSSG_CONFIG_VARS as they don't affect the config hash directly,
# but changes to the locale *file* itself are checked by common_rebuild_check in cache.sh.
export MSG_HOME MSG_TAGS MSG_ARCHIVES MSG_RSS MSG_PAGES
export MSG_PUBLISHED_ON MSG_READING_TIME_TEMPLATE MSG_UPDATED_ON
export MSG_PREVIOUS_POST MSG_NEXT_POST
export MSG_TAG_PAGE_TITLE MSG_ARCHIVE_PAGE_TITLE
export MSG_POSTS_TAGGED_WITH MSG_POSTS_IN_ARCHIVE
export MSG_NO_POSTS_FOUND
export MSG_MINUTE MSG_MINUTES
# Exports needed by generate_index.sh (especially for parallel)
export MSG_LATEST_POSTS MSG_BY MSG_PAGINATION_TITLE MSG_PAGE_INFO_TEMPLATE
export MSG_MONTH_01 MSG_MONTH_02 MSG_MONTH_03 MSG_MONTH_04
export MSG_MONTH_05 MSG_MONTH_06 MSG_MONTH_07 MSG_MONTH_08
export MSG_MONTH_09 MSG_MONTH_10 MSG_MONTH_11 MSG_MONTH_12
# Fallback using compgen (use with caution, might export unintended vars)
# compgen -v MSG_ | while read -r var; do export "$var"; done
# --- Export All Variables --- END ---
# --- 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 ---

431
scripts/build/content.sh Executable file
View file

@ -0,0 +1,431 @@
#!/usr/bin/env bash
#
# BSSG - Content Processing Utilities
# Functions for parsing metadata, generating excerpts, and converting markdown.
#
# Source Utilities if needed by functions below
# shellcheck source=utils.sh disable=SC1091
source "$(dirname "$0")/utils.sh" || { echo >&2 "Error: Failed to source utils.sh from content.sh"; exit 1; }
# --- Content Functions --- START ---
# Parse metadata from a markdown file (uses cache)
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")"
# Get locks for cache access
# IMPORTANT: Assumes lock_file/unlock_file are sourced/available
lock_file "$cache_file"
# Create metadata cache if it doesn't exist or is older than source
if [ ! -f "$cache_file" ] || [ "$file" -nt "$cache_file" ]; then
# Use grep -n and sed to extract frontmatter block efficiently
local frontmatter_lines
frontmatter_lines=$(grep -n "^---$" "$file" | cut -d: -f1)
local start_line=$(echo "$frontmatter_lines" | head -n 1)
local end_line=$(echo "$frontmatter_lines" | head -n 2 | tail -n 1)
# Check if valid start and end lines were found
if [[ -n "$start_line" && -n "$end_line" && $start_line -lt $end_line ]]; then
# Extract frontmatter, remove leading/trailing whitespace, and save to cache
sed -n "$((start_line+1)),$((end_line-1))p" "$file" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//' > "$cache_file"
else
# No valid frontmatter found, create empty cache file
> "$cache_file"
fi
fi
# Read from cache if it exists
if [ -f "$cache_file" ]; then
# Use grep -m 1 for efficiency
value=$(grep -m 1 "^$field:[[:space:]]*" "$cache_file" | cut -d ':' -f 2- | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
fi
# Release lock
unlock_file "$cache_file"
# Fallback to direct file read ONLY if cache read failed (should be rare)
if [ -z "$value" ]; then
local frontmatter_lines
frontmatter_lines=$(grep -n "^---$" "$file" | cut -d: -f1)
local start_line=$(echo "$frontmatter_lines" | head -n 1)
local end_line=$(echo "$frontmatter_lines" | head -n 2 | tail -n 1)
if [[ -n "$start_line" && -n "$end_line" && $start_line -lt $end_line ]]; then
value=$(sed -n "$((start_line+1)),$((end_line-1))p" "$file" | grep -m 1 "^$field:[[:space:]]*" | cut -d ':' -f 2- | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
fi
fi
echo "$value"
}
# 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 ! $ram_mode_active && [ ! -f "$file" ]; then
echo "ERROR_FILE_NOT_FOUND"
return 1
fi
# Flag to track whether frontmatter has changed
local frontmatter_changed=false
# Check if cache exists and is newer than the source file
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
else
# If we're regenerating metadata, assume it changed for index rebuilding purposes
frontmatter_changed=true
fi
# If we're here, we need to parse the file
local title="" date="" lastmod="" tags="" slug="" image="" image_caption="" description="" author_name="" author_email=""
# Check file type and parse accordingly
if [[ "$file" == *.html ]]; then
# 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.
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>[^<]*</title>' 2>/dev/null | sed -e 's/<title>//' -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>[^<]*</title>' "$file" 2>/dev/null | sed -e 's/<title>//' -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 a shared awk parser for both disk and RAM paths.
local parsed_data
local awk_frontmatter_parser
awk_frontmatter_parser=$(cat <<'EOF'
BEGIN {
in_fm = 0;
found_fm = 0;
# Define default empty values
vars["title"] = ""; vars["date"] = ""; vars["lastmod"] = "";
vars["tags"] = ""; vars["slug"] = ""; vars["image"] = "";
vars["image_caption"] = ""; vars["description"] = "";
vars["author_name"] = ""; vars["author_email"] = "";
}
/^---$/ {
if (!in_fm && !found_fm) { in_fm = 1; found_fm = 1; next; }
if (in_fm) { in_fm = 0; exit; } # Exit awk early after frontmatter
}
in_fm {
# Match key: value, trim whitespace
local key value
if (match($0, /^([^:]+):[[:space:]]*(.*[^[:space:]])[[:space:]]*$/)) {
key = substr($0, RSTART, RLENGTH);
# Extract key part
match(key, /^[^:]+/);
key_str = substr(key, RSTART, RLENGTH);
# Extract value part
match(key, /:[[:space:]]*(.*)$/);
value = substr(key, RSTART + 1, RLENGTH -1 ); # +1/-1 to skip the :
# Trim spaces from key and value
gsub(/^[[:space:]]+|[[:space:]]+$/, "", key_str);
gsub(/^[[:space:]]+|[[:space:]]+$/, "", value);
key = tolower(key_str);
# Handle quoted strings (optional, basic handling)
if ( (match(value, /^"(.*)"$/) || match(value, /^\'(.*)\'$/)) && length(value) > 1 ) {
value = substr(value, 2, length(value)-2);
}
vars[key] = value;
}
}
END {
# Print values in specific order
print vars["title"] "|" vars["date"] "|" vars["lastmod"] "|" \
vars["tags"] "|" vars["slug"] "|" vars["image"] "|" \
vars["image_caption"] "|" vars["description"] "|" \
vars["author_name"] "|" vars["author_email"];
}
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"
else
echo "Warning: Unknown file type '$file' for metadata extraction." >&2
fi
# Fallbacks for missing metadata
if [ -z "$title" ]; then
title=$(basename "$file" | sed 's/\\\\.[^.]*$//')
fi
if [ -z "$date" ]; then
local file_mtime=$(get_file_mtime "$file")
date=$(format_date_from_timestamp "$file_mtime")
fi
# Fallback for lastmod: use date if lastmod is empty
if [ -z "$lastmod" ]; then
lastmod="$date"
fi
if [ -z "$slug" ]; then
slug=$(generate_slug "$title")
else
slug=$(generate_slug "$slug")
fi
if [ -z "$description" ] && [ "${GENERATE_EXCERPT:-true}" = "true" ]; then
# Generate excerpt only if description is missing
# The excerpt is already sanitized and HTML-escaped plain text
echo "[DEBUG] Generating excerpt for $file" >&2
description=$(generate_excerpt "$file")
fi
# Apply fallback logic for author fields
if [ -z "$author_name" ]; then
author_name="${AUTHOR_NAME:-Anonymous}"
fi
if [ -z "$author_email" ] && [ -n "$author_name" ] && [ "$author_name" = "${AUTHOR_NAME:-Anonymous}" ]; then
# Only use default email if using default name
author_email="${AUTHOR_EMAIL:-anonymous@example.com}"
fi
# If author_name is specified but author_email is empty, leave email empty
# Construct the metadata string for comparison and caching
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 ! $ram_mode_active && [ -f "$metadata_cache_file" ]; then
local old_metadata=$(cat "$metadata_cache_file")
if [ "$old_metadata" != "$new_metadata" ]; then
frontmatter_changed=true
fi
fi
# Store all metadata in one write operation
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 ! $ram_mode_active && $frontmatter_changed; then
touch "$frontmatter_changes_marker"
fi
# Return the metadata as pipe-separated values
echo "$new_metadata"
}
# Generate an excerpt from post content
generate_excerpt() {
local file="$1"
local max_length="${2:-160}" # Default to 160 characters
local raw_content_stream
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
# 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
# Apply sanitization steps sequentially
local sanitized_content
sanitized_content=$(echo "$raw_content_stream" | \
# Remove code blocks (``` and indented)
awk '/^```/{flag=!flag;next} !flag;' | grep -v '^```' | \
grep -v '^ ' | \
# Remove images, links, headings, hr, blockquotes
sed -E 's/!\[([^]]*)\]\([^)]*\)//g' | \
sed -E 's/\[([^]]+)\]\(([^)]+)\)/\1/g' | \
sed 's/^#\{1,6\} //' | \
grep -v '^---\+$' | \
grep -v '^\*\*\*\+$' | \
grep -v '^___\+$' | \
sed 's/^> //' | \
# Remove list markers
sed -E 's/^\* |^- |^[0-9]+\. //' | \
# Remove inline markdown: bold, italics, strikethrough, code
sed -E 's/\*\*([^*]+)\*\*/\1/g; s/__([^_]+)__/\1/g' | \
sed -E 's/\*([^*]+)\*/\1/g; s/_([^_]+)_/\1/g' | \
sed -E 's/~~([^~]+)~~/\1/g' | \
sed -E 's/`([^`]+)`/\1/g' | \
# Remove HTML tags
sed -E 's/<[^>]*>//g' | \
# Escape basic HTML entities (ampersand, less than, greater than)
sed 's/&/\&amp;/g; s/</\&lt;/g; s/>/\&gt;/g' | \
# Remove extra blank lines
awk 'NF {p=1} p' | \
# Get the first non-empty line (first paragraph)
awk 'NF {print; exit}'
)
# Truncate to max length using dd for portability
local excerpt
if [ -z "$sanitized_content" ]; then
excerpt=""
else
# Use dd: bs=1 reads byte by byte, count limits the total bytes
# 2>/dev/null suppresses dd's status messages
excerpt=$(echo "$sanitized_content" | dd bs=1 count="$max_length" 2>/dev/null)
fi
# Add ellipsis if truncated
if [ ${#sanitized_content} -gt $max_length ]; then
excerpt+="..."
fi
# Ensure description is not empty after all this
if [ -z "$excerpt" ]; then
# Fallback: use the filename if excerpt is still empty
excerpt=$(basename "$file" | sed 's/\.[^.]*$//')
fi
echo "$excerpt"
}
# Convert provided markdown content string to HTML
convert_markdown_to_html() {
local content="$1" # Expect markdown content as the first argument
local html_content=""
# IMPORTANT: Assumes MARKDOWN_PROCESSOR, MARKDOWN_PL_PATH are exported/available
# IMPORTANT: Assumes required processor (pandoc, cmark, perl) is installed
if [ "${MARKDOWN_PROCESSOR:-pandoc}" = "pandoc" ]; then
if ! html_content=$(echo "$content" | pandoc -f markdown -t html); then
echo -e "${RED}Error: Markdown conversion failed using pandoc.${NC}" >&2
return 1
fi
elif [ "$MARKDOWN_PROCESSOR" = "commonmark" ]; then
if ! html_content=$(echo "$content" | cmark); then
echo -e "${RED}Error: Markdown conversion failed using cmark.${NC}" >&2
return 1
fi
elif [ "$MARKDOWN_PROCESSOR" = "markdown.pl" ]; then
# Preprocess content to handle fenced code blocks for markdown.pl
local preprocessed_content="$content"
# Handle fenced code blocks (``` and ~~~) -> indented
# Requires awk
if command -v awk &> /dev/null; then
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; } }
')
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="$content"
fi
# Ensure MARKDOWN_PL_PATH is set and executable
if [ -z "$MARKDOWN_PL_PATH" ] || [ ! -x "$MARKDOWN_PL_PATH" ]; then
echo -e "${RED}Error: MARKDOWN_PL_PATH ('$MARKDOWN_PL_PATH') not set or not executable.${NC}" >&2
return 1
fi
# Use printf to pipe content to avoid issues with content starting with -
if ! html_content=$(printf '%s' "$preprocessed_content" | perl "$MARKDOWN_PL_PATH"); then
echo -e "${RED}Error: Markdown conversion failed using markdown.pl.${NC}" >&2
return 1
fi
else
echo -e "${RED}Error: Unknown MARKDOWN_PROCESSOR ('$MARKDOWN_PROCESSOR'). Cannot convert content.${NC}" >&2
return 1
fi
echo "$html_content" # Output the result
return 0
}
# --- Content Functions --- END ---

149
scripts/build/deps.sh Normal file
View file

@ -0,0 +1,149 @@
#!/usr/bin/env bash
#
# BSSG - Dependency Checking
# Checks for required tools and sets up environment variables.
#
# Portable md5sum wrapper
portable_md5sum() {
if command -v md5sum > /dev/null 2>&1; then
# Linux: md5sum command exists
md5sum "$@"
elif [[ "$(uname)" == "OpenBSD" ]] || [[ "$(uname)" == "NetBSD" ]]; then
# OpenBSD/NetBSD: md5 command without -r or -q
# Output format: "MD5 (filename) = hash" -> Need "hash filename"
if [ $# -eq 0 ] || [ "$1" = "-" ]; then
# Handle stdin: OpenBSD md5 outputs just the hash directly.
# Read the hash (field 1) and append " -" to match md5sum format.
md5 | awk '{print $1 " -"}'
else
# Handle files: MD5 (file) = hash -> hash file
md5 "$@" | awk '{print $4 " " $2}' | sed 's/[()]//g'
fi
elif command -v md5 > /dev/null 2>&1; then
# macOS / FreeBSD: Use md5 -r which outputs "hash filename"
# This matches the old script's alias logic for macOS
md5 -r "$@"
else
echo -e "${RED}Error: Neither md5sum nor md5 command found.${NC}" >&2
return 1
fi
}
# Check for required tools
check_dependencies() {
local missing_deps=0
# Array of required commands
local deps=("awk" "sed" "grep" "find" "date")
# Check if a usable MD5 command exists (md5sum or md5)
if ! command -v md5sum &> /dev/null && ! command -v md5 &> /dev/null; then
echo -e "${RED}Error: Neither 'md5sum' nor 'md5' command found. Cannot calculate checksums.${NC}"
missing_deps=1
# No need to add md5sum/md5 to the deps array, as we've already verified one exists
fi
# Add markdown processor dependency based on configuration
# IMPORTANT: Config variables like MARKDOWN_PROCESSOR must be exported/available
if [ "${MARKDOWN_PROCESSOR:-pandoc}" = "pandoc" ]; then # Default to pandoc if unset
deps+=("pandoc")
elif [ "$MARKDOWN_PROCESSOR" = "commonmark" ]; then
# Check if cmark (commonmark implementation) is installed
if ! command -v cmark &> /dev/null; then
echo -e "${RED}Error: commonmark (cmark) is not installed${NC}"
echo -e "${YELLOW}Tip: Install commonmark/cmark from https://github.com/commonmark/cmark${NC}"
missing_deps=1
fi
# Add cmark even if missing, so the main loop reports it
deps+=("cmark")
elif [ "$MARKDOWN_PROCESSOR" = "markdown.pl" ]; then
# Check if markdown.pl or Markdown.pl exists in PATH or current directory
if command -v markdown.pl &> /dev/null; then
MARKDOWN_PL_PATH="markdown.pl"
elif command -v Markdown.pl &> /dev/null; then
MARKDOWN_PL_PATH="Markdown.pl"
elif [ -f "./markdown.pl" ] && [ -x "./markdown.pl" ]; then
MARKDOWN_PL_PATH="./markdown.pl"
elif [ -f "./Markdown.pl" ] && [ -x "./Markdown.pl" ]; then
MARKDOWN_PL_PATH="./Markdown.pl"
else
echo -e "${RED}Error: markdown.pl is not installed or not in PATH${NC}"
echo -e "${YELLOW}Tip: You can place markdown.pl in the BSSG directory and make it executable${NC}"
missing_deps=1
fi
# Add the specific path if found, otherwise add the generic name for error reporting
deps+=("${MARKDOWN_PL_PATH:-markdown.pl}")
else
echo -e "${RED}Error: Invalid MARKDOWN_PROCESSOR value ('$MARKDOWN_PROCESSOR'). Use 'pandoc', 'commonmark', or 'markdown.pl'.${NC}"
# No dependency to add, but set missing_deps
missing_deps=1
fi
echo "Checking dependencies..."
for dep in "${deps[@]}"; do
if ! command -v "$dep" &> /dev/null; then
# Avoid redundant error for cmark/markdown.pl already printed
if [[ "$dep" != "cmark" && "$dep" != "markdown.pl" && "$dep" != "./markdown.pl" && "$dep" != "./Markdown.pl" ]]; then
echo -e "${RED}Error: Required command '$dep' is not installed${NC}"
missing_deps=1
fi
fi
done
# Check for GNU parallel
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 && { 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
echo -e "${YELLOW}GNU parallel not found. Using sequential processing.${NC}"
export HAS_PARALLEL=false
fi
if [ $missing_deps -eq 1 ]; then
echo -e "${RED}Please install the missing dependencies and try again.${NC}"
exit 1
fi
echo -e "${GREEN}All dependencies satisfied!${NC}"
# Call directory check after dependency check
check_directories || { echo -e "${RED}Error: Directory check failed.${NC}"; exit 1; }
}
# Check if src, templates directories exist and create output directory
check_directories() {
echo "Checking required directories..."
if [ ! -d "$SRC_DIR" ]; then
echo -e "${RED}Error: Source directory '$SRC_DIR' does not exist${NC}"
exit 1
fi
if [ ! -d "$TEMPLATES_DIR" ]; then
echo -e "${RED}Error: Templates directory '$TEMPLATES_DIR' does not exist${NC}"
exit 1
fi
if [ ! -d "$THEMES_DIR" ]; then
echo -e "${RED}Error: Themes directory '$THEMES_DIR' does not exist${NC}"
exit 1
fi
# Note: Output directory and cache directory creation is handled in main.sh initial setup.
# We just check the source/template dirs here.
echo -e "${GREEN}Source/Template/Theme directories verified!${NC}"
}
# Export functions
export -f check_dependencies
export -f check_directories
export -f portable_md5sum
# Define and export the MD5 command variable to use the portable function
MD5_CMD="portable_md5sum"
export MD5_CMD

View file

@ -0,0 +1,887 @@
#!/usr/bin/env bash
#
# BSSG - Archive Page Generation
# Handles the creation of yearly and monthly archive pages and the main archive index.
#
# Source dependencies
# shellcheck source=utils.sh disable=SC1091
source "$(dirname "$0")/utils.sh" || { echo >&2 "Error: Failed to source utils.sh from generate_archives.sh"; exit 1; }
# shellcheck source=cache.sh disable=SC1091
source "$(dirname "$0")/cache.sh" || { echo >&2 "Error: Failed to source cache.sh from generate_archives.sh"; exit 1; }
# ==============================================================================
# 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//\{\{twitter_card\}\}/"summary"}
year_header=${year_header//\{\{fediverse_creator_meta\}\}/"${SITE_FEDIVERSE_CREATOR_META_TAG}"}
year_header=${year_header//\{\{canonical\}\}/}
year_header=${year_header//\{\{featured_image_preload\}\}/}
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//\{\{twitter_card\}\}/"summary"}
month_header=${month_header//\{\{fediverse_creator_meta\}\}/"${SITE_FEDIVERSE_CREATOR_META_TAG}"}
month_header=${month_header//\{\{canonical\}\}/}
month_header=${month_header//\{\{featured_image_preload\}\}/}
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" loading="lazy" />
</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//\{\{twitter_card\}\}/"summary"}
header_content=${header_content//\{\{fediverse_creator_meta\}\}/"${SITE_FEDIVERSE_CREATOR_META_TAG}"}
header_content=${header_content//\{\{canonical\}\}/}
header_content=${header_content//\{\{featured_image_preload\}\}/}
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"
local archives_index_page="$OUTPUT_DIR/archives/index.html"
local rebuild_reason=""
# --- Core Rebuild Reasons ---
# 1. Force rebuild flag
if [ "$FORCE_REBUILD" = true ]; then
rebuild_reason="Force rebuild flag set."
# 2. Config changed
elif [ "$BSSG_CONFIG_CHANGED_STATUS" -eq 0 ]; then
rebuild_reason="Global config changed."
# 3. Output index page missing
elif [ ! -f "$archives_index_page" ]; then
rebuild_reason="Archive index page '$archives_index_page' missing."
# 4. Templates changed (header/footer are global dependencies)
elif [ -f "$archives_index_page" ]; then
local template_dir="${TEMPLATES_DIR:-templates}"
if [ -d "$template_dir/${THEME:-default}" ]; then template_dir="$template_dir/${THEME:-default}"; fi
local header_template="$template_dir/header.html"
local footer_template="$template_dir/footer.html"
local output_time=$(get_file_mtime "$archives_index_page")
local header_time=$(get_file_mtime "$header_template")
local footer_time=$(get_file_mtime "$footer_template")
if (( header_time > output_time )) || (( footer_time > output_time )); then
rebuild_reason="Header or footer template changed."
fi
fi
# --- Content-based Rebuild Reasons ---
# If no core reason found yet, check if content changed and if it affects the main index page.
if [ -z "$rebuild_reason" ]; then
if [ -n "$AFFECTED_ARCHIVE_MONTHS" ]; then # Check if any month had post changes
if [ "${ARCHIVES_LIST_ALL_POSTS:-false}" = true ]; then
# If listing all posts, ANY change in affected months requires main index rebuild
rebuild_reason="List all posts enabled and archive content changed."
elif [ "${ARCHIVE_INDEX_NEEDS_REBUILD:-false}" = true ]; then
# If *not* listing all posts, only rebuild main index if month *counts* changed
rebuild_reason="Archive month counts changed."
fi
fi
fi
# --- End Content-based Check ---
if [ -n "$rebuild_reason" ]; then
echo -e "${YELLOW}Main archive index rebuild needed: $rebuild_reason${NC}" >&2 # Debug
return 0 # Needs rebuild
else
# No message here - generate_archive_pages will print the skipping message if needed.
return 1 # No rebuild needed
fi
}
# Generate the main archives index page (archives/index.html)
_generate_main_archive_index() {
local archive_index_file="$CACHE_DIR/archive_index.txt"
local archives_index_page="$OUTPUT_DIR/archives/index.html"
echo "Generating main archive index page: $archives_index_page" >&2 # Debug
# Create archives directory if it doesn't exist
mkdir -p "$(dirname "$archives_index_page")"
# Get unique years sorted descending
local unique_years=""
if [ -f "$archive_index_file" ] && [ -s "$archive_index_file" ]; then
unique_years=$(cut -d'|' -f1 "$archive_index_file" | sort -nr | uniq)
fi
# Generate header
local header_content="$HEADER_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/"} # 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//\{\{twitter_card\}\}/"summary"}
header_content=${header_content//\{\{fediverse_creator_meta\}\}/"${SITE_FEDIVERSE_CREATOR_META_TAG}"}
header_content=${header_content//\{\{canonical\}\}/}
header_content=${header_content//\{\{featured_image_preload\}\}/}
# 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"}
# Generate footer
local footer_content="$FOOTER_TEMPLATE"
footer_content=${footer_content//\{\{current_year\}\}/$(date +%Y)}
footer_content=${footer_content//\{\{author_name\}\}/"$AUTHOR_NAME"}
# Create the archives index page content
{
echo "$header_content"
echo "<h1>${MSG_ARCHIVES:-"Archives"}</h1>"
echo "<div class=\"archives-list year-list\">"
# Loop through years (Existing logic for Year/Month links)
echo "$unique_years" | while IFS= read -r year; do
[ -z "$year" ] && continue
local year_url
year_url=$(fix_url "/archives/$year/")
echo " <h2><a href=\"$year_url\">$year</a></h2>"
# Changed class to support potential block layout for month + posts
echo " <ul class=\"month-list-detailed\">"
# Get unique months for this year, sorted descending by month number
local months_in_year=""
if [ -f "$archive_index_file" ] && [ -s "$archive_index_file" ]; then
months_in_year=$(grep "^$year|" "$archive_index_file" 2>/dev/null | cut -d'|' -f2,3 | sort -t'|' -k1,1nr | uniq)
fi
# Add month links and potentially post lists
echo "$months_in_year" | while IFS= read -r month_line; do
local month month_name
# IMPORTANT: month is the numeric month (1-12) from the index
IFS='|' read -r month month_name <<< "$month_line"
[ -z "$month" ] && continue
local month_post_count=0
if [ -f "$archive_index_file" ] && [ -s "$archive_index_file" ]; then
# Use the numeric month for grep
month_post_count=$(grep -c "^$year|$month|" "$archive_index_file" 2>/dev/null || echo 0)
fi
local month_idx_formatted=$(printf "%02d" "$((10#$month))")
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/")
# Start the list item for the month
echo " <li>"
# Print the month link itself
echo " <a href=\"$month_url\">$current_month_name ($month_post_count)</a>"
# --- START: Add nested post list if configured ---
if [ "${ARCHIVES_LIST_ALL_POSTS:-false}" = true ] && [ "$month_post_count" -gt 0 ]; then
echo " <ul class=\"post-list-condensed-inline\">" # Nested list for posts
local post_year post_month post_day url_path post_url
# Grep posts for this specific year and numeric month, sort REVERSE chronologically
grep "^$year|$month|" "$archive_index_file" 2>/dev/null | sort -t'|' -k5,5r | while IFS='|' read -r _ _ _ title date _ filename slug _ _ _ author_name author_email; do
# Construct post URL (logic adapted from process_single_month)
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) # Fallback
fi
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}"
post_url="/$(echo "$url_path" | sed 's|^/||; s|/*$|/|')"
post_url=$(fix_url "$post_url") # Use fix_url for BASE_URL prefixing if needed
# Extract just the date part (YYYY-MM-DD)
local display_date=$(echo "$date" | cut -d' ' -f1)
echo " <li><a href=\"$post_url\">[$display_date] $title</a></li>"
done
echo " </ul>" # Close nested post list
fi
# --- END: Add nested post list ---
# Close the list item for the month
echo " </li>"
done
echo " </ul>" # End of month-list-detailed
done
echo "</div>" # End of year-list div
echo "$footer_content"
} > "$archives_index_page"
echo -e "Generated ${GREEN}$archives_index_page${NC}"
}
# Generate the index page for a specific year (archives/YYYY/index.html)
# This is called only if at least one month within the year needs updating.
_generate_year_index() {
local year="$1"
local archive_index_file="$CACHE_DIR/archive_index.txt"
local year_index_page="$OUTPUT_DIR/archives/$year/index.html"
echo "Generating year index page: $year_index_page" >&2 # Debug
mkdir -p "$(dirname "$year_index_page")"
# Generate header
local header_content="$HEADER_TEMPLATE"
local year_page_title="${MSG_ARCHIVES_FOR:-"Archives for"} $year"
local year_archive_rel_url="/archives/$year/"
header_content=${header_content//\{\{site_title\}\}/"$SITE_TITLE"}
header_content=${header_content//\{\{page_title\}\}/"$year_page_title"}
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\}\}/"$year_archive_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//\{\{twitter_card\}\}/"summary"}
header_content=${header_content//\{\{fediverse_creator_meta\}\}/"${SITE_FEDIVERSE_CREATOR_META_TAG}"}
header_content=${header_content//\{\{canonical\}\}/}
header_content=${header_content//\{\{featured_image_preload\}\}/}
# 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"}
# Generate footer
local footer_content="$FOOTER_TEMPLATE"
footer_content=${footer_content//\{\{current_year\}\}/$(date +%Y)}
footer_content=${footer_content//\{\{author_name\}\}/"$AUTHOR_NAME"}
# Create the year index page content
{
echo "$header_content"
echo "<h1>$year_page_title</h1>"
echo "<ul class=\"month-list\">"
# Get unique months for this year, sorted descending by month number
local months_in_year=""
if [ -f "$archive_index_file" ] && [ -s "$archive_index_file" ]; then
months_in_year=$(grep "^$year|" "$archive_index_file" 2>/dev/null | cut -d'|' -f2,3 | sort -t'|' -k1,1nr | uniq)
fi
# Add month links
echo "$months_in_year" | while IFS= read -r month_line; do
local month month_name
IFS='|' read -r month month_name <<< "$month_line"
[ -z "$month" ] && continue
local month_post_count=0
if [ -f "$archive_index_file" ] && [ -s "$archive_index_file" ]; then
month_post_count=$(grep -c "^$year|$month|" "$archive_index_file" 2>/dev/null || echo 0)
fi
local month_idx_formatted=$(printf "%02d" "$((10#$month))")
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 "$footer_content"
} > "$year_index_page"
echo -e "Generated ${GREEN}$year_index_page${NC}"
}
# Generate the index page for a specific month (archives/YYYY/MM/index.html)
process_single_month() {
local year="$1"
local month_num="$2" # Expecting MM format (01-12)
local archive_index_file="$CACHE_DIR/archive_index.txt"
local month_index_page="$OUTPUT_DIR/archives/$year/$month_num/index.html"
echo "Processing month archive: $year-$month_num -> $month_index_page" >&2 # Debug
mkdir -p "$(dirname "$month_index_page")"
# Get month name (from locale or fallback)
local month_name_var="MSG_MONTH_${month_num}"
local month_name=${!month_name_var}
if [[ -z "$month_name" ]]; then # Fallback using date command
local input_date_for_month_name="${year}-${month_num}-01"
if [[ "$OSTYPE" == "darwin"* ]] || [[ "$OSTYPE" == *bsd* ]]; then
month_name=$(date -j -f "%Y-%m-%d" "$input_date_for_month_name" "+%B" 2>/dev/null)
else
month_name=$(date -d "$input_date_for_month_name" "+%B" 2>/dev/null)
fi
[[ -z "$month_name" ]] && month_name="Month $month_num" # Final fallback
fi
# Generate header
local header_content="$HEADER_TEMPLATE"
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"}
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\}\}/"$month_archive_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//\{\{twitter_card\}\}/"summary"}
header_content=${header_content//\{\{fediverse_creator_meta\}\}/"${SITE_FEDIVERSE_CREATOR_META_TAG}"}
header_content=${header_content//\{\{canonical\}\}/}
header_content=${header_content//\{\{featured_image_preload\}\}/}
# 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"}
# Generate footer
local footer_content="$FOOTER_TEMPLATE"
footer_content=${footer_content//\{\{current_year\}\}/$(date +%Y)}
footer_content=${footer_content//\{\{author_name\}\}/"$AUTHOR_NAME"}
# Create the month index page content
{
echo "$header_content"
echo "<h1>$month_page_title</h1>"
echo "<div class=\"posts-list\">"
# Grep for posts from this specific month and year
grep "^$year|$month_num|" "$archive_index_file" 2>/dev/null | while IFS='|' read -r _ _ _ title date lastmod filename slug image image_caption description author_name author_email; do
# --- Start: Card Generation Logic (copied from generate_tags.sh) ---
local post_url post_year post_month post_day url_path
# Replicate URL generation logic from generate_posts.sh
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
# Fallback if date parsing fails (should be rare with indexing)
post_year=$(date +%Y); post_month=$(date +%m); post_day=$(date +%d)
fi
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}"
# Ensure relative URL starts with / and ends with /
post_url="/$(echo "$url_path" | sed 's|^/||; s|/*$|/|')"
# Prepend SITE_URL for the final href
post_url="${SITE_URL}${post_url}"
# Format date
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=$(format_date "$date" "$display_date_format")
# Determine author for display (with fallback)
local display_author_name="${author_name:-${AUTHOR_NAME:-Anonymous}}"
# Use cat heredoc for multi-line article structure
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=$(fix_url "$image")
local alt_text="${image_caption:-$title}"
local figcaption_content="${image_caption:-$title}"
# Use cat heredoc for figure structure
cat << EOF
<figure class="featured-image tag-image">
<a href="${post_url}">
<img src="$image_url" alt="$alt_text" loading="lazy" />
</a>
<figcaption>$figcaption_content</figcaption>
</figure>
EOF
fi
if [ -n "$description" ]; then
# Use cat heredoc for summary structure
cat << EOF
<div class="summary">
$description
</div>
EOF
fi
# Use cat heredoc for closing article tag
cat << EOF
</article>
EOF
# --- End: Card Generation Logic ---
done
# Close the div instead of ul
echo "</div>"
echo "$footer_content"
} > "$month_index_page"
echo -e "Generated ${GREEN}$month_index_page${NC}"
}
# Wrapper function for parallel processing to handle argument parsing
_process_single_month_parallel_wrapper() {
local line="$1"
local year month_num
# Parse the input line
IFS='|' read -r year month_num <<< "$line"
# Call the original function with parsed arguments
process_single_month "$year" "$month_num"
}
# ==============================================================================
# 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"
# Check if the archive index file exists (needed for any processing)
if [ ! -f "$archive_index_file" ]; then
echo -e "${YELLOW}Warning: Archive index file not found at '$archive_index_file'. Skipping archive generation.${NC}"
return 0
fi
# --- Step 1: Handle Main Archive Index Page ---
if _check_archive_index_rebuild_needed; then
_generate_main_archive_index
else
echo -e "${GREEN}Main archive index page is up to date, skipping generation.${NC}"
fi
# --- Step 2: Handle Monthly and Yearly Pages based on Affected Months ---
# Trim leading/trailing whitespace from AFFECTED_ARCHIVE_MONTHS just in case
AFFECTED_ARCHIVE_MONTHS=$(echo "$AFFECTED_ARCHIVE_MONTHS" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
if [ -z "$AFFECTED_ARCHIVE_MONTHS" ]; then
echo -e "${GREEN}No affected archive months found. Skipping monthly/yearly page generation.${NC}"
return 0
fi
echo "Affected months needing update: $AFFECTED_ARCHIVE_MONTHS" >&2 # Debug
# --- Prepare for potential parallel processing ---
local affected_years=()
local affected_months_list=()
local unique_years_map # Use associative array to track years needing update
# Bash 4+ required for associative arrays
if (( BASH_VERSINFO[0] < 4 )); then
echo -e "${RED}Error: Bash 4+ required for optimized archive generation.${NC}" >&2
# Fallback to non-parallel or exit? For now, just error out.
return 1
fi
declare -A unique_years_map # Associative array requires Bash 4+
for month_pair in $AFFECTED_ARCHIVE_MONTHS; do
local year month_num
IFS='|' read -r year month_num <<< "$month_pair"
# Ensure month has leading zero for consistency
month_num=$(printf "%02d" "$((10#$month_num))")
# Add to list for month processing
affected_months_list+=("$year|$month_num")
# Mark year as needing its index page updated
unique_years_map["$year"]=1
done
# Extract unique years that need updating
affected_years=("${!unique_years_map[@]}")
if [ ${#affected_months_list[@]} -eq 0 ]; then
echo -e "${GREEN}No valid affected months parsed. Nothing to do.${NC}"
return 0
fi
echo "Processing ${#affected_months_list[@]} affected month(s) across ${#affected_years[@]} year(s)." >&2 # Debug
# --- Generate Year Index Pages (Sequentially or Parallel?) ---
# Generating year pages is usually fast. Let's do it sequentially first.
echo "Generating/Updating affected year index pages..." >&2 # Debug
for year in "${affected_years[@]}"; do
_generate_year_index "$year"
done
echo "Affected year index pages updated." >&2 # Debug
# --- Generate Monthly Pages (Parallel if available) ---
echo "Generating/Updating affected monthly index pages..." >&2 # Debug
if [ "$HAS_PARALLEL" = true ]; then
# --- Parallel processing ---
echo -e "${GREEN}Using GNU parallel to process monthly archive pages${NC}"
# Determine number of cores/jobs
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 jobs=$cores # Use all detected cores
if [ $jobs -gt ${#affected_months_list[@]} ]; then jobs=${#affected_months_list[@]}; fi # Don't use more jobs than items
# Explicitly re-source utils.sh just in case environment is lost (shouldn't be needed if main.sh exports correctly, but safer)
# shellcheck source=utils.sh disable=SC1091
source "$(dirname "$0")/utils.sh" || { echo >&2 "Error: Failed to re-source utils.sh for parallel export"; return 1; }
# Export necessary variables and the processing function
export CACHE_DIR OUTPUT_DIR SITE_TITLE SITE_DESCRIPTION SITE_URL AUTHOR_NAME HEADER_TEMPLATE FOOTER_TEMPLATE MSG_ARCHIVES_FOR MSG_MONTH_01 MSG_MONTH_02 MSG_MONTH_03 MSG_MONTH_04 MSG_MONTH_05 MSG_MONTH_06 MSG_MONTH_07 MSG_MONTH_08 MSG_MONTH_09 MSG_MONTH_10 MSG_MONTH_11 MSG_MONTH_12 URL_SLUG_FORMAT DATE_FORMAT TIMEZONE SHOW_TIMEZONE
# Export needed functions (utils.sh sourced above, cache.sh sourced at top of main script)
export -f process_single_month format_date fix_url get_file_mtime
export -f _process_single_month_parallel_wrapper # Export the wrapper function
# Call the wrapper function, passing the whole line {}
printf "%s\n" "${affected_months_list[@]}" | \
parallel --jobs "$jobs" --will-cite --no-notice _process_single_month_parallel_wrapper {} || { echo -e "${RED}Parallel monthly archive processing failed.${NC}"; return 1; }
# Consider unexporting? Not strictly necessary as script exits soon.
else
# --- Sequential processing ---
echo -e "${YELLOW}Using sequential processing for monthly archive pages${NC}"
for month_pair in "${affected_months_list[@]}"; do
local year month_num
IFS='|' read -r year month_num <<< "$month_pair"
process_single_month "$year" "$month_num"
done
fi
echo "Affected monthly index pages updated." >&2 # Debug
echo -e "${GREEN}Archive page processing complete.${NC}"
}
# Make the function available for sourcing
export -f generate_archive_pages

View file

@ -0,0 +1,682 @@
#!/usr/bin/env bash
#
# BSSG - Author Page Generation
# Handles the creation of individual author pages and the main author index.
#
# Source dependencies
# shellcheck source=utils.sh disable=SC1091
source "$(dirname "$0")/utils.sh" || { echo >&2 "Error: Failed to source utils.sh from generate_authors.sh"; exit 1; }
# shellcheck source=cache.sh disable=SC1091
source "$(dirname "$0")/cache.sh" || { echo >&2 "Error: Failed to source cache.sh from generate_authors.sh"; exit 1; }
# Source the feed generator script for the reusable RSS function
# 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//\{\{twitter_card\}\}/"summary"}
header_content=${header_content//\{\{fediverse_creator_meta\}\}/"${SITE_FEDIVERSE_CREATOR_META_TAG}"}
header_content=${header_content//\{\{canonical\}\}/}
header_content=${header_content//\{\{featured_image_preload\}\}/}
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"
local main_authors_index_output="$OUTPUT_DIR/authors/index.html"
local modified_authors_list_file="${CACHE_DIR:-.bssg_cache}/modified_authors.list"
# Check if the authors index file exists (needed for listing authors)
if [ ! -f "$authors_index_file" ]; then
echo -e "${YELLOW}Authors index file not found at $authors_index_file. Skipping author page generation.${NC}"
# If the index doesn't exist, no authors were found in posts.
# Ensure the main output directory exists but is empty.
mkdir -p "$(dirname "$main_authors_index_output")"
echo -e "${GREEN}Author pages processed! (No authors found)${NC}"
echo -e "${GREEN}Generated author list pages. (No authors found)${NC}"
return 0
fi
# --- Calculate Latest Common Dependency Time --- START ---
# Get mtimes of config hash, templates, and locale file
local latest_common_dep_time=0
local config_hash_time=$(get_file_mtime "$CONFIG_HASH_FILE")
latest_common_dep_time=$(( config_hash_time > latest_common_dep_time ? config_hash_time : latest_common_dep_time ))
local template_dir="${TEMPLATES_DIR:-templates}"
if [ -d "$template_dir/${THEME:-default}" ]; then
template_dir="$template_dir/${THEME:-default}"
fi
local header_template="$template_dir/header.html"
local footer_template="$template_dir/footer.html"
local header_time=$(get_file_mtime "$header_template")
local footer_time=$(get_file_mtime "$footer_template")
latest_common_dep_time=$(( header_time > latest_common_dep_time ? header_time : latest_common_dep_time ))
latest_common_dep_time=$(( footer_time > latest_common_dep_time ? footer_time : latest_common_dep_time ))
local active_locale_file=""
if [ -f "${LOCALE_DIR:-locales}/${SITE_LANG:-en}.sh" ]; then
active_locale_file="${LOCALE_DIR:-locales}/${SITE_LANG:-en}.sh"
elif [ -f "${LOCALE_DIR:-locales}/en.sh" ]; then
active_locale_file="${LOCALE_DIR:-locales}/en.sh"
fi
local locale_time=$(get_file_mtime "$active_locale_file")
latest_common_dep_time=$(( locale_time > latest_common_dep_time ? locale_time : latest_common_dep_time ))
# --- Calculate Latest Common Dependency Time --- END ---
# --- Simplified Global Check --- START ---
# Decide if we need to proceed with any author generation steps at all.
local proceed_with_generation=false
local force_rebuild_status="${FORCE_REBUILD:-false}"
if [ "$force_rebuild_status" = true ]; then
proceed_with_generation=true
echo "Force rebuild enabled, proceeding with author generation." >&2 # Debug
elif [ "$latest_common_dep_time" -gt 0 ] && { [ ! -f "$main_authors_index_output" ] || (( $(get_file_mtime "$main_authors_index_output") < latest_common_dep_time )); }; then
# Common dependencies are newer than the main output (or main output missing)
proceed_with_generation=true
echo "Common dependencies changed, proceeding with author generation." >&2 # Debug
elif [ -s "$modified_authors_list_file" ]; then
# Modified authors list exists and is not empty
proceed_with_generation=true
echo "Modified authors detected, proceeding with author generation." >&2 # Debug
elif [ ! -f "$main_authors_index_output" ]; then
# Fallback: if main output is missing, we should generate it
proceed_with_generation=true
echo "Main authors index missing, proceeding with author generation." >&2 # Debug
fi
if [ "$proceed_with_generation" = false ]; then
echo -e "${GREEN}Authors index, author pages${NC}${ENABLE_AUTHOR_RSS:+, and author RSS feeds} appear up to date based on common dependencies and modified posts, skipping.${NC}"
echo -e "${GREEN}Author pages processed!${NC}" # Keep consistent final message
echo -e "${GREEN}Generated author list pages.${NC}" # Keep consistent final message
return 0
fi
# --- Simplified Global Check --- END ---
# --- Proceed with Generation ---
# Get unique authors (Author|Slug pairs)
local unique_authors_lines=$(awk -F'|' '{print $1 "|" $2}' "$authors_index_file" | sort | uniq)
local author_count=$(echo "$unique_authors_lines" | grep -v '^$' | wc -l)
echo -e "Checking ${GREEN}$author_count${NC} author pages${NC}${ENABLE_AUTHOR_RSS:+/feeds} for changes (based on common deps & modified authors)" # Updated message
# --- Pre-group posts by author slug --- START ---
local author_data_dir="$CACHE_DIR/author_data"
rm -rf "$author_data_dir" # Clean previous data
mkdir -p "$author_data_dir"
echo -e "Pre-grouping posts by author into ${BLUE}$author_data_dir${NC}..."
if awk -F'|' -v author_dir="$author_data_dir" '
NF >= 2 { # Ensure at least author and slug fields exist
author_slug = $2;
if (author_slug != "") {
# Sanitize slug just in case for filename safety? (basic: remove /)
gsub(/\//, "_", author_slug);
output_file = author_dir "/" author_slug ".tmp";
print $0 >> output_file; # Append the whole line
close(output_file); # Close file handle to avoid too many open files
} else {
print "Warning: Skipping line with empty author slug in authors_index: " $0 > "/dev/stderr";
}
}
' "$authors_index_file"; then
echo -e "${GREEN}Pre-grouping complete.${NC}"
else
echo -e "${RED}Error: Failed to pre-group author data using awk.${NC}" >&2
return 1
fi
# --- Pre-group posts by author slug --- END ---
# Define a modified file_needs_rebuild function for parallel use
parallel_file_needs_rebuild() {
local output_file="$1"
local latest_dep_time="$2" # This should be latest_common_dep_time
# Rebuild if output file doesn't exist
if [ ! -f "$output_file" ]; then
return 0 # Rebuild needed
fi
local output_time=$(get_file_mtime "$output_file")
# Rebuild if output is older than the latest relevant *common* dependency
if (( output_time < latest_dep_time )); then
return 0 # Rebuild needed
fi
return 1 # No rebuild needed
}
# Define a function to process a single author
process_author() {
local author_line="$1"
local author_data_dir="$2"
local latest_common_dep_time_for_author="$3"
local modified_authors_file="$4" # Accept filename instead of hash
# --- Load modified authors from file ---
declare -A modified_authors_hash
if [ -f "$modified_authors_file" ]; then
local mod_author_local
while IFS= read -r mod_author_local || [[ -n "$mod_author_local" ]]; do
if [ -n "$mod_author_local" ]; then # Ensure not empty line
modified_authors_hash["$mod_author_local"]=1
fi
done < "$modified_authors_file"
fi
local author author_slug
IFS='|' read -r author author_slug <<< "$author_line"
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_rss_rel_url="/authors/${author_slug}/${RSS_FILENAME:-rss.xml}"
local rebuild_html=false
local rebuild_rss=false
# --- Force rebuild flags if author was modified ---
local author_was_modified=false
if [ -n "${modified_authors_hash[$author]}" ]; then
author_was_modified=true
rebuild_html=true # Force rebuild if author was modified
if [ "${ENABLE_AUTHOR_RSS:-false}" = true ]; then
rebuild_rss=true # Force rebuild if author was modified
fi
fi
# --- Check if HTML rebuild is needed ---
if [ "$rebuild_html" = false ]; then
if parallel_file_needs_rebuild "$author_page_html_file" "$latest_common_dep_time_for_author"; then
rebuild_html=true
fi
fi
# --- Check if RSS rebuild is needed ---
if [ "${ENABLE_AUTHOR_RSS:-false}" = true ] && [ "$rebuild_rss" = false ]; then
if parallel_file_needs_rebuild "$author_rss_file" "$latest_common_dep_time_for_author"; then
rebuild_rss=true
fi
fi
# --- Skip if no rebuilds needed ---
if [ "$rebuild_html" = false ] && { [ "${ENABLE_AUTHOR_RSS:-false}" = false ] || [ "$rebuild_rss" = false ]; }; then
echo "Author '$author' pages are up to date, skipping."
return 0
fi
# --- Load author posts data ---
local author_data_file="$author_data_dir/${author_slug}.tmp"
if [ ! -f "$author_data_file" ]; then
echo "Warning: No posts found for author '$author' (expected file: $author_data_file)" >&2
return 0
fi
# Count posts for this author
local post_count=$(wc -l < "$author_data_file")
echo "Processing author '$author' ($post_count posts)..."
# --- Generate Author HTML Page ---
if [ "$rebuild_html" = true ]; then
mkdir -p "$(dirname "$author_page_html_file")"
# Generate author page content
local author_page_content=""
author_page_content+="<h1>${MSG_POSTS_BY:-Posts by} $author</h1>"$'\n'
# Add RSS link if enabled
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
# Add posts list
author_page_content+="<div class=\"posts-list\">"$'\n'
# Sort posts by date (newest first) and generate HTML
local posts_html=""
while IFS='|' read -r author_name author_slug_inner author_email post_title post_date post_lastmod post_filename post_slug post_image post_image_caption post_description; do
# Construct post URL using URL_SLUG_FORMAT (same logic as generate_posts.sh)
local post_url=""
if [ -n "$post_date" ]; then
local year month day
if [[ "$post_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) # Fallback
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/$post_slug}"
# Ensure relative post_url starts with / and ends with /
post_url="/$(echo "$url_path" | sed 's|^/||; s|/*$|/|')"
else
# Fallback for posts without date
post_url="/$(echo "$post_slug" | sed 's|^/||; s|/*$|/|')"
fi
# Convert to full URL with BASE_URL
post_url="$BASE_URL$post_url"
local formatted_date=$(format_date "$post_date")
posts_html+="<article>"$'\n'
posts_html+=" <h2><a href=\"$post_url\">$post_title</a></h2>"$'\n'
posts_html+=" <div class=\"meta\">"$'\n'
posts_html+=" <time datetime=\"$post_date\">$formatted_date</time>"$'\n'
posts_html+=" </div>"$'\n'
if [ -n "$post_description" ]; then
posts_html+=" <p class=\"summary\">$post_description</p>"$'\n'
fi
if [ -n "$post_image" ]; then
posts_html+=" <div class=\"author-image\">"$'\n'
posts_html+=" <img src=\"$post_image\" alt=\"$post_image_caption\" loading=\"lazy\">"$'\n'
posts_html+=" </div>"$'\n'
fi
posts_html+="</article>"$'\n'
done < <(sort -t'|' -k5,5r "$author_data_file")
author_page_content+="$posts_html"
author_page_content+="</div>"$'\n'
# Generate full HTML page
local page_title="${MSG_POSTS_BY:-Posts by} $author"
local page_description="${MSG_POSTS_BY:-Posts by} $author - $post_count ${MSG_POSTS:-posts}"
# Process templates with placeholder replacement
local header_content="$HEADER_TEMPLATE"
local footer_content="$FOOTER_TEMPLATE"
# Replace placeholders in the header (following tags generator pattern)
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"}
# 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 -->/}
# Add author RSS link if enabled
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
# Schema.org structured data
local 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>"}
# Replace placeholders in the footer
local 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.}"}
# Create the full HTML page
{
echo "$header_content"
echo "$author_page_content"
echo "$footer_content"
} > "$author_page_html_file"
echo "Generated author page: $author_page_html_file"
fi
# --- Generate Author RSS Feed ---
if [ "${ENABLE_AUTHOR_RSS:-false}" = true ] && [ "$rebuild_rss" = true ]; then
mkdir -p "$(dirname "$author_rss_file")"
# Generate RSS feed for this author
local rss_title="$SITE_TITLE - ${MSG_POSTS_BY:-Posts by} $author"
local rss_description="${MSG_POSTS_BY:-Posts by} $author"
local feed_link_rel="$author_page_rel_url"
local feed_atom_link_rel="$author_rss_rel_url"
# Read and format author post data for RSS generation
local author_post_data=""
if [ -f "$author_data_file" ]; then
# Transform author data format to RSS format and sort by date (newest first)
# Author format: Author|Slug|Email|Title|Date|LastMod|Filename|PostSlug|Image|ImageCaption|Description
# RSS format: file|filename|title|date|lastmod|tags|slug|image|image_caption|description|author_name|author_email
author_post_data=$(sort -t'|' -k5,5r "$author_data_file" | awk -F'|' '{
# Map fields from author format to RSS format
author_name = $1
author_slug = $2
author_email = $3
title = $4
date = $5
lastmod = $6
filename = $7
post_slug = $8
image = $9
image_caption = $10
description = $11
# RSS format: file|filename|title|date|lastmod|tags|slug|image|image_caption|description|author_name|author_email
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
}')
fi
# Check if _generate_rss_feed function exists
if ! command -v _generate_rss_feed > /dev/null 2>&1; then
echo -e "${RED}Error: _generate_rss_feed function not found. Ensure generate_feeds.sh is sourced correctly.${NC}" >&2
else
_generate_rss_feed "$author_rss_file" "$rss_title" "$rss_description" "$feed_link_rel" "$feed_atom_link_rel" "$author_post_data"
echo "Generated author RSS feed: $author_rss_file"
fi
fi
fi
}
# Export the function for potential parallel use
export -f process_author parallel_file_needs_rebuild
# Process each unique author
echo "$unique_authors_lines" | while IFS= read -r author_line || [[ -n "$author_line" ]]; do
if [ -n "$author_line" ]; then
process_author "$author_line" "$author_data_dir" "$latest_common_dep_time" "$modified_authors_list_file"
fi
done
# --- Generate Main Authors Index Page ---
if [ "${AUTHORS_INDEX_NEEDS_REBUILD:-false}" = true ] || [ ! -f "$main_authors_index_output" ] || (( $(get_file_mtime "$main_authors_index_output") < latest_common_dep_time )); then
echo "Generating main authors index page..."
mkdir -p "$(dirname "$main_authors_index_output")"
# Count posts per author and generate the main index
local authors_with_counts=""
echo "$unique_authors_lines" | while IFS= read -r author_line || [[ -n "$author_line" ]]; do
if [ -n "$author_line" ]; then
local author author_slug
IFS='|' read -r author author_slug <<< "$author_line"
local author_data_file="$author_data_dir/${author_slug}.tmp"
if [ -f "$author_data_file" ]; then
local post_count=$(wc -l < "$author_data_file")
echo "$author|$author_slug|$post_count"
fi
fi
done | sort > "${CACHE_DIR}/authors_with_counts.tmp"
# Generate main authors index HTML
local main_content=""
main_content+="<h1>${MSG_ALL_AUTHORS:-All Authors}</h1>"$'\n'
main_content+="<div class=\"tags-list\">"$'\n' # Reuse tags styling
while IFS='|' read -r author author_slug post_count; do
if [ -n "$author" ] && [ "$post_count" -gt 0 ]; then
main_content+=" <a href=\"$BASE_URL/authors/$author_slug/\">$author <span class=\"tag-count\">($post_count)</span></a>"$'\n'
fi
done < "${CACHE_DIR}/authors_with_counts.tmp"
main_content+="</div>"$'\n'
# 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/"
# Process templates with placeholder replacement (following tags generator pattern)
local header_content="$HEADER_TEMPLATE"
local footer_content="$FOOTER_TEMPLATE"
# Replace placeholders in the header
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_index_rel_url"}
header_content=${header_content//\{\{site_url\}\}/"$SITE_URL"}
# Remove unprocessed image placeholders
header_content=${header_content//\{\{og_image\}\}/}
header_content=${header_content//\{\{twitter_image\}\}/}
header_content=${header_content//\{\{twitter_card\}\}/"summary"}
header_content=${header_content//\{\{fediverse_creator_meta\}\}/"${SITE_FEDIVERSE_CREATOR_META_TAG}"}
header_content=${header_content//\{\{canonical\}\}/}
header_content=${header_content//\{\{featured_image_preload\}\}/}
# Remove the placeholder for the tag-specific RSS feed link in the main authors index
header_content=${header_content//<!-- bssg:tag_rss_link -->/}
# Schema.org structured data
local 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>"}
# Replace placeholders in the footer
local 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 "$main_content"
echo "$footer_content"
} > "$main_authors_index_output"
echo "Generated main authors index: $main_authors_index_output"
# Clean up temporary file
rm -f "${CACHE_DIR}/authors_with_counts.tmp"
else
echo "Main authors index is up to date, skipping..."
fi
# Clean up author data directory
rm -rf "$author_data_dir"
echo -e "${GREEN}Author pages processed!${NC}"
echo -e "${GREEN}Generated author list pages.${NC}"
}

921
scripts/build/generate_feeds.sh Executable file
View file

@ -0,0 +1,921 @@
#!/usr/bin/env bash
#
# BSSG - Feed Generation
# Handles the creation of sitemap.xml and rss.xml.
#
# Source dependencies
# shellcheck source=utils.sh disable=SC1091
source "$(dirname "$0")/utils.sh" || { echo >&2 "Error: Failed to source utils.sh from generate_feeds.sh"; exit 1; }
# shellcheck source=cache.sh disable=SC1091
source "$(dirname "$0")/cache.sh" || { echo >&2 "Error: Failed to source cache.sh from generate_feeds.sh"; exit 1; }
# Source content.sh to get convert_markdown_to_html
# shellcheck source=content.sh disable=SC1091
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
_rfc822_gmt_date() {
local input_dt="$1"
if [ -z "$input_dt" ]; then
echo ""
return
fi
if [ "$input_dt" = "now" ]; then
LC_ALL=C date -u "+%a, %d %b %Y %H:%M:%S GMT"
return
fi
local cache_key="gmt|$input_dt"
if [[ -n "${BSSG_FORMAT_DATE_CACHE[$cache_key]+_}" ]]; then
echo "${BSSG_FORMAT_DATE_CACHE[$cache_key]}"
return
fi
local formatted=""
if [[ "$BSSG_KERNEL_NAME" == "Darwin" ]] || [[ "$BSSG_KERNEL_NAME" == *"BSD" ]]; then
formatted=$(LC_ALL=C date -ujf "%Y-%m-%d %H:%M:%S" "$input_dt" "+%a, %d %b %Y %H:%M:%S GMT" 2>/dev/null)
[ -z "$formatted" ] && formatted=$(LC_ALL=C date -ujf "%Y-%m-%d" "$input_dt" "+%a, %d %b %Y %H:%M:%S GMT" 2>/dev/null)
[ -z "$formatted" ] && formatted=$(LC_ALL=C date -ujf "%d %b %Y %H:%M:%S %z" "$input_dt" "+%a, %d %b %Y %H:%M:%S GMT" 2>/dev/null)
else
formatted=$(LC_ALL=C date -ud "$input_dt" "+%a, %d %b %Y %H:%M:%S GMT" 2>/dev/null)
fi
if [ -z "$formatted" ]; then
formatted=$(format_date "$input_dt" "%a, %d %b %Y %H:%M:%S %z" 2>/dev/null)
fi
BSSG_FORMAT_DATE_CACHE["$cache_key"]="$formatted"
echo "$formatted"
}
_get_mime_type() {
local filepath="$1"
local ext="${filepath##*.}"
ext=$(echo "$ext" | tr '[:upper:]' '[:lower:]')
case "$ext" in
jpg|jpeg) echo "image/jpeg" ;;
png) echo "image/png" ;;
gif) echo "image/gif" ;;
webp) echo "image/webp" ;;
svg) echo "image/svg+xml" ;;
avif) echo "image/avif" ;;
*) echo "application/octet-stream" ;;
esac
}
_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"]=$(_rfc822_gmt_date "$date")
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
# Example: get_latest_mod_date "$tags_index" 5 "^tag-slug|" "%Y-%m-%d" # Latest for a tag
get_latest_mod_date() {
local index_file="$1"
local date_field_index="${2:-5}" # Default to 5 for lastmod in file_index/tags_index
local filter_pattern="$3" # Optional grep pattern
local date_format="${4:-%Y-%m-%d}" # Default sitemap format
if [ ! -f "$index_file" ]; then
echo "$(format_date "now" "$date_format")" # Fallback to now if index missing
return
fi
local latest_date_str
if [ -n "$filter_pattern" ]; then
# Filter, extract date, sort numerically (YYYY-MM-DD is sortable), get latest
latest_date_str=$(grep -E "$filter_pattern" "$index_file" | cut -d'|' -f"$date_field_index" | sort -r | head -n 1)
else
# Extract date, sort numerically, get latest
latest_date_str=$(cut -d'|' -f"$date_field_index" "$index_file" | sort -r | head -n 1)
fi
if [ -n "$latest_date_str" ]; then
# Attempt to format the found date string
local formatted_date=$(format_date "$latest_date_str" "$date_format")
if [ -n "$formatted_date" ]; then
echo "$formatted_date"
else
# Fallback if format_date fails (e.g., invalid date string)
echo "$(format_date "now" "$date_format")"
fi
else
# Fallback if no matching entries or dates found
echo "$(format_date "now" "$date_format")"
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,
# with each line formatted as: file|filename|title|date|lastmod|tags|slug|image|image_caption|description
# Example Call:
# sorted_posts=$(sort -t'|' -k4,4r "$file_index" | head -n "$rss_item_limit")
# _generate_rss_feed "$rss" "$feed_title" "$feed_desc" "/" "/rss.xml" "$sorted_posts"
_generate_rss_feed() {
local output_file="$1"
local feed_title="$2"
local feed_description="$3"
local feed_link_rel="$4" # Relative link for the channel (e.g., "/" or "/tags/tag-slug/")
local feed_atom_link_rel="$5" # Relative link for the atom:link (e.g., "/rss.xml" or "/tags/tag-slug/rss.xml")
local post_data_input="$6" # String containing post data lines
# Get build timestamp in ISO 8601 for atom:updated fallback
local build_timestamp_iso=$(format_date "now" "%Y-%m-%dT%H:%M:%S%z")
# Convert RFC-2822 timezone (+0000) to ISO 8601 (+00:00) if needed
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
# Ensure output directory exists
mkdir -p "$(dirname "$output_file")"
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=$(_rfc822_gmt_date "now")
exec 4> "$output_file" || return 1
printf '%s\n' \
'<?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>${escaped_feed_title}</title>" \
" <link>${feed_link}</link>" \
" <description>${escaped_feed_description}</description>" \
" <language>${SITE_LANG:-en}</language>" \
" <lastBuildDate>${channel_last_build_date}</lastBuildDate>" \
" <atom:link href=\"${feed_atom_link}\" rel=\"self\" type=\"application/rss+xml\" />" >&4
# Process the provided post data
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
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
# 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
pub_date=$(_rfc822_gmt_date "$date")
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 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="$escaped_title"
local img_title=$(html_escape "$image_caption")
[ -z "$img_title" ] && img_title="$img_alt" # Use alt if title is empty
figure_part="<figure><img src=\"${img_src}\" alt=\"${img_alt}\" title=\"${img_title}\">" # Open tags
if [ -n "$image_caption" ]; then
local escaped_caption=$(html_escape "$image_caption")
caption_part="<figcaption>${escaped_caption}</figcaption>" # Caption
fi
figure_part="${figure_part}${caption_part}</figure>" # Close figure tag (with caption inside if it exists)
fi
# Build content part (excerpt or full)
if [ "${RSS_INCLUDE_FULL_CONTENT:-false}" = true ]; 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=$?
if [ $convert_status -eq 0 ] && [ -n "$converted_html" ]; then
content_part="$converted_html"
else
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"
fi
fi
else
content_part="$description"
fi
# Combine parts safely
item_description_content="${figure_part}${content_part}"
# Wrap final description in CDATA
local final_description="<![CDATA[$item_description_content]]>"
# Determine author for RSS item (with fallback)
local rss_author_name="${author_name:-${AUTHOR_NAME:-Anonymous}}"
local rss_author_email="${author_email}"
# Build author element if we have author info
local author_element=""
if [ -n "$rss_author_name" ]; then
if [ -n "$rss_author_email" ]; then
author_element=" <dc:creator>$(html_escape "$rss_author_name") ($(html_escape "$rss_author_email"))</dc:creator>"
else
author_element=" <dc:creator>$(html_escape "$rss_author_name")</dc:creator>"
fi
fi
# Build enclosure element if image exists
local enclosure_element=""
if [ -n "$image" ]; then
local enclosure_img_path=""
if [[ "$image" == /* ]]; then
enclosure_img_path="${OUTPUT_DIR}${image}"
elif [[ "$image" != http://* && "$image" != https://* ]]; then
enclosure_img_path="${OUTPUT_DIR}/${image}"
fi
if [ -n "$enclosure_img_path" ] && [ -f "$enclosure_img_path" ]; then
local file_size file_mime
if [[ "$BSSG_KERNEL_NAME" == "Darwin" ]] || [[ "$BSSG_KERNEL_NAME" == *"BSD" ]]; then
file_size=$(stat -f "%z" "$enclosure_img_path" 2>/dev/null || echo 0)
else
file_size=$(stat -c "%s" "$enclosure_img_path" 2>/dev/null || echo 0)
fi
if [ "$file_size" -gt 0 ]; then
file_mime=$(_get_mime_type "$image")
enclosure_element=" <enclosure url=\"${img_src}\" length=\"${file_size}\" type=\"${file_mime}\" />"
fi
fi
fi
local rss_item_xml
rss_item_xml=" <item>
<title>${escaped_title}</title>
<link>${full_url}</link>
<guid isPermaLink=\"true\">${full_url}</guid>
<pubDate>${pub_date}</pubDate>
<atom:updated>${updated_date_iso}</atom:updated>
<description>${final_description}</description>
${enclosure_element}
"
if [ -n "$author_element" ]; then
rss_item_xml+="${author_element}"$'\n'
fi
rss_item_xml+=" </item>
"
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
printf '%s\n' '</channel>' '</rss>' >&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
}
export -f _generate_rss_feed # Export for potential parallel use or sourcing
# Generate RSS feed (Main site feed)
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
if [ -z "${MD5_CMD:-}" ]; then
echo -e "${RED}Error: MD5_CMD is not set.${NC}" >&2; return 1; fi
if [ -z "${CACHE_DIR:-}" ]; then
echo -e "${RED}Error: CACHE_DIR is not set.${NC}" >&2; return 1; fi
local rss="$OUTPUT_DIR/${RSS_FILENAME:-rss.xml}"
local file_index="$CACHE_DIR/file_index.txt"
local config_hash_file="$CONFIG_HASH_FILE"
local script_path="$BSSG_SCRIPT_DIR/build/generate_feeds.sh"
# Determine active locale file
local active_locale_file=""
if [ -f "${LOCALE_DIR:-locales}/${SITE_LANG:-en}.sh" ]; then
active_locale_file="${LOCALE_DIR:-locales}/${SITE_LANG:-en}.sh"
elif [ -f "${LOCALE_DIR:-locales}/en.sh" ]; then
active_locale_file="${LOCALE_DIR:-locales}/en.sh"
fi
# Check if RSS feed needs to be rebuilt (Simplified check)
local rebuild_needed=false
if [ "${FORCE_REBUILD:-false}" = true ]; then
rebuild_needed=true
elif [ ! -f "$rss" ]; then
rebuild_needed=true # Rebuild if RSS file doesn't exist
else
local rss_mtime=$(get_file_mtime "$rss")
# Check file index mtime AND config hash mtime
if { [ -f "$file_index" ] && [ "$(get_file_mtime "$file_index")" -gt "$rss_mtime" ]; } || \
{ [ -f "$config_hash_file" ] && [ "$(get_file_mtime "$config_hash_file")" -gt "$rss_mtime" ]; }; then \
rebuild_needed=true
fi
# Removed checks for script, locale mtime for simplicity, kept config hash check
fi
# If no rebuild needed, skip
if [ "$rebuild_needed" = false ]; then
echo -e "${GREEN}Main RSS feed is up to date (based on file index), skipping...${NC}"
return 0
fi
if [ ! -f "$file_index" ]; then
echo -e "${RED}Error: File index '$file_index' not found. Cannot generate RSS feed.${NC}"
return 1
fi
# Prepare data for the reusable function
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}" # Use the config variable
local rss_item_limit=${RSS_ITEM_LIMIT:-15}
# Read file_index.txt, sort by original date (field 4), take top N
# Use lastmod (field 5) as secondary sort key if dates are identical (optional, but good practice)
local sorted_posts
sorted_posts=$(sort -t'|' -k4,4r -k5,5r "$file_index" | head -n "$rss_item_limit")
# Call the reusable function
# echo "DEBUG: In generate_rss, RSS_FILENAME='${RSS_FILENAME:-rss.xml}', output_file='${rss}'" >&2 # DEBUG
_generate_rss_feed "$rss" "$feed_title" "$feed_desc" "$feed_link_rel" "$feed_atom_link_rel" "$sorted_posts"
# The reusable function already prints the success message
# echo -e "${GREEN}RSS feed generated!${NC}" # Redundant now
}
# Export public functions
export -f generate_rss
# Generate sitemap.xml
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"
local authors_index="$CACHE_DIR/authors_index.txt"
local primary_pages_cache="$CACHE_DIR/primary_pages.tmp"
local secondary_pages_cache="$CACHE_DIR/secondary_pages.tmp"
local config_hash_file="$CONFIG_HASH_FILE" # Use the global var
local script_path="$BSSG_SCRIPT_DIR/build/generate_feeds.sh" # Path to this script
local sitemap_date_fmt="%Y-%m-%d"
# Determine active locale file
local active_locale_file=""
if [ -f "${LOCALE_DIR:-locales}/${SITE_LANG:-en}.sh" ]; then
active_locale_file="${LOCALE_DIR:-locales}/${SITE_LANG:-en}.sh"
elif [ -f "${LOCALE_DIR:-locales}/en.sh" ]; then # Fallback to en
active_locale_file="${LOCALE_DIR:-locales}/en.sh"
fi
# Check if sitemap needs rebuild (Simplified check)
local rebuild_needed=false
if [ "${FORCE_REBUILD:-false}" = true ]; then
rebuild_needed=true
elif [ ! -f "$sitemap" ]; then
rebuild_needed=true # Rebuild if sitemap doesn't exist
else
local sitemap_mtime=$(get_file_mtime "$sitemap")
# Check main content index files
if [ -f "$file_index" ] && [ "$(get_file_mtime "$file_index")" -gt "$sitemap_mtime" ]; then rebuild_needed=true; fi
if ! $rebuild_needed && [ -f "$tags_index" ] && [ "$(get_file_mtime "$tags_index")" -gt "$sitemap_mtime" ]; then rebuild_needed=true; fi
if ! $rebuild_needed && [ -f "$authors_index" ] && [ "$(get_file_mtime "$authors_index")" -gt "$sitemap_mtime" ]; then rebuild_needed=true; fi
if ! $rebuild_needed && [ -f "$primary_pages_cache" ] && [ "$(get_file_mtime "$primary_pages_cache")" -gt "$sitemap_mtime" ]; then rebuild_needed=true; fi
if ! $rebuild_needed && [ -f "$secondary_pages_cache" ] && [ "$(get_file_mtime "$secondary_pages_cache")" -gt "$sitemap_mtime" ]; then rebuild_needed=true; fi
# Removed checks for script, config, locale mtime for simplicity to avoid sourcing errors
fi
# If no rebuild needed based on simple checks, skip
if [ "$rebuild_needed" = false ]; then
echo -e "${GREEN}Sitemap is up to date (based on content indexes), skipping...${NC}"
return 0
fi
# --- Pre-calculate latest dates (Still needed for Homepage/Tags/Authors) ---
local latest_post_mod_date=$(get_latest_mod_date "$file_index" 5 "" "$sitemap_date_fmt")
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
echo "Generating sitemap content using awk..."
_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 _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 _rfc822_gmt_date _get_mime_type
export -f generate_sitemap generate_rss

705
scripts/build/generate_index.sh Executable file
View file

@ -0,0 +1,705 @@
#!/usr/bin/env bash
#
# BSSG - Index/Pagination Generation
# Handles the creation of the main index.html and paginated index pages.
#
# Source dependencies
# shellcheck source=utils.sh disable=SC1091
source "$(dirname "$0")/utils.sh" || { echo >&2 "Error: Failed to source utils.sh from generate_index.sh"; exit 1; }
# 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 <<EOF
<script type="application/ld+json">
{
"@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"
}
}
</script>
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 <<EOF
<script type="application/ld+json">
{
"@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"
}
}
</script>
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//\{\{twitter_card\}\}/"summary"}
page_header=${page_header//\{\{fediverse_creator_meta\}\}/"${SITE_FEDIVERSE_CREATOR_META_TAG}"}
page_header=${page_header//\{\{canonical\}\}/}
page_header=${page_header//\{\{featured_image_preload\}\}/}
local page_footer="$FOOTER_TEMPLATE"
page_footer=${page_footer//\{\{current_year\}\}/$(date +%Y)}
page_footer=${page_footer//\{\{author_name\}\}/"$AUTHOR_NAME"}
cat > "$output_file" <<EOF
$page_header
EOF
local index_file="${PAGES_DIR}/index.md"
local has_custom_index=false
if [ "${BSSG_RAM_MODE:-false}" = true ] && declare -F ram_mode_has_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" <<EOF
$page_footer
EOF
continue
fi
if [ "$total_posts_orig" -gt 0 ]; then
cat >> "$output_file" <<EOF
<h1>${MSG_LATEST_POSTS:-"Latest Posts"}</h1>
<div class="posts-list">
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" <<EOF
<article>
<h2><a href="$(fix_url "$post_link")">$title</a></h2>
<div class="meta">${MSG_PUBLISHED_ON:-"Published on"} $formatted_date${author_name:+" ${MSG_BY:-"by"} ${author_name:-$AUTHOR_NAME}"}</div>
EOF
if [ -n "$image" ]; then
local image_url="$image"
if [[ "$image" == /* ]]; then
image_url="${SITE_URL}${image}"
fi
cat >> "$output_file" <<EOF
<div class="featured-image index-image">
<a href="$(fix_url "$post_link")">
<img src="$image_url" alt="${image_caption:-$title}" title="${image_caption:-$title}" loading="lazy" />
</a>
</div>
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" <<EOF
<div class="post-content">
$html_content
</div>
EOF
fi
elif [ "${SHOW_INDEX_DESCRIPTIONS:-true}" = "true" ] && [ -n "$description" ]; then
cat >> "$output_file" <<EOF
<div class="summary">
$description
</div>
EOF
fi
cat >> "$output_file" <<EOF
</article>
EOF
done
cat >> "$output_file" <<EOF
</div> <!-- .posts-list -->
EOF
if [ "$total_pages" -gt 1 ]; then
cat >> "$output_file" <<EOF
<!-- Pagination -->
<div class="pagination">
EOF
if [ "$current_page" -gt 1 ]; then
local prev_page=$((current_page - 1))
local prev_url="/"
if [ $prev_page -ne 1 ]; then
prev_url="/page/$prev_page/"
fi
cat >> "$output_file" <<PAG_EOF
<a href="$(fix_url "$prev_url")" class="prev">&laquo; ${MSG_NEWER_POSTS:-Newer}</a>
PAG_EOF
fi
cat >> "$output_file" <<PAG_EOF
<span class="page-info">$(printf "${MSG_PAGE_INFO_TEMPLATE:-Page %d of %d}" "$current_page" "$total_pages")</span>
PAG_EOF
if [ "$current_page" -lt "$total_pages" ]; then
local next_page=$((current_page + 1))
cat >> "$output_file" <<PAG_EOF
<a href="$(fix_url "/page/$next_page/")" class="next">${MSG_OLDER_POSTS:-Older} &raquo;</a>
PAG_EOF
fi
cat >> "$output_file" <<EOF
</div>
EOF
fi
fi
cat >> "$output_file" <<EOF
$page_footer
EOF
done
echo -e "${GREEN}Index pages processed!${NC}"
}
# Generate main index page (homepage) and paginated pages
generate_index() {
if [ "${BSSG_RAM_MODE:-false}" = true ]; then
_generate_index_ram
return $?
fi
echo -e "${YELLOW}Generating index pages...${NC}"
# Check if rebuild is needed (using function from cache.sh)
if ! indexes_need_rebuild; then
echo -e "${GREEN}Index pages are up to date, skipping...${NC}"
return
fi
# Define the index page paths
local file_index="$CACHE_DIR/file_index.txt"
# Check if file index exists
if [ ! -f "$file_index" ]; then
echo -e "${RED}Error: File index $file_index not found. Cannot generate index pages.${NC}"
return 1
fi
# Count total posts
local total_posts_orig=$(wc -l < "$file_index")
local total_posts=$total_posts_orig
local total_pages=$(( (total_posts + POSTS_PER_PAGE - 1) / POSTS_PER_PAGE ))
# Ensure total_pages is at least 1 even if total_posts is 0
if [ $total_pages -eq 0 ]; then
total_pages=1
fi
echo -e "Generating ${GREEN}$total_pages${NC} index pages for ${GREEN}$total_posts${NC} posts"
# Prepare templates (already exported, but good to have locally)
local header_content="$HEADER_TEMPLATE"
local footer_content="$FOOTER_TEMPLATE"
# Define function to process a single index page
process_index_page() {
# Ensure current_page is treated as an integer
local -i current_page="$1"
local -i total_pages="$2"
local file_index="$3"
local -i total_posts_orig="$4"
# Template content is accessed via exported global variables
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
# Skip if index page file is up to date relative to file index
if ! file_needs_rebuild "$file_index" "$output_file"; then
echo -e "Skipping unchanged index page $current_page"
return 0
fi
# Replace placeholders in the header
local page_header="$HEADER_TEMPLATE"
page_header=${page_header//\{\{site_title\}\}/"$SITE_TITLE"}
if [ $current_page -eq 1 ]; then
# For the homepage
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"}
# Create WebSite schema for homepage
local home_url="${SITE_URL}/"
local schema_json_ld=""
local tmp_schema=$(mktemp)
cat > "$tmp_schema" << EOF
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "WebSite",
"name": "$SITE_TITLE",
"description": "$SITE_DESCRIPTION",
"url": "$home_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"
}
}
</script>
EOF
schema_json_ld=$(cat "$tmp_schema")
rm "$tmp_schema"
page_header=${page_header//\{\{schema_json_ld\}\}/"$schema_json_ld"}
else
# For pagination pages
local pag_title=$(printf "${MSG_PAGINATION_TITLE:-"%s - Page %d"}" "$SITE_TITLE" "$current_page")
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"}
# Create CollectionPage schema for paginated pages
local schema_json_ld=""
local tmp_schema=$(mktemp)
cat > "$tmp_schema" << EOF
<script type="application/ld+json">
{
"@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"
}
}
</script>
EOF
schema_json_ld=$(cat "$tmp_schema")
rm "$tmp_schema"
page_header=${page_header//\{\{schema_json_ld\}\}/"$schema_json_ld"}
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//\{\{twitter_card\}\}/"summary"}
page_header=${page_header//\{\{fediverse_creator_meta\}\}/"${SITE_FEDIVERSE_CREATOR_META_TAG}"}
page_header=${page_header//\{\{canonical\}\}/}
page_header=${page_header//\{\{featured_image_preload\}\}/}
# Replace placeholders in the footer
local page_footer="$FOOTER_TEMPLATE"
page_footer=${page_footer//\{\{current_year\}\}/$(date +%Y)}
page_footer=${page_footer//\{\{author_name\}\}/"$AUTHOR_NAME"}
# Create the index page
cat > "$output_file" << EOF
$page_header
EOF
# If there is an index.md, use that
if [ -f "${PAGES_DIR}/index.md" ]; then
local input_file="${PAGES_DIR}/index.md"
local title=$(parse_metadata "$input_file" "title")
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)
# Extract content after the second --- line
content=$(tail -n +$((end_line + 1)) $input_file)
html_content=$(convert_markdown_to_html "$content")
echo "$html_content" >> $output_file
echo -e "${GREEN}Used custom index.md with title '$title' as the sole homepage content.${NC}"
# Append footer and finish for the homepage
cat >> "$output_file" << EOF
$page_footer
EOF
# echo -e "Generated custom index page ${GREEN}$current_page${NC}" # Optional: Specific message
return 0 # Successfully generated custom index page, skip post listing
else
# No index.md found, proceed with standard "Latest Posts" logic
# Only add "Latest Posts" section if there are actually posts
if [ "$total_posts_orig" -gt 0 ]; then
cat >> "$output_file" << EOF
<h1>${MSG_LATEST_POSTS:-"Latest Posts"}</h1>
<div class="posts-list">
EOF
# Calculate start and end indices
local start_index=$(( (current_page - 1) * POSTS_PER_PAGE + 1 ))
local end_index=$(( current_page * POSTS_PER_PAGE ))
# Add posts to the index page
awk -v start="$start_index" -v end="$end_index" 'NR >= start && NR <= end { print }' "$file_index" | while IFS='|' read -r file filename title date lastmod tags slug image image_caption description author_name author_email; do
# ... (rest of the post item generation logic remains the same) ...
if [ -z "$file" ] || [ -z "$title" ] || [ -z "$date" ]; then
continue
fi
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=$(awk -v m="$((10#${BASH_REMATCH[2]}))" 'BEGIN { printf "%02d", m }')
post_day=$(awk -v d="$((10#${BASH_REMATCH[3]}))" 'BEGIN { printf "%02d", d }')
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 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=$(format_date "$date" "$display_date_format")
local post_link="/$formatted_path/"
cat >> "$output_file" << EOF
<article>
<h2><a href="$(fix_url "$post_link")">$title</a></h2>
<div class="meta">${MSG_PUBLISHED_ON:-"Published on"} $formatted_date${author_name:+" ${MSG_BY:-"by"} ${author_name:-$AUTHOR_NAME}"}</div>
EOF
if [ -n "$image" ]; then
local image_url="$image"
if [[ "$image" == /* ]]; then image_url="${SITE_URL}${image}"; fi
cat >> "$output_file" << EOF
<div class="featured-image index-image">
<a href="$(fix_url "$post_link")">
<img src="$image_url" alt="${image_caption:-$title}" title="${image_caption:-$title}" />
</a>
</div>
EOF
fi
# 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.*>/,/<\/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
<div class="post-content">
$html_content
</div>
EOF
fi
elif [ "${SHOW_INDEX_DESCRIPTIONS:-true}" = "true" ] && [ -n "$description" ]; then
# Show just the description/excerpt (default behavior)
cat >> "$output_file" << EOF
<div class="summary">
$description
</div>
EOF
fi
cat >> "$output_file" << EOF
</article>
EOF
done # End of while loop reading posts
# Close the posts list div
cat >> "$output_file" << EOF
</div> <!-- .posts-list -->
EOF
# Pagination logic (Only needed if there were posts)
if [ "$total_pages" -gt 1 ]; then
cat >> "$output_file" << EOF
<!-- Pagination -->
<div class="pagination">
EOF
if [ "$current_page" -gt 1 ]; then
local prev_page=$((current_page - 1))
local prev_url="/"
if [ $prev_page -ne 1 ]; then prev_url="/page/$prev_page/"; fi
cat >> "$output_file" << PAG_EOF
<a href="$(fix_url "$prev_url")" class="prev">&laquo; ${MSG_NEWER_POSTS:-Newer}</a>
PAG_EOF
fi
cat >> "$output_file" << PAG_EOF
<span class="page-info">$(printf "${MSG_PAGE_INFO_TEMPLATE:-Page %d of %d}" "$current_page" "$total_pages")</span>
PAG_EOF
if [ "$current_page" -lt "$total_pages" ]; then
local next_page=$((current_page + 1))
cat >> "$output_file" << PAG_EOF
<a href="$(fix_url "/page/$next_page/")" class="next">${MSG_OLDER_POSTS:-Older} &raquo;</a>
PAG_EOF
fi
cat >> "$output_file" << EOF
</div>
EOF
fi # End pagination check
else
# No index.md and no posts - display a message or leave blank?
# Currently implies a blank content area between header/footer.
echo "No posts found and no custom index.md; homepage will be mostly empty."
fi # End of if total_posts_orig > 0
# Add footer (always needed in the 'else' case)
cat >> "$output_file" << EOF
$page_footer
EOF
fi # End of if [ -f "${PAGES_DIR}/index.md" ] ... else ...
# This message will now only be reached if index.md was NOT used.
echo -e "Generated index page ${GREEN}$current_page${NC} of ${GREEN}$total_pages${NC}"
}
# 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
cores=$(get_parallel_jobs)
# Use all detected cores
local jobs=$cores
# Export required functions and variables
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 INDEX_SHOW_FULL_CONTENT
export SHOW_INDEX_DESCRIPTIONS
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 convert_markdown_to_html
# Ensure templates are exported
if [ -z "$HEADER_TEMPLATE" ] || [ -z "$FOOTER_TEMPLATE" ]; then
echo -e "${RED}Error: Header or Footer template not loaded/exported correctly.${NC}"
return 1
fi
# Process pages in parallel, passing total_posts_orig as the 4th argument
seq 1 $total_pages | parallel --jobs $jobs --will-cite process_index_page {} $total_pages "$file_index" $total_posts_orig || { echo -e "${RED}Parallel index page generation failed.${NC}"; exit 1; }
else
# Sequential implementation
echo -e "${YELLOW}Using sequential processing${NC}"
local current_page=1
while [ "$current_page" -le "$total_pages" ]; do
process_index_page $current_page $total_pages "$file_index" $total_posts_orig
current_page=$((current_page + 1))
done
fi
echo -e "${GREEN}Index pages processed!${NC}"
}
# Make the function available for sourcing
export -f generate_index

276
scripts/build/generate_pages.sh Executable file
View file

@ -0,0 +1,276 @@
#!/usr/bin/env bash
#
# BSSG - Static Page Generation
# Functions for converting markdown/HTML pages.
#
# Source dependencies
# shellcheck source=utils.sh disable=SC1091
source "$(dirname "$0")/utils.sh" || { echo >&2 "Error: Failed to source utils.sh from generate_pages.sh"; exit 1; }
# shellcheck source=content.sh disable=SC1091
source "$(dirname "$0")/content.sh" || { echo >&2 "Error: Failed to source content.sh from generate_pages.sh"; exit 1; }
# shellcheck source=cache.sh disable=SC1091
source "$(dirname "$0")/cache.sh" || { echo >&2 "Error: Failed to source cache.sh from generate_pages.sh"; exit 1; } # For file_needs_rebuild checks etc.
# --- Moved Function Definitions --- START ---
# Convert a page (Markdown or HTML) to final HTML output
convert_page() {
local input_file="$1"
local output_base_path="$2"
local title="$3"
local date="$4"
local slug="$5"
# 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 ! $ram_mode_active && [ ! -f "$input_file" ]; then
echo -e "${RED}Error: Source page '$input_file' not found${NC}" >&2
return 1
fi
# Skip if output file is newer than input file and no force rebuild
# Uses file_needs_rebuild from cache.sh
if ! file_needs_rebuild "$input_file" "$output_html_file"; then
echo -e "Skipping unchanged page: ${YELLOW}$(basename "$input_file")${NC}"
return 0
fi
echo -e "Processing page: ${GREEN}$(basename "$input_file")${NC}"
local content="" # Content for reading time calculation (if markdown)
local html_content="" # Final body HTML content
if [[ "$input_file" == *.html ]]; then
# For HTML files, extract content between <body> tags (simple approach)
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>/,/<\/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 source_stream=""
if $ram_mode_active; then
source_stream=$(ram_mode_get_content "$input_file")
else
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
html_content=$(convert_markdown_to_html "$content")
if [ $? -ne 0 ]; then
echo -e "${RED}Markdown conversion failed for page '$input_file', skipping html generation.${NC}" >&2
return 1 # Propagate the error
fi
# --- MODIFIED PART --- END ---
fi
# Calculate reading time (best effort for HTML input)
local reading_time
reading_time=$(calculate_reading_time "$content")
# Use pre-loaded templates
# IMPORTANT: Assumes HEADER_TEMPLATE, FOOTER_TEMPLATE are exported/available
local header_content="$HEADER_TEMPLATE"
local footer_content="$FOOTER_TEMPLATE"
# Verify templates are not empty
if [ -z "$header_content" ] || [ -z "$footer_content" ]; then
echo -e "${RED}Error: Templates are empty in convert_page. Was templates.sh sourced correctly?${NC}" >&2
return 1
fi
# Replace placeholders in the header
header_content=${header_content//\{\{site_title\}\}/"$SITE_TITLE"}
header_content=${header_content//\{\{page_title\}\}/"$title"}
header_content=${header_content//\{\{site_description\}\}/"$SITE_DESCRIPTION"}
header_content=${header_content//\{\{og_description\}\}/"$SITE_DESCRIPTION"} # Use site description for pages
header_content=${header_content//\{\{twitter_description\}\}/"$SITE_DESCRIPTION"}
header_content=${header_content//\{\{og_type\}\}/"website"} # Pages are usually 'website' type
header_content=${header_content//\{\{site_url\}\}/"$SITE_URL"}
# Construct page URL based on format, ensuring trailing slash
local formatted_page_path="${PAGE_URL_FORMAT//slug/$slug}"
local page_rel_url="/$(echo "$formatted_page_path" | sed 's|^/||; s|/*$|/|')"
header_content=${header_content//\{\{page_url\}\}/"$page_rel_url"}
# Remove schema if it exists, or set default schema for WebPage
local page_full_url="${SITE_URL}${page_rel_url}" # Construct full URL
local schema_json_ld=$(printf '<script type="application/ld+json">\n{\n "@context": "https://schema.org",\n "@type": "WebPage",\n "name": "%s",\n "url": "%s",\n "isPartOf": {\n "@type": "WebSite",\n "name": "%s",\n "url": "%s"\n }\n}\n</script>' \
"$(echo "$title" | sed 's/"/\\"/g')" \
"$page_full_url" \
"$SITE_TITLE" \
"$SITE_URL")
header_content=${header_content//\{\{schema_json_ld\}\}/"$schema_json_ld"}
# 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//\{\{twitter_card\}\}/"summary"}
header_content=${header_content//\{\{fediverse_creator_meta\}\}/"${SITE_FEDIVERSE_CREATOR_META_TAG}"}
# Add canonical link
local canonical_tag="<link rel=\"canonical\" href=\"${SITE_URL}${page_rel_url}\">"
header_content=${header_content//\{\{canonical\}\}/"$canonical_tag"}
header_content=${header_content//\{\{featured_image_preload\}\}/}
# Assemble the final HTML
local final_html="${header_content}"
# Add page title and content (no post-meta for pages usually)
final_html+=$(printf '<article class="page">\n <h1>%s</h1>\n <div class="page-content">\n%s\n </div>\n</article>\n' "$title" "$html_content")
# Replace placeholders in footer content before appending
local current_year=$(date +'%Y')
footer_content=${footer_content//\{\{current_year\}\}/$current_year}
footer_content=${footer_content//\{\{author_name\}\}/${AUTHOR_NAME:-Anonymous}}
# Append footer
final_html+="${footer_content}"
# Create output directory if it doesn't exist
mkdir -p "$output_base_path"
# Write the final HTML to the output file
printf '%s' "$final_html" > "$output_html_file"
}
# Define a function for processing a single page file
process_single_page_file() {
local file="$1"
# Extract metadata (title, date, slug)
# IMPORTANT: Assumes parse_metadata is available (content.sh)
local title slug date
if [[ "$file" == *.html ]]; then
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/')
else
title=$(parse_metadata "$file" "title")
slug=$(parse_metadata "$file" "slug")
date=$(parse_metadata "$file" "date") # Date might be optional for pages
fi
# Fallback/Defaults
if [ -z "$title" ]; then title=$(basename "$file" | sed 's/\.[^.]*$//'); fi
if [ -z "$slug" ]; then slug=$(generate_slug "$title"); fi # Use generate_slug (utils.sh)
# Create output path based on PAGE_URL_FORMAT
# IMPORTANT: Assumes PAGE_URL_FORMAT, OUTPUT_DIR are exported/available
local formatted_path="${PAGE_URL_FORMAT//slug/$slug}"
# Ensure the path represents the directory for index.html
local output_path="${OUTPUT_DIR:-output}/$(echo "$formatted_path" | sed 's|^/||; s|/*$||')"
# Call the modified convert_page function (defined above in this script)
convert_page "$file" "$output_path" "$title" "$date" "$slug"
}
# --- Moved Function Definitions --- END ---
# --- Page Generation Functions --- START ---
# Process all pages found in the PAGES_DIR
process_all_pages() {
echo -e "${YELLOW}Processing static pages...${NC}"
# IMPORTANT: Assumes PAGES_DIR is exported/available
if [ ! -d "${PAGES_DIR:-pages}" ]; then
echo -e "${YELLOW}Pages directory ('${PAGES_DIR:-pages}') not found, skipping page processing.${NC}"
return 0
fi
# Use mapfile -t to read sorted files into array (newline-separated, trailing newline stripped)
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
echo -e "${YELLOW}No pages found in '${PAGES_DIR:-pages}'. Skipping page generation.${NC}"
return 0
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
elif [ "${HAS_PARALLEL:-false}" = true ]; then
echo -e "${GREEN}Using GNU parallel to generate pages${NC}"
# Determine number of cores
local cores
cores=$(get_parallel_jobs)
# Export functions needed by the parallel process and its children
export -f convert_page process_single_page_file
# Export necessary dependencies from sourced scripts
export -f calculate_reading_time file_needs_rebuild convert_markdown_to_html parse_metadata generate_slug
export -f common_rebuild_check config_has_changed # from cache.sh
export -f portable_md5sum get_file_mtime format_date fix_url # from utils.sh
# Export necessary variables for cache checks and template paths
export OUTPUT_DIR CACHE_DIR TEMPLATES_DIR THEME LOCALE_DIR SITE_LANG FORCE_REBUILD HEADER_TEMPLATE FOOTER_TEMPLATE
export CONFIG_HASH_FILE # Export path to hash file
# Process page files in parallel using newline separation
printf '%s\n' "${page_files[@]}" | parallel --jobs "$cores" --will-cite process_single_page_file {}
else
# Fallback to sequential processing
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
echo -e "${GREEN}Static page processing complete!${NC}"
}
# --- Page Generation Functions --- END ---

863
scripts/build/generate_posts.sh Executable file
View file

@ -0,0 +1,863 @@
#!/usr/bin/env bash
#
# BSSG - Post Generation
# Functions for converting markdown posts to HTML.
#
# Source dependencies
# shellcheck source=utils.sh disable=SC1091
source "$(dirname "$0")/utils.sh" || { echo >&2 "Error: Failed to source utils.sh from generate_posts.sh"; exit 1; }
# shellcheck source=content.sh disable=SC1091
source "$(dirname "$0")/content.sh" || { echo >&2 "Error: Failed to source content.sh from generate_posts.sh"; exit 1; }
# shellcheck source=cache.sh disable=SC1091
source "$(dirname "$0")/cache.sh" || { echo >&2 "Error: Failed to source cache.sh from generate_posts.sh"; exit 1; } # For file_needs_rebuild checks etc.
# shellcheck source=related_posts.sh disable=SC1091
source "$(dirname "$0")/related_posts.sh" || { echo >&2 "Error: Failed to source related_posts.sh from generate_posts.sh"; exit 1; } # For related posts functionality
# --- 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"
local output_base_path="$2"
local title="$3"
local date="$4"
local lastmod="$5"
local tags="$6"
local slug="$7"
local image="$8"
local image_caption="$9"
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 ! $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.
# 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
if [ "${BSSG_RAM_MODE:-false}" != true ] || [ "${RAM_MODE_VERBOSE:-false}" = true ]; then
echo -e "Processing post: ${GREEN}$(basename "$input_file")${NC}"
fi
# 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 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 ! $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
# Calculate reading time
local reading_time=0
if [ "${SHOW_READING_TIME:-true}" = "true" ]; then
reading_time=$(calculate_reading_time "$content")
fi
# Convert markdown content to HTML (No HTML caching here anymore)
local html_content
if [[ "$input_file" == *.html ]]; then
# For HTML files, extract content between <body> tags (simple approach)
# Assumes content is already HTML
html_content=$(sed -n '/<body.*>/,/<\/body>/p' "$input_file" | sed '1d;$d')
# echo -e "Extracted body content from HTML file: ${GREEN}$(basename "$input_file")${NC}" # Can be verbose
elif [[ "$input_file" == *.md ]]; then
# Original Markdown conversion using the raw content we extracted/cached
# This now uses the content *without* frontmatter
html_content=$(convert_markdown_to_html "$content")
if [ $? -ne 0 ]; then
echo -e "${RED}Markdown conversion failed for '$input_file', skipping html generation.${NC}" >&2
# Optionally delete the output file if it exists from a previous run?
# rm -f "$output_html_file"
return 1
fi
else
echo -e "${RED}Error: Unknown input file type '$input_file' for content conversion.${NC}" >&2
return 1
fi
# Create HTML tags for tags
local tags_html=""
if [ -n "$tags" ]; then
tags_html="<div class=\"tags\">"
IFS=',' read -ra TAG_ARRAY <<< "$tags"
for tag in "${TAG_ARRAY[@]}"; do
tag=$(echo "$tag" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
[[ -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+=" <a href=\"${SITE_URL:-}/tags/${tag_slug}/\" class=\"tag\">${tag}</a>"
fi
done
tags_html+="</div>"
fi
# Use pre-loaded templates
local header_content="$HEADER_TEMPLATE"
local footer_content="$FOOTER_TEMPLATE"
# Verify templates are not empty
if [ -z "$header_content" ] || [ -z "$footer_content" ]; then
echo -e "${RED}Error: Header or Footer template is empty. Was templates.sh sourced correctly?${NC}" >&2
return 1
fi
# Replace placeholders in the header
header_content=${header_content//\{\{site_title\}\}/"$SITE_TITLE"}
header_content=${header_content//\{\{page_title\}\}/"$title"}
header_content=${header_content//\{\{og_type\}\}/"article"}
header_content=${header_content//\{\{site_url\}\}/"$SITE_URL"}
# Construct page URL based on format
local page_url=""
if [ -n "$date" ]; then
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) # Fallback
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}"
# Ensure relative page_url starts with / and ends with /
page_url="/$(echo "$url_path" | sed 's|^/||; s|/*$|/|')"
else
# Ensure relative page_url starts with / and ends with / for slug-only urls
page_url="/$(echo "$slug" | sed 's|^/||; s|/*$|/|')"
fi
header_content=${header_content//\{\{page_url\}\}/"$page_url"}
header_content=${header_content//\{\{site_description\}\}/"$SITE_DESCRIPTION"}
# Trim whitespace from post description
local meta_desc
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" == *"</head>"* ]]; then
header_content=${header_content/<\/head>/$'\n'"$fediverse_creator_meta_tag"$'\n''</head>'}
else
header_content+=$'\n'"$fediverse_creator_meta_tag"
fi
fi
# Generate Schema.org JSON-LD for articles
local schema_json_ld=""
local iso_date=""
local iso_lastmod_date=""
if [ -n "$date" ]; then
iso_date=$(format_iso8601_post_date "$date")
# Use date as fallback for lastmod, then format
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
now_iso=$(format_iso8601_post_date "now")
iso_date="$now_iso"
iso_lastmod_date="$now_iso"
fi
local image_url=""
if [ -n "$image" ]; then
image_url=$(fix_url "$image")
fi
# Create JSON-LD using post-specific author info
local post_author_name="${author_name:-${AUTHOR_NAME:-Anonymous}}"
local author_json
author_json=$(printf '{\n "@type": "Person",\n "name": "%s"\n }' "$post_author_name")
schema_json_ld=$(printf '<script type="application/ld+json">\n{\n "@context": "https://schema.org",\n "@type": "BlogPosting",\n "headline": "%s",\n "datePublished": "%s",\n "dateModified": "%s",\n "author": %s,\n "publisher": {\n "@type": "Organization",\n "name": "%s",\n "logo": {\n "@type": "ImageObject",\n "url": "%s/logo.png"\n }\n },\n "description": "%s",\n "mainEntityOfPage": {\n "@type": "WebPage",\n "@id": "%s%s"\n }%s\n}\n</script>' \
"$(echo "$title" | sed 's/"/\"/g')" \
"$iso_date" \
"$iso_lastmod_date" \
"$author_json" \
"$SITE_TITLE" \
"$SITE_URL" \
"$(echo "$meta_desc" | sed 's/"/\"/g')" \
"$SITE_URL" "$page_url" \
"${image_url:+,
\"image\": {
\"@type\": \"ImageObject\",
\"url\": \"$image_url\"
}}")
fi
header_content=${header_content//\{\{schema_json_ld\}\}/"$schema_json_ld"}
# Handle image placeholders
if [ -n "$image_url" ]; then
local og_image_tag="<meta property=\"og:image\" content=\"$image_url\">"
local twitter_image_tag="<meta name=\"twitter:image\" content=\"$image_url\">"
header_content=${header_content//\{\{og_image\}\}/"$og_image_tag"}
header_content=${header_content//\{\{twitter_image\}\}/"$twitter_image_tag"}
header_content=${header_content//\{\{twitter_card\}\}/"summary_large_image"}
else
header_content=${header_content//\{\{og_image\}\}/}
header_content=${header_content//\{\{twitter_image\}\}/}
header_content=${header_content//\{\{twitter_card\}\}/"summary"}
fi
# Handle canonical link
local canonical_tag="<link rel=\"canonical\" href=\"${SITE_URL}${page_url}\">"
header_content=${header_content//\{\{canonical\}\}/"$canonical_tag"}
# Handle featured image preload for LCP optimization
if [ -n "$image_url" ]; then
header_content=${header_content//\{\{featured_image_preload\}\}/"<link rel=\"preload\" as=\"image\" href=\"$image_url\">"}
else
header_content=${header_content//\{\{featured_image_preload\}\}/}
fi
# Construct meta div (date, reading time, lastmod)
# Determine the date format based on SHOW_TIMEZONE
local display_date_format="$DATE_FORMAT"
if [ "${SHOW_TIMEZONE:-false}" = false ]; then
# Remove timezone format specifiers (%z or %Z) if they exist
display_date_format=$(echo "$display_date_format" | sed -e 's/%[zZ]//g' -e 's/[[:space:]]*$//')
fi
local formatted_date=$(format_date "$date" "$display_date_format")
local formatted_lastmod=$(format_date "$lastmod" "$display_date_format")
local post_meta="<div class=\"page-meta\">"
post_meta+="<p class=\"meta\">"
post_meta+="${MSG_PUBLISHED_ON:-Published on}: <time datetime=\"$iso_date\">$formatted_date</time> ${MSG_BY:-by} <strong>$display_author_name</strong>"
post_meta+="</p>"
if [ "${SHOW_READING_TIME:-true}" = "true" ]; then
local post_meta_reading_time
post_meta_reading_time=$(printf "${MSG_READING_TIME_TEMPLATE:-%d min read}" "$reading_time")
if [ "$formatted_date" != "$formatted_lastmod" ]; then
post_meta+="<p class=\"meta reading-time\">"
post_meta+="${MSG_UPDATED_ON:-Updated on}: <time datetime=\"$iso_lastmod_date\">$formatted_lastmod</time> &bull; $post_meta_reading_time"
post_meta+="</p>"
else
post_meta+="<p class=\"meta reading-time\">$post_meta_reading_time</p>"
fi
elif [ "$formatted_date" != "$formatted_lastmod" ]; then
post_meta+="<p class=\"meta\">"
post_meta+="${MSG_UPDATED_ON:-Updated on}: <time datetime=\"$iso_lastmod_date\">$formatted_lastmod</time>"
post_meta+="</p>"
fi
post_meta+="</div>"
# Construct featured image HTML
local image_html=""
if [ -n "$image" ]; then
local alt_text="${image_caption:-$title}"
image_html="<div class=\"featured-image\"><img src=\"$(fix_url "$image")\" alt=\"$alt_text\" fetchpriority=\"high\"><div class=\"image-caption\">${image_caption:-$title}</div></div>"
fi
# Generate related posts if enabled and tags exist
local related_posts_html=""
if [ "${ENABLE_RELATED_POSTS:-true}" = true ] && [ -n "$tags" ]; then
# 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
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
local final_html="${header_content}"
final_html+='<article class="post">'$'\n'
final_html+=" <h1>$title</h1>"$'\n'
final_html+="$post_meta"$'\n'
final_html+="$image_html"$'\n'
final_html+="$html_content"$'\n'
final_html+="$tags_html"$'\n'
if [ -n "$related_posts_html" ]; then
final_html+="$related_posts_html"$'\n'
fi
final_html+='</article>'$'\n'
# Replace placeholders in footer content
local current_year=$(date +'%Y')
local post_author_name="${author_name:-${AUTHOR_NAME:-Anonymous}}"
footer_content=${footer_content//\{\{current_year\}\}/$current_year}
footer_content=${footer_content//\{\{author_name\}\}/$post_author_name}
final_html+="${footer_content}"
# Create output directory
mkdir -p "$output_base_path"
# Write the final HTML
printf '%s' "$final_html" > "$output_html_file"
local write_status=$?
if [ $write_status -ne 0 ]; then
echo "${RED}ERROR:${NC} Failed to write HTML file '$output_html_file' (Status: $write_status)" >&2
return 1
fi
return 0
}
# Process all markdown files listed in the file index
process_all_markdown_files() {
echo -e "${YELLOW}Processing markdown posts...${NC}"
local file_index="${CACHE_DIR:-.bssg_cache}/file_index.txt"
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 ! $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=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
fi
echo -e "Checking ${GREEN}$total_file_count${NC} potential posts listed in index."
# --- OPTIMIZATION: Quick check if any posts need rebuilding ---
local needs_pass1=false
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 ] && ! $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}"
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
continue
fi
# Calculate expected 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"
# Quick rebuild check
common_rebuild_check "$output_html_file"
local common_result=$?
local needs_rebuild=false
if [ $common_result -eq 0 ]; then
needs_rebuild=true
else
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
fi
fi
if $needs_rebuild; then
posts_needing_rebuild=$((posts_needing_rebuild + 1))
needs_pass1=true
# Early exit optimization: if we find posts needing rebuild, we need Pass 1
break
fi
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 ] && ! $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}"
# Clear previous modified tags lists
rm -f "$modified_tags_list"
rm -f "$modified_authors_list"
touch "$modified_tags_list" # Ensure file exists even if empty
touch "$modified_authors_list" # Ensure file exists even if empty
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
continue
fi
# Calculate expected 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
# If post needs rebuilding, add its tags to the modified list
if $needs_rebuild; then
local new_tags="$tags"
local old_tags=""
# Try to get old tags from the previous index snapshot
if [ -f "$file_index_prev" ]; then
old_tags=$(grep "^${file}|" "$file_index_prev" | cut -d'|' -f6)
fi
# Combine old and new tags
local combined_tags="${old_tags},${new_tags}"
if [ -n "$combined_tags" ]; then
# Split by comma, trim, filter empty, sort unique, and add each tag on a new line
echo "$combined_tags" | tr ',' '\n' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//' | grep . | sort -u >> "$modified_tags_list"
fi
# Track modified authors (similar logic to tags)
local new_author="$author_name"
local old_author=""
if [ -f "$file_index_prev" ]; then
old_author=$(grep "^${file}|" "$file_index_prev" | cut -d'|' -f11)
fi
# Add both old and new authors to the modified list (if they exist)
if [ -n "$old_author" ] && [ "$old_author" != "" ]; then
echo "$old_author" >> "$modified_authors_list"
fi
if [ -n "$new_author" ] && [ "$new_author" != "" ]; then
echo "$new_author" >> "$modified_authors_list"
fi
fi
done < "$file_index"
# Unique sort the modified tags and authors lists
if [ -f "$modified_tags_list" ]; then
local temp_tags_list=$(mktemp)
sort -u "$modified_tags_list" > "$temp_tags_list"
mv "$temp_tags_list" "$modified_tags_list"
fi
if [ -f "$modified_authors_list" ]; then
local temp_authors_list=$(mktemp)
sort -u "$modified_authors_list" > "$temp_authors_list"
mv "$temp_authors_list" "$modified_authors_list"
fi
# Invalidate related posts cache if there are modified tags
if [ -f "$modified_tags_list" ] && [ -s "$modified_tags_list" ]; then
# Source related posts functions if not already loaded
if ! command -v invalidate_related_posts_cache_for_tags > /dev/null 2>&1; then
# shellcheck source=related_posts.sh disable=SC1091
source "$(dirname "$0")/related_posts.sh" || { echo -e "${RED}Error: Failed to source related_posts.sh${NC}"; exit 1; }
fi
# Create a temporary file to capture the list of invalidated posts
RELATED_POSTS_INVALIDATED_LIST="${CACHE_DIR:-.bssg_cache}/related_posts_invalidated.list"
> "$RELATED_POSTS_INVALIDATED_LIST" # Create empty file
# Call the invalidation function with the output file
invalidate_related_posts_cache_for_tags "$modified_tags_list" "$RELATED_POSTS_INVALIDATED_LIST"
# 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
# --- PASS 2: Process posts with proper rebuild flags ---
echo -e "${YELLOW}Pass 2: Processing posts...${NC}"
# Pre-filter files that need rebuilding
local files_to_process_list=()
local files_to_process_count=0
local skipped_count=0
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
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"
# 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
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
echo -e "${GREEN}All $total_file_count posts are up to date.${NC}"