open_blog 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +52 -0
- data/LICENSE.txt +21 -0
- data/README.md +101 -0
- data/app/assets/builds/open_blog/blog.css +2 -0
- data/app/assets/javascripts/open_blog/controllers/code_copy_controller.js +40 -0
- data/app/assets/javascripts/open_blog/controllers/reading_progress_controller.js +36 -0
- data/app/assets/javascripts/open_blog/controllers/search_controller.js +49 -0
- data/app/assets/javascripts/open_blog/controllers/share_controller.js +48 -0
- data/app/assets/javascripts/open_blog/controllers/theme_controller.js +51 -0
- data/app/assets/javascripts/open_blog/controllers/toc_controller.js +33 -0
- data/app/controllers/concerns/open_blog/reader_controller.rb +49 -0
- data/app/controllers/open_blog/api/v1/adoptions_controller.rb +27 -0
- data/app/controllers/open_blog/api/v1/approvals_controller.rb +15 -0
- data/app/controllers/open_blog/api/v1/authors_controller.rb +62 -0
- data/app/controllers/open_blog/api/v1/base_controller.rb +100 -0
- data/app/controllers/open_blog/api/v1/categories_controller.rb +33 -0
- data/app/controllers/open_blog/api/v1/connections_controller.rb +45 -0
- data/app/controllers/open_blog/api/v1/doctor_controller.rb +13 -0
- data/app/controllers/open_blog/api/v1/faq_extractions_controller.rb +13 -0
- data/app/controllers/open_blog/api/v1/findings_controller.rb +13 -0
- data/app/controllers/open_blog/api/v1/images_controller.rb +22 -0
- data/app/controllers/open_blog/api/v1/pages_controller.rb +67 -0
- data/app/controllers/open_blog/api/v1/posts_controller.rb +114 -0
- data/app/controllers/open_blog/api/v1/previews_controller.rb +13 -0
- data/app/controllers/open_blog/api/v1/records_controller.rb +13 -0
- data/app/controllers/open_blog/api/v1/redirects_controller.rb +51 -0
- data/app/controllers/open_blog/api/v1/report_controller.rb +20 -0
- data/app/controllers/open_blog/api/v1/resources_controller.rb +39 -0
- data/app/controllers/open_blog/api/v1/series_controller.rb +32 -0
- data/app/controllers/open_blog/api/v1/tags_controller.rb +12 -0
- data/app/controllers/open_blog/api/v1/views_controller.rb +19 -0
- data/app/controllers/open_blog/application_controller.rb +5 -0
- data/app/controllers/open_blog/authors_controller.rb +14 -0
- data/app/controllers/open_blog/categories_controller.rb +15 -0
- data/app/controllers/open_blog/errors_controller.rb +7 -0
- data/app/controllers/open_blog/feeds_controller.rb +22 -0
- data/app/controllers/open_blog/mcp_controller.rb +38 -0
- data/app/controllers/open_blog/media_controller.rb +20 -0
- data/app/controllers/open_blog/pages_controller.rb +12 -0
- data/app/controllers/open_blog/posts_controller.rb +57 -0
- data/app/controllers/open_blog/previews_controller.rb +35 -0
- data/app/controllers/open_blog/search_controller.rb +32 -0
- data/app/controllers/open_blog/series_controller.rb +15 -0
- data/app/controllers/open_blog/sitemaps_controller.rb +10 -0
- data/app/controllers/open_blog/tags_controller.rb +15 -0
- data/app/helpers/open_blog/content_helper.rb +79 -0
- data/app/helpers/open_blog/dates_helper.rb +18 -0
- data/app/helpers/open_blog/faq_helper.rb +59 -0
- data/app/helpers/open_blog/head_helper.rb +75 -0
- data/app/helpers/open_blog/icons_helper.rb +10 -0
- data/app/helpers/open_blog/notices_helper.rb +47 -0
- data/app/helpers/open_blog/pagination_helper.rb +18 -0
- data/app/helpers/open_blog/structured_data_helper.rb +63 -0
- data/app/helpers/open_blog/theme_helper.rb +52 -0
- data/app/helpers/open_blog/url_helper.rb +58 -0
- data/app/jobs/open_blog/publish_scheduled_post_job.rb +14 -0
- data/app/models/concerns/open_blog/immutable.rb +13 -0
- data/app/models/concerns/open_blog/immutable_image_attachment.rb +43 -0
- data/app/models/concerns/open_blog/previewable.rb +38 -0
- data/app/models/open_blog/api_token.rb +34 -0
- data/app/models/open_blog/application_record.rb +5 -0
- data/app/models/open_blog/approval.rb +19 -0
- data/app/models/open_blog/author.rb +11 -0
- data/app/models/open_blog/baseline.rb +26 -0
- data/app/models/open_blog/category.rb +10 -0
- data/app/models/open_blog/connection_declaration.rb +16 -0
- data/app/models/open_blog/faq.rb +22 -0
- data/app/models/open_blog/image.rb +45 -0
- data/app/models/open_blog/page.rb +23 -0
- data/app/models/open_blog/page_view.rb +7 -0
- data/app/models/open_blog/post.rb +132 -0
- data/app/models/open_blog/publication.rb +17 -0
- data/app/models/open_blog/redirect.rb +16 -0
- data/app/models/open_blog/revision.rb +12 -0
- data/app/models/open_blog/series.rb +10 -0
- data/app/models/open_blog/tag.rb +11 -0
- data/app/models/open_blog/tagging.rb +7 -0
- data/app/queries/open_blog/api_post_query.rb +54 -0
- data/app/serializers/open_blog/adoption_result_serializer.rb +9 -0
- data/app/serializers/open_blog/author_serializer.rb +10 -0
- data/app/serializers/open_blog/category_serializer.rb +7 -0
- data/app/serializers/open_blog/doctor_serializer.rb +7 -0
- data/app/serializers/open_blog/error_serializer.rb +7 -0
- data/app/serializers/open_blog/extraction_result_serializer.rb +7 -0
- data/app/serializers/open_blog/findings_serializer.rb +13 -0
- data/app/serializers/open_blog/image_serializer.rb +9 -0
- data/app/serializers/open_blog/page_serializer.rb +9 -0
- data/app/serializers/open_blog/post_serializer.rb +59 -0
- data/app/serializers/open_blog/preview_serializer.rb +11 -0
- data/app/serializers/open_blog/records_serializer.rb +28 -0
- data/app/serializers/open_blog/redirect_serializer.rb +7 -0
- data/app/serializers/open_blog/series_serializer.rb +7 -0
- data/app/serializers/open_blog/tag_serializer.rb +7 -0
- data/app/serializers/open_blog/write_result_serializer.rb +9 -0
- data/app/views/open_blog/attachments/_blob.html.erb +1 -0
- data/app/views/open_blog/feeds/atom.xml.builder +27 -0
- data/app/views/open_blog/sitemaps/show.xml.builder +9 -0
- data/config/locales/open_blog.en.yml +95 -0
- data/config/routes.rb +52 -0
- data/db/migrate/20261003012512_create_open_blog_authors.rb +16 -0
- data/db/migrate/20261003012513_create_open_blog_categories.rb +13 -0
- data/db/migrate/20261003012514_create_open_blog_series.rb +11 -0
- data/db/migrate/20261003012515_create_open_blog_images.rb +17 -0
- data/db/migrate/20261003012516_create_open_blog_posts.rb +47 -0
- data/db/migrate/20261003012517_create_open_blog_faqs.rb +13 -0
- data/db/migrate/20261003012518_create_open_blog_tags.rb +17 -0
- data/db/migrate/20261003012519_create_open_blog_redirects.rb +14 -0
- data/db/migrate/20261003012520_create_open_blog_revisions.rb +14 -0
- data/db/migrate/20261003012521_create_open_blog_approvals.rb +21 -0
- data/db/migrate/20261003012522_create_open_blog_publications.rb +18 -0
- data/db/migrate/20261003012523_create_open_blog_baselines.rb +24 -0
- data/db/migrate/20261003012524_create_open_blog_connection_declarations.rb +13 -0
- data/db/migrate/20261003031410_add_body_image_manifest_to_open_blog_posts.rb +7 -0
- data/db/migrate/20261003164418_create_open_blog_api_tokens.rb +15 -0
- data/db/migrate/20261003175454_create_open_blog_pages.rb +16 -0
- data/db/migrate/20261003182919_create_open_blog_page_views.rb +11 -0
- data/lib/generators/open_blog/admin_suite/admin_suite_generator.rb +27 -0
- data/lib/generators/open_blog/admin_suite/templates/author_resource.rb.tt +29 -0
- data/lib/generators/open_blog/admin_suite/templates/blog_portal.rb.tt +16 -0
- data/lib/generators/open_blog/admin_suite/templates/category_resource.rb.tt +28 -0
- data/lib/generators/open_blog/admin_suite/templates/initializer.rb.tt +19 -0
- data/lib/generators/open_blog/admin_suite/templates/page_resource.rb.tt +32 -0
- data/lib/generators/open_blog/admin_suite/templates/post_resource.rb.tt +58 -0
- data/lib/generators/open_blog/generator_support.rb +64 -0
- data/lib/generators/open_blog/install/install_generator.rb +188 -0
- data/lib/generators/open_blog/install/templates/initializer.rb.tt +16 -0
- data/lib/generators/open_blog/install/templates/sample_cover.png +0 -0
- data/lib/generators/open_blog/install/templates/sample_post.md +54 -0
- data/lib/generators/open_blog/install/templates/theme/blog.css +193 -0
- data/lib/generators/open_blog/install/templates/theme/open_blog_theme.css +82 -0
- data/lib/generators/open_blog/install/templates/theme/syntax.css +358 -0
- data/lib/generators/open_blog/install/templates/theme/theme.css +120 -0
- data/lib/generators/open_blog/install/templates/views/layouts/open_blog.html.erb +16 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/authors/show.html.erb +17 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/categories/show.html.erb +13 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/errors/not_found.html.erb +11 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/errors/preview_expired.html.erb +5 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/icons/_check.svg.erb +1 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/icons/_copy.svg.erb +1 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/icons/_link.svg.erb +1 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/icons/_monitor.svg.erb +1 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/icons/_moon.svg.erb +1 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/icons/_share.svg.erb +1 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/icons/_sun.svg.erb +1 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/pages/show.html.erb +5 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/posts/_faq.html.erb +1 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/posts/_highlights.html.erb +7 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/posts/_preview_bar.html.erb +1 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/posts/_series_nav.html.erb +12 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/posts/_tags.html.erb +5 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/posts/_toc.html.erb +1 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/posts/index.html.erb +13 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/posts/show.html.erb +29 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/search/show.html.erb +13 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/series/show.html.erb +13 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/shared/_author_box.html.erb +6 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/shared/_breadcrumbs.html.erb +1 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/shared/_call_to_action.html.erb +7 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/shared/_featured_card.html.erb +11 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/shared/_footer.html.erb +11 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/shared/_header.html.erb +7 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/shared/_pagination.html.erb +1 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/shared/_post_card.html.erb +9 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/shared/_related_posts.html.erb +10 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/shared/_search.html.erb +9 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/shared/_share.html.erb +13 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/shared/_sidebar.html.erb +38 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/shared/_theme_toggle.html.erb +10 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/tags/show.html.erb +13 -0
- data/lib/generators/open_blog/views/views_generator.rb +21 -0
- data/lib/open_blog/actor.rb +7 -0
- data/lib/open_blog/admin_suite.rb +73 -0
- data/lib/open_blog/adopt/contract.rb +152 -0
- data/lib/open_blog/adopt/snapshot.rb +35 -0
- data/lib/open_blog/adopt.rb +159 -0
- data/lib/open_blog/api_fields.rb +40 -0
- data/lib/open_blog/approve.rb +18 -0
- data/lib/open_blog/authentication.rb +18 -0
- data/lib/open_blog/build_css.rb +53 -0
- data/lib/open_blog/configuration.rb +185 -0
- data/lib/open_blog/configuration_error.rb +4 -0
- data/lib/open_blog/content_guard.rb +77 -0
- data/lib/open_blog/doctor/files.rb +61 -0
- data/lib/open_blog/doctor/javascript.rb +57 -0
- data/lib/open_blog/doctor.rb +177 -0
- data/lib/open_blog/engine.rb +45 -0
- data/lib/open_blog/errors.rb +40 -0
- data/lib/open_blog/faq_extraction.rb +184 -0
- data/lib/open_blog/findings/body_checks.rb +58 -0
- data/lib/open_blog/findings/check.rb +19 -0
- data/lib/open_blog/findings/link_check.rb +78 -0
- data/lib/open_blog/findings/record_checks.rb +94 -0
- data/lib/open_blog/findings/registry.rb +18 -0
- data/lib/open_blog/findings.rb +27 -0
- data/lib/open_blog/image_fetch.rb +115 -0
- data/lib/open_blog/image_import.rb +164 -0
- data/lib/open_blog/image_resolution.rb +171 -0
- data/lib/open_blog/instructions.rb +7 -0
- data/lib/open_blog/json_feed.rb +36 -0
- data/lib/open_blog/label_policy.rb +41 -0
- data/lib/open_blog/markdown_view.rb +57 -0
- data/lib/open_blog/mcp/definition.rb +28 -0
- data/lib/open_blog/mcp/dispatcher.rb +33 -0
- data/lib/open_blog/mcp/image_input.rb +30 -0
- data/lib/open_blog/mcp/registry.rb +139 -0
- data/lib/open_blog/mcp/schemas.rb +72 -0
- data/lib/open_blog/mcp.rb +24 -0
- data/lib/open_blog/not_found.rb +4 -0
- data/lib/open_blog/operation.rb +36 -0
- data/lib/open_blog/page_views.rb +72 -0
- data/lib/open_blog/pagination.rb +18 -0
- data/lib/open_blog/plain_text.rb +36 -0
- data/lib/open_blog/policy_url.rb +9 -0
- data/lib/open_blog/post_attributes.rb +172 -0
- data/lib/open_blog/post_identity.rb +28 -0
- data/lib/open_blog/publish.rb +7 -0
- data/lib/open_blog/reader_dates.rb +21 -0
- data/lib/open_blog/reader_page.rb +21 -0
- data/lib/open_blog/reader_queries.rb +90 -0
- data/lib/open_blog/record_release.rb +66 -0
- data/lib/open_blog/redirect_target.rb +58 -0
- data/lib/open_blog/remove.rb +43 -0
- data/lib/open_blog/renderer/highlighter.rb +44 -0
- data/lib/open_blog/renderer/markdown.rb +14 -0
- data/lib/open_blog/renderer/rich_text.rb +19 -0
- data/lib/open_blog/renderer/sanitizer.rb +72 -0
- data/lib/open_blog/renderer/scrubber.rb +35 -0
- data/lib/open_blog/renderer/transform.rb +115 -0
- data/lib/open_blog/renderer.rb +51 -0
- data/lib/open_blog/result.rb +11 -0
- data/lib/open_blog/revision_payload.rb +67 -0
- data/lib/open_blog/rich_text_guard.rb +26 -0
- data/lib/open_blog/sample.rb +56 -0
- data/lib/open_blog/save_draft.rb +7 -0
- data/lib/open_blog/search.rb +21 -0
- data/lib/open_blog/sitemap_entries.rb +66 -0
- data/lib/open_blog/surface_report/expected_content.rb +27 -0
- data/lib/open_blog/surface_report/http.rb +92 -0
- data/lib/open_blog/surface_report/page_checks.rb +303 -0
- data/lib/open_blog/surface_report/reach_checks.rb +141 -0
- data/lib/open_blog/surface_report/record_checks.rb +248 -0
- data/lib/open_blog/surface_report/robots.rb +49 -0
- data/lib/open_blog/surface_report.rb +223 -0
- data/lib/open_blog/syntax_css.rb +55 -0
- data/lib/open_blog/unpublish.rb +13 -0
- data/lib/open_blog/version.rb +3 -0
- data/lib/open_blog/write_post.rb +205 -0
- data/lib/open_blog.rb +91 -0
- data/lib/tasks/open_blog.rake +71 -0
- data/skills/open-blog-adopt/SKILL.md +12 -0
- data/skills/open-blog-install/SKILL.md +10 -0
- data/skills/open-blog-policy-pages/SKILL.md +10 -0
- data/skills/open-blog-publish/SKILL.md +14 -0
- data/skills/open-blog-report/SKILL.md +10 -0
- data/skills/open-blog-update/SKILL.md +16 -0
- metadata +515 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 324268599e7e9a136c090a233ac46619fd15c9d329292d96d12498213e163bdc
|
|
4
|
+
data.tar.gz: d598e2a23b0d553c81ae2640cf0812d2e302fa5f88fa4fca08de3612e53511b8
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 0c7b6d301c115edc1930b630c2e291570225454d2947808b482f5b195c604bfdc563e00940f92d553a6e6f8a2242699f198f20af5933c517b3f4c59e0f8912af
|
|
7
|
+
data.tar.gz: 4f8850a75fec631002c9496fdd024999892683c5fe96e80a8d92aceb9d31ce8c4700d8f72e51b8639f9e49b3489a88d57b00a18bd2c033e66e0d173bb2e084ec
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [Unreleased]
|
|
9
|
+
|
|
10
|
+
## [0.1.0] - 2026-10-03
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- Publish searchable public documentation with installation, configuration, publishing, API, MCP, and customization guides.
|
|
15
|
+
- Prepare release pull requests that update the version and dated changelog, then publish verified packages to RubyGems and synchronize GitHub Releases.
|
|
16
|
+
|
|
17
|
+
### Changed
|
|
18
|
+
|
|
19
|
+
- Document supported Ruby, Rails, and database versions, every configuration setting with its default, and verified Ruby, API, MCP, and reader examples.
|
|
20
|
+
|
|
21
|
+
### Added
|
|
22
|
+
|
|
23
|
+
- Initial gem package, runtime dependencies, tests, and continuous integration.
|
|
24
|
+
- Add a mountable engine with validated configuration and a PostgreSQL/SQLite host test matrix.
|
|
25
|
+
- Add content models, reversible database migrations, deterministic revision identifiers, and immutable publishing records.
|
|
26
|
+
- Add transactional draft, publish, approve, unpublish, and remove operations with publication history and scheduled enqueueing.
|
|
27
|
+
- Record direct FAQ and rich-text changes atomically and return advisory findings and AI notice labels from publishing operations.
|
|
28
|
+
- Import existing articles with source identity, historical dates, declared or imported approvals, redirects, safe repeat handling, and transactional dry runs.
|
|
29
|
+
- Reuse signed Active Storage blobs for imported images and extract proposed FAQ sections from Markdown with review findings.
|
|
30
|
+
- Render sanitized Markdown and rich text with heading links, syntax highlighting, image figures, body findings, durable image identities, and generated light/dark syntax colors.
|
|
31
|
+
- Add reader routes and copied templates for the layout, header, footer, sidebar, featured and ordinary cards, pagination, breadcrumbs, sharing, author box, call to action, related posts, post highlights, table of contents, tags, index, post, category, tag, author, and missing-page views.
|
|
32
|
+
- Add reader metadata, structured data, publication dates, notices, Atom feeds, sitemap entries, redirects, and stable original-image delivery.
|
|
33
|
+
- Add responsive light/dark themes, configurable design tokens, accessible syntax colors, and a reproducible compiled stylesheet.
|
|
34
|
+
- Add theme selection, link and code copying, device sharing, table-of-contents tracking, reading progress, and browser accessibility checks.
|
|
35
|
+
- Render ordered FAQ records as visible plain text and FAQ structured data, with a shared table-of-contents anchor and Markdown text output.
|
|
36
|
+
- Add install and views generators, an idempotent sample article, installation diagnostics, and fresh-application compatibility checks.
|
|
37
|
+
- Add scoped API tokens and authenticated JSON endpoints for posts, approvals, connection declarations, publishing history, findings, and installation checks.
|
|
38
|
+
- Add bounded remote image imports, deduplicated uploads, supporting-record APIs, adoption previews, FAQ extraction, redirect serialization, and one-time installer tokens.
|
|
39
|
+
- Add stateless MCP publishing tools, shared API validation, revision-bound preview links, and packaged agent workflows.
|
|
40
|
+
- Add editable policy pages with scoped API and MCP access, public rendering, footer and sitemap links, and live notice and diagnostic updates.
|
|
41
|
+
- Add series navigation, ranked reader and API search, live search suggestions, Markdown responses, and JSON Feed 1.1.
|
|
42
|
+
- Execute due publication schedules safely, count anonymous daily page views, expose view reports and a popular-post sidebar, and check internal blog links.
|
|
43
|
+
- Add page and publishing-record reports with JSON, text, MCP, and Rails-task access, plus optional bounded reach checks.
|
|
44
|
+
- Add an optional AdminSuite generator with isolated resources, nested FAQ editing, and read-only publishing-history panels.
|
|
45
|
+
|
|
46
|
+
### Upgrade notes
|
|
47
|
+
|
|
48
|
+
- This is the first packaged release. Existing Git-source installations should run `bin/rails open_blog:install:migrations` and `bin/rails db:migrate` before restarting application and job processes.
|
|
49
|
+
- Reader templates are copied into the host. Run `bin/rails generate open_blog:views` to review updates; preserve local customizations and deliberately accept replacements. Refresh installed browser controllers with the install generator when upgrading from an earlier development checkout.
|
|
50
|
+
- Configure the public origin, publisher identity, policy pages, storage, and authentication for your deployment. The sample article is demonstration content and has no human approval record.
|
|
51
|
+
- Applications writing through bulk SQL must move to normal model saves or publishing operations to retain revision history.
|
|
52
|
+
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 TechWright Labs
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# Open Blog
|
|
2
|
+
|
|
3
|
+
A Rails blog engine with Markdown and rich-text articles, responsive reader pages, revision history, and publishing through Ruby, a JSON API, or MCP tools. Every publish, edit, approval, and removal leaves an immutable record, so a reader can trust the dates and notices on the page and an agent can publish without inventing a review.
|
|
4
|
+
|
|
5
|
+
[Documentation](https://techwright-lab.github.io/open-blog/) · [API reference](https://techwright-lab.github.io/open-blog/api/) · [Changelog](CHANGELOG.md)
|
|
6
|
+
|
|
7
|
+
## What you get
|
|
8
|
+
|
|
9
|
+
- **Reader pages** with light and dark themes, categories, tags, authors, series, search, Atom and JSON feeds, a sitemap, and structured data. The templates are copied into your app so you can change them.
|
|
10
|
+
- **Publishing operations** in Ruby that compute a revision identifier from the content, record each release, and refuse a public edit that does not say whether it is substantive, a correction, or maintenance.
|
|
11
|
+
- **A JSON API and an MCP endpoint** with scoped tokens, so editorial tools and AI agents publish through the same guarded path. Six agent workflows ship in the gem.
|
|
12
|
+
- **Approvals, provenance, and labels.** A post carries who wrote it, who reviewed which revision, and whether facts were checked. The AI notice on the page follows those records.
|
|
13
|
+
- **Adoption** of an existing blog with its known history, dates, and redirects, without claiming a new first publication.
|
|
14
|
+
- **Policy pages, page views, diagnostics, and surface reports** that check what readers actually see against the stored records.
|
|
15
|
+
- **Optional AdminSuite resources** for a browser-based editor.
|
|
16
|
+
|
|
17
|
+
## Requirements
|
|
18
|
+
|
|
19
|
+
| Requirement | Supported |
|
|
20
|
+
| --- | --- |
|
|
21
|
+
| Ruby | 3.2, 3.3, 3.4, or 4.0 |
|
|
22
|
+
| Rails | 8.0 or 8.1 |
|
|
23
|
+
| Database | PostgreSQL or SQLite |
|
|
24
|
+
| Images | Active Storage with libvips or ImageMagick |
|
|
25
|
+
|
|
26
|
+
Every combination of Ruby, Rails, and database above runs in CI.
|
|
27
|
+
|
|
28
|
+
## Install
|
|
29
|
+
|
|
30
|
+
Install from GitHub until a version is published on RubyGems:
|
|
31
|
+
|
|
32
|
+
```sh
|
|
33
|
+
bundle add open_blog --github techwright-lab/open-blog
|
|
34
|
+
bin/rails generate open_blog:install
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Start `bin/dev` (or `bin/rails server`) and open `/blog`. The generator installs tables, mounts the engine at `/blog`, copies the reader views and browser controllers, writes `config/initializers/open_blog.rb`, and publishes a sample article. If it prints an API token, save it: the secret is shown once.
|
|
38
|
+
|
|
39
|
+
Set your site name, publisher, and public origin in the initializer, then run `bin/rails open_blog:doctor` to check the setup. The [installation guide](https://techwright-lab.github.io/open-blog/installation/) lists every generator option, and the [configuration reference](https://techwright-lab.github.io/open-blog/configuration/) lists every setting with its default.
|
|
40
|
+
|
|
41
|
+

|
|
42
|
+
|
|
43
|
+

|
|
44
|
+
|
|
45
|
+
## Publish a post
|
|
46
|
+
|
|
47
|
+
From Ruby:
|
|
48
|
+
|
|
49
|
+
```ruby
|
|
50
|
+
result = OpenBlog::Publish.call(
|
|
51
|
+
{ title: "Garden notes", body: "Today in the garden.", provenance: "human_written" },
|
|
52
|
+
actor: "Editor"
|
|
53
|
+
)
|
|
54
|
+
result.success? # => true
|
|
55
|
+
result.post.path # => "/blog/garden-notes"
|
|
56
|
+
result.records # => { revision: "new", publication: "first", approval: nil }
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
From the JSON API, with a token from `bin/rails open_blog:token`:
|
|
60
|
+
|
|
61
|
+
```sh
|
|
62
|
+
curl -s -X POST https://example.com/blog/api/v1/posts \
|
|
63
|
+
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
|
|
64
|
+
-d '{"title":"Garden notes","body":"Today in the garden.","publish":true}'
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
From an agent, point an MCP client at `https://example.com/blog/mcp` with the same token. The `blog_save_draft` tool returns a preview link, and `blog_publish_post` records the release. Agents must show the exact text to a person and record only the approval they actually give; the [publishing instructions](https://techwright-lab.github.io/open-blog/mcp/#publishing-instructions) spell this out.
|
|
68
|
+
|
|
69
|
+
A later edit of a public post must carry `change: "substantive"`, `"correction"`, or `"maintenance"`. Without it the write is refused and nothing changes. Normal model saves and the operations keep this history; bulk SQL writes bypass it.
|
|
70
|
+
|
|
71
|
+
## Guides
|
|
72
|
+
|
|
73
|
+
- [Configuration](https://techwright-lab.github.io/open-blog/configuration/): identity, routes, content gates, images, limits, and page views.
|
|
74
|
+
- [Publishing](https://techwright-lab.github.io/open-blog/publishing/): operations, previews, scheduling, approvals, and findings.
|
|
75
|
+
- [JSON API](https://techwright-lab.github.io/open-blog/api/) and [MCP](https://techwright-lab.github.io/open-blog/mcp/): endpoints, tools, examples, and error codes.
|
|
76
|
+
- [Reader pages and feeds](https://techwright-lab.github.io/open-blog/reader/): routes, search, feeds, dates, images, and policy pages.
|
|
77
|
+
- [Adoption](https://techwright-lab.github.io/open-blog/adoption/): import existing articles and preserve their known history.
|
|
78
|
+
- [Customization](https://techwright-lab.github.io/open-blog/customization/): copied templates, themes, and browser controllers.
|
|
79
|
+
- [AdminSuite](https://techwright-lab.github.io/open-blog/admin-suite/), [Diagnostics](https://techwright-lab.github.io/open-blog/diagnostics/), and [Page views](https://techwright-lab.github.io/open-blog/analytics/).
|
|
80
|
+
|
|
81
|
+
## Development
|
|
82
|
+
|
|
83
|
+
The gem tests run through a dummy host application. Set `DB` to `sqlite` or `postgres`:
|
|
84
|
+
|
|
85
|
+
```sh
|
|
86
|
+
bundle install
|
|
87
|
+
DB=sqlite RAILS_ENV=test bundle exec ruby bin/test
|
|
88
|
+
bundle exec rubocop
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
CI runs the matrix of Ruby 3.2 to 4.0, Rails 8.0 and 8.1, and both databases, plus browser tests and a fresh-application installer check with the built gem.
|
|
92
|
+
|
|
93
|
+
The documentation is Markdown in `docs/`, built with Jekyll and Just the Docs from a separate bundle. See [working on the documentation](https://techwright-lab.github.io/open-blog/contributing/) for local preview and validation commands.
|
|
94
|
+
|
|
95
|
+
## Releases
|
|
96
|
+
|
|
97
|
+
The **Prepare Release** workflow updates the version and changelog in a pull request. After review, merge, and successful checks, **Publish** publishes the verified package to RubyGems and creates or updates the GitHub Release. Merging ordinary changes does not publish a gem. See the [release guide](https://techwright-lab.github.io/open-blog/releases/) for the first-time Trusted Publishing setup.
|
|
98
|
+
|
|
99
|
+
## License
|
|
100
|
+
|
|
101
|
+
Licensed under the [MIT License](LICENSE.txt).
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
/*! tailwindcss v4.3.3 | MIT License | https://tailwindcss.com */
|
|
2
|
+
@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial}}}@layer theme{:root,:host{--font-sans:-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring:where(:not(iframe)){outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components;@layer utilities{.hidden{display:none}.transform{transform:var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,)}}.ob-page{background:var(--ob-surface);color:var(--ob-text);font-family:var(--ob-font-body);overflow-wrap:anywhere;margin:0;line-height:1.6}.ob-page *,.ob-page :before,.ob-page :after{box-sizing:border-box}.ob-page [hidden]{display:none!important}.ob-page a{color:var(--ob-accent);text-underline-offset:.2em}.ob-page a:hover{color:var(--ob-accent-hover)}.ob-page a:focus-visible,.ob-page button:focus-visible,.ob-page input:focus-visible,.ob-page .ob-table:focus-visible,.ob-page .ob-prose pre:focus-visible{outline:3px solid var(--ob-accent);outline-offset:4px}.ob-page h1,.ob-page h2,.ob-page h3,.ob-page h4,.ob-page h5,.ob-page h6{color:var(--ob-heading);font-family:var(--ob-font-display);text-wrap:balance;font-weight:750;line-height:1.25}.ob-page h1{letter-spacing:-.035em;font-size:clamp(1.875rem,4vw,3rem)}.ob-page h2{font-size:1.5rem}.ob-page h3{font-size:1.25rem}.ob-page h4,.ob-page h5,.ob-page h6{font-size:1.125rem}.ob-page img{max-width:100%;height:auto}.ob-page button{cursor:pointer;font:inherit}.ob-page button:disabled{cursor:default}.ob-main{max-width:var(--ob-page-width);margin:auto;padding:3rem 1rem}.ob-header,.ob-footer{max-width:var(--ob-page-width);flex-wrap:wrap;align-items:center;gap:1rem;margin:auto;padding:1.25rem 1rem;display:flex}.ob-header{border-bottom:1px solid var(--ob-border)}.ob-header nav{margin-left:auto}.ob-site-name{font-size:1.125rem;font-weight:800;text-decoration:none}.ob-footer{border-top:1px solid var(--ob-border);font-size:.875rem}.ob-footer nav{flex-wrap:wrap;gap:1rem;display:flex}.ob-index-heading,.ob-list-heading{margin-bottom:1.5rem}.ob-index-heading h1{font-size:2.25rem;font-weight:800}.ob-tagline,.ob-list-description,.ob-lede{color:var(--ob-text-muted);margin-top:.75rem;font-size:1.125rem}.ob-list-layout{gap:2rem;display:grid}.ob-list-main,.ob-sidebar,.ob-post-card,.ob-featured-details{min-width:0}.ob-search{margin-bottom:1.5rem}.ob-search label{margin-bottom:.5rem;font-weight:700;display:block}.ob-search-controls{gap:.5rem;display:flex}.ob-search input{border:1px solid var(--ob-border-strong);background:var(--ob-surface);width:100%;min-width:0;color:var(--ob-text);font:inherit;border-radius:.5rem;padding:.5rem}.ob-search button{white-space:nowrap;background:var(--ob-accent);color:var(--ob-accent-contrast);border-radius:.5rem;flex-shrink:0;padding:.5rem .75rem}.ob-search-results{border:1px solid var(--ob-border);background:var(--ob-surface-raised);border-radius:.5rem;margin-top:.75rem}.ob-sidebar .ob-search-results a{border-radius:.5rem;display:block}.ob-search-results a:hover{background:var(--ob-accent-soft)}.ob-series-nav{border:1px solid var(--ob-border);border-radius:.75rem;margin:2rem 0;padding:1.25rem}.ob-series-nav h2{margin-bottom:.75rem;font-size:1.125rem}.ob-series-nav p+p{margin-top:.5rem}.ob-sidebar h2{margin-bottom:.75rem;font-size:1rem}.ob-sidebar-categories ul{gap:.5rem;padding-bottom:.5rem;display:flex;overflow-x:auto}.ob-sidebar-categories li{flex-shrink:0}.ob-sidebar a{border-radius:var(--ob-radius-pill);padding:.4rem .8rem;text-decoration:none;display:block}.ob-sidebar a[aria-current=page]{color:var(--ob-accent-contrast);background:var(--ob-accent)}.ob-sidebar-popular{margin-top:1.5rem}.ob-sidebar-popular li+li{margin-top:.25rem}.ob-sidebar-tags,.ob-count{display:none}.ob-post-grid{grid-template-columns:minmax(0,1fr);gap:2rem;display:grid}.ob-card-media{border-radius:var(--ob-radius-media);display:block;overflow:hidden}.ob-card-image,.ob-card-placeholder{aspect-ratio:16/9;object-fit:cover;width:100%}.ob-card-image{transition:transform .18s}.ob-card-media:hover .ob-card-image{transform:scale(1.05)}.ob-card-placeholder{background:linear-gradient(135deg, var(--ob-accent-soft), var(--ob-surface-sunken))}.ob-card-category,.ob-post-category{text-transform:uppercase;letter-spacing:.07em;margin-top:1rem;font-size:.8rem;font-weight:750;display:inline-block}.ob-card-title{margin:.5rem 0}.ob-card-title a,.ob-featured-details h2 a{text-decoration:none}.ob-card-title,.ob-card-description{-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}.ob-card-title{-webkit-line-clamp:2}.ob-card-description{-webkit-line-clamp:2;color:var(--ob-text-muted);margin:.75rem 0}.ob-card-author{align-items:center;gap:.5rem;margin-top:1rem;display:flex}.ob-card-author a{color:var(--ob-text)}.ob-avatar{object-fit:cover;vertical-align:middle;border-radius:50%;flex:0 0 2.5rem;justify-content:center;align-items:center;width:2.5rem;height:2.5rem;display:inline-flex}.ob-avatar--initials{background:var(--ob-accent-soft);color:var(--ob-heading);font-weight:750}.ob-date,.ob-reading-time{color:var(--ob-text-subtle);margin:.35rem .75rem .35rem 0;font-size:.875rem;display:inline-block}.ob-featured-card{border:1px solid var(--ob-border);border-top:4px solid var(--ob-accent);border-radius:var(--ob-radius-card);min-width:0;box-shadow:var(--ob-shadow);margin-bottom:2rem;display:grid;overflow:hidden}.ob-featured-card:hover{border-color:var(--ob-accent)}.ob-featured-card .ob-card-media{border-radius:0}.ob-featured-details{padding:1.25rem}.ob-featured-details .ob-card-description{-webkit-line-clamp:3}.ob-featured-label{color:var(--ob-accent);text-transform:uppercase;letter-spacing:.08em;margin-bottom:.5rem;font-size:.8rem;font-weight:750}.ob-pagination{flex-wrap:wrap;justify-content:center;gap:.5rem;margin-top:2.5rem;display:flex}.ob-pagination-link,.ob-pagination-current{border-radius:var(--ob-radius-pill);justify-content:center;align-items:center;min-width:2.5rem;min-height:2.5rem;text-decoration:none;display:inline-flex}.ob-pagination-current{color:var(--ob-accent-contrast);background:var(--ob-accent)}.ob-breadcrumbs ol{flex-wrap:wrap;gap:.5rem;margin-bottom:1.5rem;font-size:.875rem;display:flex}.ob-breadcrumbs li+li:before{content:"/";color:var(--ob-text-subtle);margin-right:.5rem}.ob-post{max-width:var(--ob-content-width);margin:auto}.ob-post-title{margin:.75rem 0 1rem;font-weight:800;line-height:1.15}.ob-post-category{margin-top:0}.ob-byline{margin:1rem 0 1.5rem}.ob-byline-author{font-weight:700}.ob-cover{margin:1.5rem -1rem}.ob-cover img{aspect-ratio:2;object-fit:cover;width:100%}.ob-highlights{border:1px solid var(--ob-border);border-radius:var(--ob-radius-card);background:var(--ob-surface-raised);margin:1.5rem 0;padding:1rem}.ob-highlights h2{margin-bottom:.5rem;font-size:1rem}.ob-highlights ul{flex-wrap:wrap;gap:.25rem 1rem;display:flex}.ob-notice{background:var(--ob-notice-bg);color:var(--ob-notice-text);border:1px solid var(--ob-notice-border);border-radius:var(--ob-radius-card);margin:1rem 0;padding:1rem 1.25rem}.ob-notice h2{color:inherit;margin-bottom:.5rem;font-size:1.125rem}.ob-notice ul{padding-left:1.25rem;list-style:outside}.ob-correction time{display:inline-block}.ob-toc{border-left:3px solid var(--ob-accent);background:var(--ob-surface-raised);border-radius:0 var(--ob-radius-card) var(--ob-radius-card) 0;margin:2rem 0;padding:1rem 1.25rem}.ob-toc h2{margin-bottom:.75rem;font-size:1rem}.ob-toc-item{margin:.35rem 0}.ob-toc-item--h3{padding-left:1rem}.ob-toc a[aria-current=location]{color:var(--ob-heading);font-weight:800;text-decoration-thickness:.15em}.ob-prose{font-size:var(--ob-prose-size);line-height:var(--ob-prose-leading)}.ob-prose>*{margin-block:1.25rem}.ob-prose h2{border-bottom:1px solid var(--ob-border);margin-top:2.5rem;padding-bottom:.5rem}.ob-prose h3,.ob-prose h4{margin-top:2rem}.ob-prose :is(h2,h3,h4,h5,h6){scroll-margin-top:2rem}.ob-heading-anchor{margin-left:.4em;font-size:.8em;text-decoration:none}.ob-prose ul,.ob-prose ol{padding-left:1.5rem}.ob-prose ul{list-style:outside}.ob-prose ol{list-style:decimal}.ob-prose li::marker{color:var(--ob-accent)}.ob-prose li+li{margin-top:.35rem}.ob-prose input[type=checkbox]{margin-right:.4rem}.ob-prose blockquote{border-left:3px solid var(--ob-accent);background:var(--ob-accent-soft);border-radius:0 var(--ob-radius-card) var(--ob-radius-card) 0;padding:.75rem 1.25rem}.ob-prose :not(pre)>code{background:var(--ob-surface-sunken);font-size:.9em;font-family:var(--ob-font-mono);border-radius:.3rem;padding:.15em .3em}.ob-prose pre{background:var(--ob-code-bg);border:1px solid var(--ob-code-border);border-radius:var(--ob-radius-card);overflow-wrap:normal;max-width:100%;padding:3.25rem 1rem 1rem;font-size:.9rem;line-height:1.6;position:relative;overflow-x:auto}.ob-prose .ob-highlight .w{background-color:#0000}.ob-prose pre code{width:max-content;min-width:100%;font-family:var(--ob-font-mono);display:block}.ob-code-language{color:var(--ob-text-subtle);font-family:var(--ob-font-body);font-size:.8rem;position:absolute;top:.65rem;left:1rem}.ob-code-copy{position:absolute;top:.5rem;right:.65rem}.ob-code-copy,.ob-theme-toggle,.ob-share-button{background:var(--ob-surface-raised);min-height:2.5rem;color:var(--ob-text);border:1px solid var(--ob-border-strong);border-radius:var(--ob-radius-pill);justify-content:center;align-items:center;gap:.4rem;padding:.4rem .75rem;display:inline-flex}.ob-icon{flex-shrink:0;width:1.125rem;height:1.125rem}.ob-prose hr{background:linear-gradient(to right, var(--ob-border), var(--ob-accent), var(--ob-border));border:0;height:1px;margin:2.5rem 0}.ob-figure{margin-inline:0}.ob-figure img{border-radius:var(--ob-radius-media)}.ob-figure figcaption{color:var(--ob-text-muted);margin-top:.5rem;font-size:.9rem}.ob-table{border:1px solid var(--ob-border);border-radius:var(--ob-radius-media);max-width:100%;overflow-x:auto}.ob-table table{border-collapse:collapse;width:max-content;min-width:100%;font-size:.95rem}.ob-table th,.ob-table td{text-align:left;border-bottom:1px solid var(--ob-border);padding:.65rem 1rem}.ob-table th{background:var(--ob-surface-raised);color:var(--ob-heading);font-weight:700}.ob-prose .markdown-alert{border-left:4px solid var(--ob-note);background:var(--ob-surface-raised);border-radius:0 var(--ob-radius-card) var(--ob-radius-card) 0;padding:1rem 1.25rem}.ob-prose .markdown-alert-title{color:var(--ob-note);margin-bottom:.5rem;font-weight:750}.ob-prose .markdown-alert-tip{border-color:var(--ob-tip)}.ob-prose .markdown-alert-tip .markdown-alert-title{color:var(--ob-tip)}.ob-prose .markdown-alert-warning,.ob-prose .markdown-alert-caution{border-color:var(--ob-warning)}.ob-prose .markdown-alert-warning .markdown-alert-title,.ob-prose .markdown-alert-caution .markdown-alert-title{color:var(--ob-warning)}.ob-prose--dropcap>p:first-of-type:first-letter{float:left;color:var(--ob-accent);padding-right:.1em;font-size:3.5em;line-height:.9}.ob-faq{border-top:1px solid var(--ob-border);min-width:0;margin-block:2.5rem;padding-top:2rem}.ob-faq>h2{margin-bottom:1.5rem;scroll-margin-top:2rem}.ob-faq-entry+.ob-faq-entry{border-top:1px solid var(--ob-border);margin-top:1.5rem;padding-top:1.5rem}.ob-faq-entry h3{margin-bottom:.75rem}.ob-faq-entry p{font-size:var(--ob-prose-size);line-height:var(--ob-prose-leading);overflow-wrap:anywhere}.ob-faq-entry p+p{margin-top:1rem}.ob-faq-entry a{text-decoration:underline}.ob-tags,.ob-author-profiles,.ob-share{flex-wrap:wrap;align-items:center;gap:.75rem;margin:1.5rem 0;display:flex}.ob-tags a{border-radius:var(--ob-radius-pill);background:var(--ob-accent-soft);padding:.25rem .75rem}.ob-share-status{color:var(--ob-text-muted);font-size:.875rem}.ob-author-box,.ob-call-to-action{background:var(--ob-surface-raised);border:1px solid var(--ob-border);border-radius:var(--ob-radius-card);margin-block:2rem;padding:1.5rem}.ob-author-box h2,.ob-call-to-action h2{margin-bottom:.75rem}.ob-author-bio{margin-top:.75rem}.ob-related-posts,.ob-recent-posts{margin-top:3rem}.ob-related-posts>h2,.ob-recent-posts>h2{margin-bottom:1.5rem}.ob-empty{color:var(--ob-text-muted);padding-block:2rem}.ob-reading-progress{z-index:50;background:var(--ob-border);pointer-events:none;height:3px;position:fixed;inset:0 0 auto}.ob-reading-progress-fill{background:var(--ob-accent);width:0;height:100%}@media (min-width:40rem){.ob-post-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.ob-featured-card{grid-template-columns:minmax(0,1fr) minmax(0,1fr)}.ob-featured-card .ob-card-image,.ob-featured-card .ob-card-placeholder{height:100%}.ob-cover{margin-inline:0}.ob-cover img{border-radius:var(--ob-radius-media)}}@media (min-width:64rem){.ob-list-layout{grid-template-columns:14rem minmax(0,1fr)}.ob-sidebar{align-self:start;position:sticky;top:2rem}.ob-sidebar-categories ul{display:block;overflow:visible}.ob-sidebar-categories li{margin-bottom:.25rem}.ob-sidebar a{justify-content:space-between;gap:.75rem;display:flex}.ob-sidebar-tags{margin-top:1.5rem;display:block}.ob-sidebar-tags ul{flex-wrap:wrap;gap:.5rem;display:flex}.ob-count{display:inline}.ob-related-posts .ob-post-grid{grid-template-columns:repeat(3,minmax(0,1fr))}}@media (prefers-reduced-motion:reduce){.ob-card-image{transition:none}.ob-card-media:hover .ob-card-image{transform:none}}.ob-highlight table td{padding:5px}.ob-highlight table pre{margin:0}.ob-highlight,.ob-highlight .w{color:#24292f;background-color:#f6f8fa}.ob-highlight .k,.ob-highlight .kd,.ob-highlight .kn,.ob-highlight .kp,.ob-highlight .kr,.ob-highlight .kt,.ob-highlight .kv{color:#cf222e}.ob-highlight .gd{color:#82071e;background-color:#ffebe9}.ob-highlight .nb,.ob-highlight .nc,.ob-highlight .no,.ob-highlight .nn{color:#953800}.ob-highlight .sr,.ob-highlight .na,.ob-highlight .nt{color:#116329}.ob-highlight .gi{color:#116329;background-color:#dafbe1}.ob-highlight .ges{font-style:italic;font-weight:700}.ob-highlight .kc,.ob-highlight .l,.ob-highlight .ld,.ob-highlight .m,.ob-highlight .mb,.ob-highlight .mf,.ob-highlight .mh,.ob-highlight .mi,.ob-highlight .il,.ob-highlight .mo,.ob-highlight .mx,.ob-highlight .sb,.ob-highlight .bp,.ob-highlight .ne,.ob-highlight .nl,.ob-highlight .py,.ob-highlight .nv,.ob-highlight .vc,.ob-highlight .vg,.ob-highlight .vi,.ob-highlight .vm,.ob-highlight .o,.ob-highlight .ow{color:#0550ae}.ob-highlight .gh,.ob-highlight .gu{color:#0550ae;font-weight:700}.ob-highlight .s,.ob-highlight .sa,.ob-highlight .sc,.ob-highlight .dl,.ob-highlight .sd,.ob-highlight .s2,.ob-highlight .se,.ob-highlight .sh,.ob-highlight .sx,.ob-highlight .s1,.ob-highlight .ss{color:#0a3069}.ob-highlight .nd,.ob-highlight .nf,.ob-highlight .fm{color:#8250df}.ob-highlight .err{color:#f6f8fa;background-color:#82071e}.ob-highlight .ni,.ob-highlight .si{color:#24292f}.ob-highlight .ge{color:#24292f;font-style:italic}.ob-highlight .gs{color:#24292f;font-weight:700}[data-theme=dark] .ob-highlight table td{padding:5px}[data-theme=dark] .ob-highlight table pre{margin:0}[data-theme=dark] .ob-highlight,[data-theme=dark] .ob-highlight .w{color:#c9d1d9;background-color:#161b22}[data-theme=dark] .ob-highlight .k,[data-theme=dark] .ob-highlight .kd,[data-theme=dark] .ob-highlight .kn,[data-theme=dark] .ob-highlight .kp,[data-theme=dark] .ob-highlight .kr,[data-theme=dark] .ob-highlight .kt,[data-theme=dark] .ob-highlight .kv{color:#ff7b72}[data-theme=dark] .ob-highlight .gr{color:#f0f6fc}[data-theme=dark] .ob-highlight .gd{color:#ffdcd7;background-color:#67060c}[data-theme=dark] .ob-highlight .nb,[data-theme=dark] .ob-highlight .nc,[data-theme=dark] .ob-highlight .no,[data-theme=dark] .ob-highlight .nn{color:#ffa657}[data-theme=dark] .ob-highlight .sr,[data-theme=dark] .ob-highlight .na,[data-theme=dark] .ob-highlight .nt{color:#7ee787}[data-theme=dark] .ob-highlight .gi{color:#aff5b4;background-color:#033a16}[data-theme=dark] .ob-highlight .ges{font-style:italic;font-weight:700}[data-theme=dark] .ob-highlight .kc,[data-theme=dark] .ob-highlight .l,[data-theme=dark] .ob-highlight .ld,[data-theme=dark] .ob-highlight .m,[data-theme=dark] .ob-highlight .mb,[data-theme=dark] .ob-highlight .mf,[data-theme=dark] .ob-highlight .mh,[data-theme=dark] .ob-highlight .mi,[data-theme=dark] .ob-highlight .il,[data-theme=dark] .ob-highlight .mo,[data-theme=dark] .ob-highlight .mx,[data-theme=dark] .ob-highlight .sb,[data-theme=dark] .ob-highlight .bp,[data-theme=dark] .ob-highlight .ne,[data-theme=dark] .ob-highlight .nl,[data-theme=dark] .ob-highlight .py,[data-theme=dark] .ob-highlight .nv,[data-theme=dark] .ob-highlight .vc,[data-theme=dark] .ob-highlight .vg,[data-theme=dark] .ob-highlight .vi,[data-theme=dark] .ob-highlight .vm,[data-theme=dark] .ob-highlight .o,[data-theme=dark] .ob-highlight .ow{color:#79c0ff}[data-theme=dark] .ob-highlight .gh,[data-theme=dark] .ob-highlight .gu{color:#1f6feb;font-weight:700}[data-theme=dark] .ob-highlight .s,[data-theme=dark] .ob-highlight .sa,[data-theme=dark] .ob-highlight .sc,[data-theme=dark] .ob-highlight .dl,[data-theme=dark] .ob-highlight .sd,[data-theme=dark] .ob-highlight .s2,[data-theme=dark] .ob-highlight .se,[data-theme=dark] .ob-highlight .sh,[data-theme=dark] .ob-highlight .sx,[data-theme=dark] .ob-highlight .s1,[data-theme=dark] .ob-highlight .ss{color:#a5d6ff}[data-theme=dark] .ob-highlight .nd,[data-theme=dark] .ob-highlight .nf,[data-theme=dark] .ob-highlight .fm{color:#d2a8ff}[data-theme=dark] .ob-highlight .err{color:#f0f6fc;background-color:#8e1519}[data-theme=dark] .ob-highlight .c,[data-theme=dark] .ob-highlight .ch,[data-theme=dark] .ob-highlight .cd,[data-theme=dark] .ob-highlight .cm,[data-theme=dark] .ob-highlight .cp,[data-theme=dark] .ob-highlight .cpf,[data-theme=dark] .ob-highlight .c1,[data-theme=dark] .ob-highlight .cs,[data-theme=dark] .ob-highlight .gl,[data-theme=dark] .ob-highlight .gt{color:#8b949e}[data-theme=dark] .ob-highlight .ni,[data-theme=dark] .ob-highlight .si{color:#c9d1d9}[data-theme=dark] .ob-highlight .ge{color:#c9d1d9;font-style:italic}[data-theme=dark] .ob-highlight .gs{color:#c9d1d9;font-weight:700}@media (prefers-color-scheme:dark){html:not([data-theme=light]) .ob-highlight table td{padding:5px}html:not([data-theme=light]) .ob-highlight table pre{margin:0}html:not([data-theme=light]) .ob-highlight,html:not([data-theme=light]) .ob-highlight .w{color:#c9d1d9;background-color:#161b22}html:not([data-theme=light]) .ob-highlight .k,html:not([data-theme=light]) .ob-highlight .kd,html:not([data-theme=light]) .ob-highlight .kn,html:not([data-theme=light]) .ob-highlight .kp,html:not([data-theme=light]) .ob-highlight .kr,html:not([data-theme=light]) .ob-highlight .kt,html:not([data-theme=light]) .ob-highlight .kv{color:#ff7b72}html:not([data-theme=light]) .ob-highlight .gr{color:#f0f6fc}html:not([data-theme=light]) .ob-highlight .gd{color:#ffdcd7;background-color:#67060c}html:not([data-theme=light]) .ob-highlight .nb,html:not([data-theme=light]) .ob-highlight .nc,html:not([data-theme=light]) .ob-highlight .no,html:not([data-theme=light]) .ob-highlight .nn{color:#ffa657}html:not([data-theme=light]) .ob-highlight .sr,html:not([data-theme=light]) .ob-highlight .na,html:not([data-theme=light]) .ob-highlight .nt{color:#7ee787}html:not([data-theme=light]) .ob-highlight .gi{color:#aff5b4;background-color:#033a16}html:not([data-theme=light]) .ob-highlight .ges{font-style:italic;font-weight:700}html:not([data-theme=light]) .ob-highlight .kc,html:not([data-theme=light]) .ob-highlight .l,html:not([data-theme=light]) .ob-highlight .ld,html:not([data-theme=light]) .ob-highlight .m,html:not([data-theme=light]) .ob-highlight .mb,html:not([data-theme=light]) .ob-highlight .mf,html:not([data-theme=light]) .ob-highlight .mh,html:not([data-theme=light]) .ob-highlight .mi,html:not([data-theme=light]) .ob-highlight .il,html:not([data-theme=light]) .ob-highlight .mo,html:not([data-theme=light]) .ob-highlight .mx,html:not([data-theme=light]) .ob-highlight .sb,html:not([data-theme=light]) .ob-highlight .bp,html:not([data-theme=light]) .ob-highlight .ne,html:not([data-theme=light]) .ob-highlight .nl,html:not([data-theme=light]) .ob-highlight .py,html:not([data-theme=light]) .ob-highlight .nv,html:not([data-theme=light]) .ob-highlight .vc,html:not([data-theme=light]) .ob-highlight .vg,html:not([data-theme=light]) .ob-highlight .vi,html:not([data-theme=light]) .ob-highlight .vm,html:not([data-theme=light]) .ob-highlight .o,html:not([data-theme=light]) .ob-highlight .ow{color:#79c0ff}html:not([data-theme=light]) .ob-highlight .gh,html:not([data-theme=light]) .ob-highlight .gu{color:#1f6feb;font-weight:700}html:not([data-theme=light]) .ob-highlight .s,html:not([data-theme=light]) .ob-highlight .sa,html:not([data-theme=light]) .ob-highlight .sc,html:not([data-theme=light]) .ob-highlight .dl,html:not([data-theme=light]) .ob-highlight .sd,html:not([data-theme=light]) .ob-highlight .s2,html:not([data-theme=light]) .ob-highlight .se,html:not([data-theme=light]) .ob-highlight .sh,html:not([data-theme=light]) .ob-highlight .sx,html:not([data-theme=light]) .ob-highlight .s1,html:not([data-theme=light]) .ob-highlight .ss{color:#a5d6ff}html:not([data-theme=light]) .ob-highlight .nd,html:not([data-theme=light]) .ob-highlight .nf,html:not([data-theme=light]) .ob-highlight .fm{color:#d2a8ff}html:not([data-theme=light]) .ob-highlight .err{color:#f0f6fc;background-color:#8e1519}html:not([data-theme=light]) .ob-highlight .c,html:not([data-theme=light]) .ob-highlight .ch,html:not([data-theme=light]) .ob-highlight .cd,html:not([data-theme=light]) .ob-highlight .cm,html:not([data-theme=light]) .ob-highlight .cp,html:not([data-theme=light]) .ob-highlight .cpf,html:not([data-theme=light]) .ob-highlight .c1,html:not([data-theme=light]) .ob-highlight .cs,html:not([data-theme=light]) .ob-highlight .gl,html:not([data-theme=light]) .ob-highlight .gt{color:#8b949e}html:not([data-theme=light]) .ob-highlight .ni,html:not([data-theme=light]) .ob-highlight .si{color:#c9d1d9}html:not([data-theme=light]) .ob-highlight .ge{color:#c9d1d9;font-style:italic}html:not([data-theme=light]) .ob-highlight .gs{color:#c9d1d9;font-weight:700}}.ob-highlight .gr{color:#cf222e}.ob-highlight .c,.ob-highlight .ch,.ob-highlight .cd,.ob-highlight .cm,.ob-highlight .cp,.ob-highlight .cpf,.ob-highlight .c1,.ob-highlight .cs,.ob-highlight .gl,.ob-highlight .gt{color:#57606a}[data-theme=dark] .ob-highlight .gh,[data-theme=dark] .ob-highlight .gu{color:#79c0ff}@media (prefers-color-scheme:dark){html:not([data-theme=light]) .ob-highlight .gh,html:not([data-theme=light]) .ob-highlight .gu{color:#79c0ff}}:root{--ob-surface:#fff;--ob-surface-raised:#f8fafc;--ob-surface-sunken:#f1f5f9;--ob-heading:#0f172a;--ob-text:#334155;--ob-text-muted:#475569;--ob-text-subtle:#526176;--ob-border:#cbd5e1;--ob-border-strong:#64748b;--ob-accent:#1d4ed8;--ob-accent-hover:#1e40af;--ob-accent-soft:#dbeafe;--ob-accent-contrast:#fff;--ob-code-bg:#f6f8fa;--ob-code-border:#94a3b8;--ob-notice-bg:#eff6ff;--ob-notice-border:#60a5fa;--ob-notice-text:#1e3a8a;--ob-note:#1d4ed8;--ob-tip:#166534;--ob-warning:#854d0e;--ob-shadow:0 8px 24px #0f172a1a;--ob-font-display:ui-sans-serif, system-ui, sans-serif;--ob-font-body:ui-sans-serif, system-ui, sans-serif;--ob-font-mono:ui-monospace, SFMono-Regular, Consolas, monospace;--ob-radius-card:1rem;--ob-radius-media:.75rem;--ob-radius-pill:999px;--ob-prose-size:1.125rem;--ob-prose-leading:1.8;--ob-content-width:56rem;--ob-page-width:72rem}:root[data-theme=dark]{--ob-surface:#0f172a;--ob-surface-raised:#1e293b;--ob-surface-sunken:#020617;--ob-heading:#f8fafc;--ob-text:#e2e8f0;--ob-text-muted:#cbd5e1;--ob-text-subtle:#94a3b8;--ob-border:#475569;--ob-border-strong:#94a3b8;--ob-accent:#93c5fd;--ob-accent-hover:#bfdbfe;--ob-accent-soft:#172554;--ob-accent-contrast:#0f172a;--ob-code-bg:#161b22;--ob-code-border:#64748b;--ob-notice-bg:#172554;--ob-notice-border:#60a5fa;--ob-notice-text:#dbeafe;--ob-note:#93c5fd;--ob-tip:#86efac;--ob-warning:#fde047}@media (prefers-color-scheme:dark){:root:not([data-theme=light]){--ob-surface:#0f172a;--ob-surface-raised:#1e293b;--ob-surface-sunken:#020617;--ob-heading:#f8fafc;--ob-text:#e2e8f0;--ob-text-muted:#cbd5e1;--ob-text-subtle:#94a3b8;--ob-border:#475569;--ob-border-strong:#94a3b8;--ob-accent:#93c5fd;--ob-accent-hover:#bfdbfe;--ob-accent-soft:#172554;--ob-accent-contrast:#0f172a;--ob-code-bg:#161b22;--ob-code-border:#64748b;--ob-notice-bg:#172554;--ob-notice-border:#60a5fa;--ob-notice-text:#dbeafe;--ob-note:#93c5fd;--ob-tip:#86efac;--ob-warning:#fde047}}:root{color-scheme:light dark}:root[data-theme=light]{color-scheme:light}:root[data-theme=dark]{color-scheme:dark}@property --tw-rotate-x{syntax:"*";inherits:false}@property --tw-rotate-y{syntax:"*";inherits:false}@property --tw-rotate-z{syntax:"*";inherits:false}@property --tw-skew-x{syntax:"*";inherits:false}@property --tw-skew-y{syntax:"*";inherits:false}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
export default class extends Controller {
|
|
4
|
+
static targets = ["code", "button"]
|
|
5
|
+
static values = { copiedLabel: String, errorLabel: String }
|
|
6
|
+
|
|
7
|
+
connect() {
|
|
8
|
+
this.connected = true
|
|
9
|
+
this.label = this.buttonTarget.textContent
|
|
10
|
+
this.accessibleLabel = this.buttonTarget.getAttribute("aria-label")
|
|
11
|
+
this.buttonTarget.hidden = !navigator.clipboard?.writeText
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
disconnect() {
|
|
15
|
+
this.connected = false
|
|
16
|
+
clearTimeout(this.resetTimer)
|
|
17
|
+
this.restore()
|
|
18
|
+
this.buttonTarget.hidden = true
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async copy() {
|
|
22
|
+
let label
|
|
23
|
+
try {
|
|
24
|
+
await navigator.clipboard.writeText(this.codeTarget.textContent)
|
|
25
|
+
label = this.copiedLabelValue
|
|
26
|
+
} catch (_) {
|
|
27
|
+
label = this.errorLabelValue
|
|
28
|
+
}
|
|
29
|
+
if (!this.connected) return
|
|
30
|
+
clearTimeout(this.resetTimer)
|
|
31
|
+
this.buttonTarget.textContent = label
|
|
32
|
+
this.buttonTarget.setAttribute("aria-label", label)
|
|
33
|
+
this.resetTimer = setTimeout(() => this.restore(), 2000)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
restore() {
|
|
37
|
+
this.buttonTarget.textContent = this.label
|
|
38
|
+
this.buttonTarget.setAttribute("aria-label", this.accessibleLabel)
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
export default class extends Controller {
|
|
4
|
+
static targets = ["progress"]
|
|
5
|
+
static values = { contentSelector: { type: String, default: "[data-open-blog-content]" } }
|
|
6
|
+
|
|
7
|
+
connect() {
|
|
8
|
+
this.content = document.querySelector(this.contentSelectorValue)
|
|
9
|
+
if (!this.content || !this.hasProgressTarget) return
|
|
10
|
+
this.schedule = () => {
|
|
11
|
+
if (!this.frame) this.frame = requestAnimationFrame(() => { this.frame = null; this.update() })
|
|
12
|
+
}
|
|
13
|
+
window.addEventListener("scroll", this.schedule, { passive: true })
|
|
14
|
+
window.addEventListener("resize", this.schedule)
|
|
15
|
+
this.observer = new ResizeObserver(this.schedule)
|
|
16
|
+
this.observer.observe(this.content)
|
|
17
|
+
this.update()
|
|
18
|
+
this.element.hidden = false
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
disconnect() {
|
|
22
|
+
window.removeEventListener("scroll", this.schedule)
|
|
23
|
+
window.removeEventListener("resize", this.schedule)
|
|
24
|
+
this.observer?.disconnect()
|
|
25
|
+
cancelAnimationFrame(this.frame)
|
|
26
|
+
this.frame = null
|
|
27
|
+
this.element.hidden = true
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
update() {
|
|
31
|
+
const rect = this.content.getBoundingClientRect()
|
|
32
|
+
const distance = rect.height - window.innerHeight
|
|
33
|
+
const progress = distance <= 0 ? (rect.bottom <= window.innerHeight ? 1 : 0) : Math.max(0, Math.min(1, -rect.top / distance))
|
|
34
|
+
this.progressTarget.style.width = `${progress * 100}%`
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
export default class extends Controller {
|
|
4
|
+
static targets = ["input", "results"]
|
|
5
|
+
static values = { url: String }
|
|
6
|
+
|
|
7
|
+
connect() { this.sequence = (this.sequence || 0) + 1 }
|
|
8
|
+
|
|
9
|
+
disconnect() {
|
|
10
|
+
this.sequence++
|
|
11
|
+
clearTimeout(this.timer)
|
|
12
|
+
this.request?.abort()
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
search() {
|
|
16
|
+
clearTimeout(this.timer)
|
|
17
|
+
this.request?.abort()
|
|
18
|
+
const sequence = ++this.sequence
|
|
19
|
+
const query = this.inputTarget.value.trim()
|
|
20
|
+
this.resultsTarget.replaceChildren()
|
|
21
|
+
this.resultsTarget.hidden = true
|
|
22
|
+
if (Array.from(query).length < 2 || Array.from(query).length > 100) return
|
|
23
|
+
this.timer = setTimeout(() => this.suggest(query, sequence), 200)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async suggest(query, sequence) {
|
|
27
|
+
this.request = new AbortController()
|
|
28
|
+
try {
|
|
29
|
+
const url = new URL(this.urlValue, window.location.href)
|
|
30
|
+
url.searchParams.set("q", query)
|
|
31
|
+
const response = await fetch(url, { signal: this.request.signal, headers: { Accept: "application/json" } })
|
|
32
|
+
if (!response.ok) return
|
|
33
|
+
const results = await response.json()
|
|
34
|
+
if (sequence !== this.sequence || !Array.isArray(results)) return
|
|
35
|
+
const items = results.map(result => {
|
|
36
|
+
const item = document.createElement("li")
|
|
37
|
+
const link = document.createElement("a")
|
|
38
|
+
link.href = result.url
|
|
39
|
+
link.textContent = result.title
|
|
40
|
+
item.append(link)
|
|
41
|
+
return item
|
|
42
|
+
})
|
|
43
|
+
this.resultsTarget.replaceChildren(...items)
|
|
44
|
+
this.resultsTarget.hidden = items.length === 0
|
|
45
|
+
} catch (_) {
|
|
46
|
+
// The ordinary GET form remains available when suggestions cannot load.
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
export default class extends Controller {
|
|
4
|
+
static targets = ["device", "copy", "status"]
|
|
5
|
+
static values = { url: String, title: String, copiedLabel: String, copyLabel: String, errorLabel: String }
|
|
6
|
+
|
|
7
|
+
connect() {
|
|
8
|
+
this.connected = true
|
|
9
|
+
if (this.hasDeviceTarget) this.deviceTarget.hidden = typeof navigator.share !== "function"
|
|
10
|
+
if (this.hasCopyTarget) this.copyTarget.hidden = !navigator.clipboard?.writeText
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
disconnect() {
|
|
14
|
+
this.connected = false
|
|
15
|
+
clearTimeout(this.resetTimer)
|
|
16
|
+
if (this.hasDeviceTarget) this.deviceTarget.hidden = true
|
|
17
|
+
if (this.hasCopyTarget) {
|
|
18
|
+
this.copyTarget.hidden = true
|
|
19
|
+
}
|
|
20
|
+
if (this.hasStatusTarget) this.statusTarget.textContent = ""
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async share() {
|
|
24
|
+
try {
|
|
25
|
+
await navigator.share({ title: this.titleValue, url: this.urlValue })
|
|
26
|
+
} catch (error) {
|
|
27
|
+
if (error.name !== "AbortError") this.announce(this.errorLabelValue)
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async copy() {
|
|
32
|
+
try {
|
|
33
|
+
await navigator.clipboard.writeText(this.urlValue)
|
|
34
|
+
this.announce(this.copiedLabelValue)
|
|
35
|
+
} catch (_) {
|
|
36
|
+
this.announce(this.errorLabelValue)
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
announce(label) {
|
|
41
|
+
if (!this.connected) return
|
|
42
|
+
clearTimeout(this.resetTimer)
|
|
43
|
+
if (this.hasStatusTarget) this.statusTarget.textContent = label
|
|
44
|
+
this.resetTimer = setTimeout(() => {
|
|
45
|
+
if (this.hasStatusTarget) this.statusTarget.textContent = ""
|
|
46
|
+
}, 2000)
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
export default class extends Controller {
|
|
4
|
+
static targets = ["label"]
|
|
5
|
+
static values = { systemLabel: String, lightLabel: String, darkLabel: String }
|
|
6
|
+
|
|
7
|
+
connect() {
|
|
8
|
+
this.mode = "system"
|
|
9
|
+
try {
|
|
10
|
+
const stored = localStorage.getItem("open-blog-theme")
|
|
11
|
+
if (stored === "light" || stored === "dark") this.mode = stored
|
|
12
|
+
} catch (_) {}
|
|
13
|
+
this.media = window.matchMedia("(prefers-color-scheme: dark)")
|
|
14
|
+
this.onMediaChange = () => this.apply()
|
|
15
|
+
this.onStorage = (event) => {
|
|
16
|
+
if (event.key !== "open-blog-theme" && event.key !== null) return
|
|
17
|
+
this.mode = ["light", "dark"].includes(event.newValue) ? event.newValue : "system"
|
|
18
|
+
this.apply()
|
|
19
|
+
}
|
|
20
|
+
this.media.addEventListener("change", this.onMediaChange)
|
|
21
|
+
window.addEventListener("storage", this.onStorage)
|
|
22
|
+
this.apply()
|
|
23
|
+
this.element.hidden = false
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
disconnect() {
|
|
27
|
+
this.media?.removeEventListener("change", this.onMediaChange)
|
|
28
|
+
window.removeEventListener("storage", this.onStorage)
|
|
29
|
+
this.element.hidden = true
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
cycle() {
|
|
33
|
+
this.mode = { system: "light", light: "dark", dark: "system" }[this.mode]
|
|
34
|
+
try {
|
|
35
|
+
if (this.mode === "system") localStorage.removeItem("open-blog-theme")
|
|
36
|
+
else localStorage.setItem("open-blog-theme", this.mode)
|
|
37
|
+
} catch (_) {}
|
|
38
|
+
this.apply()
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
apply() {
|
|
42
|
+
const root = document.documentElement
|
|
43
|
+
if (this.mode === "system") delete root.dataset.theme
|
|
44
|
+
else root.dataset.theme = this.mode
|
|
45
|
+
const label = this[`${this.mode}LabelValue`]
|
|
46
|
+
if (this.hasLabelTarget) this.labelTarget.textContent = label
|
|
47
|
+
this.element.setAttribute("aria-label", label)
|
|
48
|
+
const surface = getComputedStyle(root).getPropertyValue("--ob-surface").trim()
|
|
49
|
+
if (surface) document.querySelectorAll('meta[name="theme-color"]').forEach(meta => meta.content = surface)
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
export default class extends Controller {
|
|
4
|
+
connect() {
|
|
5
|
+
this.links = [...this.element.querySelectorAll('a[href^="#"]')]
|
|
6
|
+
this.headings = this.links.map(link => document.getElementById(decodeURIComponent(link.hash.slice(1))))
|
|
7
|
+
this.schedule = () => {
|
|
8
|
+
if (!this.frame) this.frame = requestAnimationFrame(() => { this.frame = null; this.update() })
|
|
9
|
+
}
|
|
10
|
+
window.addEventListener("scroll", this.schedule, { passive: true })
|
|
11
|
+
window.addEventListener("resize", this.schedule)
|
|
12
|
+
this.update()
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
disconnect() {
|
|
16
|
+
window.removeEventListener("scroll", this.schedule)
|
|
17
|
+
window.removeEventListener("resize", this.schedule)
|
|
18
|
+
cancelAnimationFrame(this.frame)
|
|
19
|
+
this.frame = null
|
|
20
|
+
this.links.forEach(link => link.removeAttribute("aria-current"))
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
update() {
|
|
24
|
+
let active = this.headings.findIndex(heading => heading)
|
|
25
|
+
this.headings.forEach((heading, index) => {
|
|
26
|
+
if (heading && heading.getBoundingClientRect().top <= 120) active = index
|
|
27
|
+
})
|
|
28
|
+
this.links.forEach((link, index) => {
|
|
29
|
+
if (index === active) link.setAttribute("aria-current", "location")
|
|
30
|
+
else link.removeAttribute("aria-current")
|
|
31
|
+
})
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
module OpenBlog
|
|
2
|
+
module ReaderController
|
|
3
|
+
extend ActiveSupport::Concern
|
|
4
|
+
|
|
5
|
+
included do
|
|
6
|
+
helper :all
|
|
7
|
+
layout -> { OpenBlog.config.layout }
|
|
8
|
+
around_action :use_blog_locale
|
|
9
|
+
rescue_from OpenBlog::NotFound, ActiveRecord::RecordNotFound, with: :render_not_found
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
private
|
|
13
|
+
|
|
14
|
+
def use_blog_locale(&block)
|
|
15
|
+
I18n.with_locale(OpenBlog.config.locale, &block)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def require_html
|
|
19
|
+
raise NotFound if params[:format].present? && params[:format] != "html"
|
|
20
|
+
request.format = :html
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def origin
|
|
24
|
+
OpenBlog.config.public_base_url || request.base_url
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def page_context(kind, path:, record: nil, pagination: nil, breadcrumbs: [])
|
|
28
|
+
@open_blog_page = ReaderPage.new(kind: kind, path: path, base_url: origin, record: record, pagination: pagination, breadcrumbs: breadcrumbs)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def paginate(scope)
|
|
32
|
+
Pagination.page(scope, page: params[:page], per_page: OpenBlog.config.posts_per_page)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def load_sidebar
|
|
36
|
+
@popular_posts = ReaderQueries.popular
|
|
37
|
+
sidebar = ReaderQueries.sidebar
|
|
38
|
+
@sidebar_categories, @sidebar_tags = sidebar.values_at(:categories, :tags)
|
|
39
|
+
@sidebar_cache_key = ReaderQueries.sidebar_cache_key(sidebar) + [ controller_name, params[:slug] ]
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def render_not_found
|
|
43
|
+
request.format = :html
|
|
44
|
+
@posts = ReaderQueries.posts.limit(6).to_a
|
|
45
|
+
page_context(:not_found, path: request.path)
|
|
46
|
+
render "open_blog/errors/not_found", status: :not_found, formats: [ :html ], content_type: "text/html"
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
module OpenBlog
|
|
2
|
+
module Api
|
|
3
|
+
module V1
|
|
4
|
+
class AdoptionsController < BaseController
|
|
5
|
+
def create
|
|
6
|
+
require_scope!(:publish)
|
|
7
|
+
attributes = input_fields!(*ApiFields::ADOPTION)
|
|
8
|
+
ApiFields::POST_NESTED.except(:approval, :connections).each do |field, allowed|
|
|
9
|
+
values = field == :faq && attributes[field].is_a?(Array) ? attributes[field] : [ attributes[field] ]
|
|
10
|
+
values.each do |value|
|
|
11
|
+
next unless value.is_a?(Hash)
|
|
12
|
+
unknown = value.keys - allowed
|
|
13
|
+
raise Error::UnknownField.new(details: unknown.map { |key| "#{field}.#{key}" }) if unknown.any?
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
if attributes.key?(:author) && !attributes[:author].is_a?(String) && !attributes[:author].is_a?(Hash)
|
|
17
|
+
raise Error::ValidationFailed.new(details: [ "author" ])
|
|
18
|
+
end
|
|
19
|
+
ApiFields.validate_post_images!(attributes)
|
|
20
|
+
result = Adopt.call(attributes, actor: actor.name)
|
|
21
|
+
return render_error(result.error) unless result.success?
|
|
22
|
+
render json: AdoptionResultSerializer.call(result, base_url: request.base_url), status: result.created ? :created : :ok
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
module OpenBlog
|
|
2
|
+
module Api
|
|
3
|
+
module V1
|
|
4
|
+
class ApprovalsController < BaseController
|
|
5
|
+
def create
|
|
6
|
+
require_scope!(:publish)
|
|
7
|
+
input = input_fields!(*ApiFields::APPROVAL)
|
|
8
|
+
result = Approve.call(find_post!, revision_identifier: input[:revision_identifier],
|
|
9
|
+
name: input[:name], facts_checked: input[:facts_checked], actor: actor.name)
|
|
10
|
+
render_result(result, status: :ok)
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|