plutonium 0.62.2 → 0.64.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 +4 -4
- data/.claude/skills/plutonium/SKILL.md +44 -0
- data/.claude/skills/plutonium-app/SKILL.md +3 -3
- data/.claude/skills/plutonium-async-interactions/SKILL.md +191 -0
- data/.claude/skills/plutonium-auth/SKILL.md +36 -0
- data/.claude/skills/plutonium-behavior/SKILL.md +121 -24
- data/.claude/skills/plutonium-kanban/SKILL.md +17 -3
- data/.claude/skills/plutonium-resource/SKILL.md +259 -12
- data/.claude/skills/plutonium-tenancy/SKILL.md +32 -3
- data/.claude/skills/plutonium-ui/SKILL.md +115 -14
- data/.claude/skills/plutonium-wizard/SKILL.md +73 -4
- data/.standard.yml +1 -1
- data/CHANGELOG.md +73 -0
- data/CLAUDE.md +87 -0
- data/Rakefile +34 -0
- data/SECURITY.md +1 -1
- data/app/assets/plutonium.css +1 -1
- data/app/assets/plutonium.js +685 -102
- data/app/assets/plutonium.js.map +4 -4
- data/app/assets/plutonium.min.js +53 -53
- data/app/assets/plutonium.min.js.map +4 -4
- data/app/views/rodauth/_login_form.html.erb +13 -0
- data/db/migrate/async_interactions/20260817000001_create_plutonium_async_runs.rb +170 -0
- data/docs/.vitepress/config.ts +81 -3
- data/docs/.vitepress/theme/blog.data.ts +44 -0
- data/docs/.vitepress/theme/components/BlogIndex.vue +87 -0
- data/docs/.vitepress/theme/components/BlogMeta.vue +47 -0
- data/docs/.vitepress/theme/components/HomeFeatureTour.vue +293 -0
- data/docs/.vitepress/theme/components/HomeHero.vue +3 -3
- data/docs/.vitepress/theme/components/HomeInTheBox.vue +8 -0
- data/docs/.vitepress/theme/components/HomeStopWriting.vue +1 -0
- data/docs/.vitepress/theme/components/HomeWhyPlutonium.vue +84 -0
- data/docs/.vitepress/theme/index.ts +8 -4
- data/docs/blog/association-inputs-post-signed-ids.md +70 -0
- data/docs/blog/fix-the-model-not-the-policy.md +122 -0
- data/docs/blog/fractional-ordering-runs-out-of-room.md +67 -0
- data/docs/blog/half-finished-forms-are-pii.md +76 -0
- data/docs/blog/index.md +13 -0
- data/docs/blog/interactions-are-presentation-objects.md +152 -0
- data/docs/blog/introducing-plutonium.md +253 -0
- data/docs/blog/jobs-are-not-permission-snapshots.md +100 -0
- data/docs/blog/plutonium-and-ai-agents.md +48 -0
- data/docs/blog/realtime-is-one-line-and-four-dependencies.md +70 -0
- data/docs/blog/two-forms-one-dom-id.md +69 -0
- data/docs/blog/whats-new-async-kanban-wizards.md +130 -0
- data/docs/getting-started/tutorial/04-authorization.md +12 -3
- data/docs/getting-started/tutorial/06-nested-resources.md +3 -1
- data/docs/getting-started/tutorial/07-author-portal.md +2 -2
- data/docs/guides/authentication.md +73 -0
- data/docs/guides/authorization.md +2 -0
- data/docs/guides/creating-packages.md +5 -3
- data/docs/guides/custom-actions.md +74 -12
- data/docs/guides/customizing-ui.md +9 -2
- data/docs/guides/index.md +1 -0
- data/docs/guides/kanban.md +7 -5
- data/docs/guides/nested-resources.md +11 -1
- data/docs/guides/performance.md +104 -0
- data/docs/guides/user-invites.md +1 -1
- data/docs/guides/wizards.md +9 -1
- data/docs/index.md +3 -3
- data/docs/public/images/home/tour-actions.png +0 -0
- data/docs/public/images/home/tour-async.png +0 -0
- data/docs/public/images/home/tour-kanban.png +0 -0
- data/docs/public/images/home/tour-tenancy.png +0 -0
- data/docs/public/images/home/tour-wizard.png +0 -0
- data/docs/public/images/reference/async-progress-page.png +0 -0
- data/docs/public/images/reference/async-running-banner.png +0 -0
- data/docs/public/templates/experimental.rb +34 -0
- data/docs/public/templates/pluton8.rb +14 -0
- data/docs/reference/app/portals.md +15 -3
- data/docs/reference/auth/accounts.md +19 -0
- data/docs/reference/behavior/async-interactions.md +295 -0
- data/docs/reference/behavior/controllers.md +17 -4
- data/docs/reference/behavior/index.md +7 -1
- data/docs/reference/behavior/interactions.md +152 -22
- data/docs/reference/configuration.md +5 -0
- data/docs/reference/index.md +1 -0
- data/docs/reference/kanban/dsl.md +7 -4
- data/docs/reference/kanban/index.md +1 -1
- data/docs/reference/kanban/positioning.md +26 -4
- data/docs/reference/positioning.md +568 -0
- data/docs/reference/resource/actions.md +97 -4
- data/docs/reference/resource/definition.md +181 -9
- data/docs/reference/tenancy/invites.md +1 -1
- data/docs/reference/tenancy/nested-resources.md +60 -2
- data/docs/reference/ui/assets.md +4 -0
- data/docs/reference/ui/components.md +57 -4
- data/docs/reference/ui/displays.md +20 -10
- data/docs/reference/ui/index.md +1 -1
- data/docs/reference/wizard/dsl.md +33 -0
- data/docs/reference/wizard/storage-config.md +1 -0
- data/docs/superpowers/plans/2026-07-16-homepage-depth-upgrade.md +624 -0
- data/docs/superpowers/plans/2026-07-16-homepage-depth-upgrade.md.tasks.json +32 -0
- data/docs/superpowers/plans/2026-07-31-positioned-drag-and-drop.md +1787 -0
- data/docs/superpowers/plans/2026-07-31-positioned-drag-and-drop.md.tasks.json +91 -0
- data/docs/superpowers/plans/2026-08-17-async-interactions.md +1414 -0
- data/docs/superpowers/plans/2026-08-17-async-interactions.md.tasks.json +66 -0
- data/docs/superpowers/specs/2026-07-16-homepage-depth-upgrade-design.md +111 -0
- data/docs/superpowers/specs/2026-07-17-action-html-attributes-design.md +124 -0
- data/docs/superpowers/specs/2026-07-31-positioned-drag-and-drop-design.md +506 -0
- data/docs/superpowers/specs/2026-08-17-async-interactions-design.md +185 -0
- data/gemfiles/postgres.gemfile.lock +85 -85
- data/gemfiles/rails_7.gemfile.lock +323 -141
- data/gemfiles/rails_8.0.gemfile.lock +126 -116
- data/gemfiles/rails_8.1.gemfile.lock +127 -117
- data/lib/generators/pu/async_interactions/install_generator.rb +111 -0
- data/lib/generators/pu/async_interactions/templates/app/controllers/async_runs_controller.rb.tt +15 -0
- data/lib/generators/pu/core/typespec/typespec_generator.rb +7 -4
- data/lib/generators/pu/invites/install_generator.rb +3 -3
- data/lib/generators/pu/invites/templates/packages/invites/app/views/layouts/invites/invitation.html.erb.tt +2 -2
- data/lib/generators/pu/lib/plutonium_generators/concerns/mounts_engines.rb +47 -2
- data/lib/generators/pu/lib/plutonium_generators/concerns/resource_registration.rb +41 -0
- data/lib/generators/pu/lite/litestream/litestream_generator.rb +1 -1
- data/lib/generators/pu/lite/solid_queue/solid_queue_generator.rb +1 -1
- data/lib/generators/pu/res/conn/conn_generator.rb +19 -39
- data/lib/generators/pu/res/conn/templates/app/controllers/resource_controller.rb.tt +4 -0
- data/lib/generators/pu/rodauth/templates/app/rodauth/account_rodauth_plugin.rb.tt +15 -6
- data/lib/generators/pu/rodauth/templates/app/rodauth/rodauth_plugin.rb.tt +7 -0
- data/lib/generators/pu/saas/welcome/templates/app/views/layouts/welcome.html.erb.tt +2 -2
- data/lib/generators/pu/wizards/install_generator.rb +78 -0
- data/lib/plutonium/action/base.rb +71 -9
- data/lib/plutonium/action/interactive.rb +9 -0
- data/lib/plutonium/attachments.rb +254 -0
- data/lib/plutonium/configuration.rb +82 -1
- data/lib/plutonium/core/controller.rb +50 -7
- data/lib/plutonium/core/controllers/authorizable.rb +16 -0
- data/lib/plutonium/core/controllers/entity_scoping.rb +12 -2
- data/lib/plutonium/definition/base.rb +51 -0
- data/lib/plutonium/definition/display_layout.rb +112 -0
- data/lib/plutonium/definition/index_views.rb +8 -7
- data/lib/plutonium/definition/input_aliases.rb +38 -0
- data/lib/plutonium/definition/page_widths.rb +65 -0
- data/lib/plutonium/definition/positioning.rb +126 -0
- data/lib/plutonium/definition/sorting.rb +17 -2
- data/lib/plutonium/helpers/turbo_helper.rb +7 -0
- data/lib/plutonium/interaction/README.md +61 -24
- data/lib/plutonium/interaction/async/configuration.rb +38 -0
- data/lib/plutonium/interaction/async/context.rb +419 -0
- data/lib/plutonium/interaction/async/executor.rb +422 -0
- data/lib/plutonium/interaction/async/job.rb +80 -0
- data/lib/plutonium/interaction/async/reap_job.rb +81 -0
- data/lib/plutonium/interaction/async/run.rb +394 -0
- data/lib/plutonium/interaction/async/run_definition.rb +155 -0
- data/lib/plutonium/interaction/async/run_policy.rb +86 -0
- data/lib/plutonium/interaction/base.rb +34 -7
- data/lib/plutonium/interaction/concerns/dispatchable.rb +518 -0
- data/lib/plutonium/interaction/concerns/scoping.rb +70 -9
- data/lib/plutonium/interaction/response/redirect.rb +11 -3
- data/lib/plutonium/kanban/board.rb +14 -0
- data/lib/plutonium/kanban/column.rb +4 -2
- data/lib/plutonium/kanban/dsl.rb +4 -1
- data/lib/plutonium/kanban/grouping.rb +9 -22
- data/lib/plutonium/kanban/positioning.rb +5 -65
- data/lib/plutonium/positioning/config.rb +94 -0
- data/lib/plutonium/positioning/model.rb +128 -0
- data/lib/plutonium/positioning.rb +25 -86
- data/lib/plutonium/railtie.rb +1 -0
- data/lib/plutonium/resource/controller.rb +118 -38
- data/lib/plutonium/resource/controllers/crud_actions/index_action.rb +32 -2
- data/lib/plutonium/resource/controllers/crud_actions.rb +30 -2
- data/lib/plutonium/resource/controllers/eager_loading.rb +87 -0
- data/lib/plutonium/resource/controllers/export_csv.rb +10 -1
- data/lib/plutonium/resource/controllers/kanban_actions.rb +53 -14
- data/lib/plutonium/resource/controllers/position_actions.rb +390 -0
- data/lib/plutonium/resource/controllers/presentable.rb +19 -13
- data/lib/plutonium/resource/controllers/queryable.rb +5 -1
- data/lib/plutonium/resource/controllers/wizard_actions.rb +21 -0
- data/lib/plutonium/resource/policy.rb +33 -0
- data/lib/plutonium/resource/query_object.rb +36 -0
- data/lib/plutonium/routing/mapper_extensions.rb +100 -8
- data/lib/plutonium/routing/route_set_extensions.rb +15 -1
- data/lib/plutonium/routing/wizard_registration.rb +4 -0
- data/lib/plutonium/testing/resource_policy.rb +6 -2
- data/lib/plutonium/ui/action_button.rb +12 -7
- data/lib/plutonium/ui/actions_dropdown.rb +1 -1
- data/lib/plutonium/ui/block.rb +21 -1
- data/lib/plutonium/ui/breadcrumbs.rb +187 -55
- data/lib/plutonium/ui/component/methods.rb +5 -0
- data/lib/plutonium/ui/component/positionable.rb +112 -0
- data/lib/plutonium/ui/component/resolves_tags.rb +57 -0
- data/lib/plutonium/ui/component/section.rb +185 -0
- data/lib/plutonium/ui/display/base.rb +13 -1
- data/lib/plutonium/ui/display/components/formatted_value.rb +26 -0
- data/lib/plutonium/ui/display/components/section.rb +18 -0
- data/lib/plutonium/ui/display/resource.rb +141 -22
- data/lib/plutonium/ui/display/theme.rb +20 -1
- data/lib/plutonium/ui/export_button.rb +1 -1
- data/lib/plutonium/ui/form/base.rb +8 -7
- data/lib/plutonium/ui/form/components/intl_tel_input.rb +1 -1
- data/lib/plutonium/ui/form/components/section.rb +7 -62
- data/lib/plutonium/ui/form/components/uppy.rb +12 -1
- data/lib/plutonium/ui/form/concerns/renders_nested_resource_fields.rb +16 -3
- data/lib/plutonium/ui/form/concerns/renders_structured_inputs.rb +5 -1
- data/lib/plutonium/ui/form/query.rb +2 -4
- data/lib/plutonium/ui/form/resource.rb +92 -15
- data/lib/plutonium/ui/form/theme.rb +17 -0
- data/lib/plutonium/ui/form/wizard.rb +25 -1
- data/lib/plutonium/ui/grid/card.rb +79 -11
- data/lib/plutonium/ui/grid/resource.rb +47 -5
- data/lib/plutonium/ui/interaction/async/run_progress.rb +227 -0
- data/lib/plutonium/ui/interaction/async/running_banner.rb +65 -0
- data/lib/plutonium/ui/kanban/card.rb +2 -1
- data/lib/plutonium/ui/kanban/column.rb +12 -6
- data/lib/plutonium/ui/kanban/resource.rb +6 -7
- data/lib/plutonium/ui/layout/base.rb +10 -3
- data/lib/plutonium/ui/nav_grid_menu.rb +1 -0
- data/lib/plutonium/ui/page/base.rb +19 -0
- data/lib/plutonium/ui/page/edit.rb +4 -1
- data/lib/plutonium/ui/page/index.rb +69 -18
- data/lib/plutonium/ui/page/interactive_action.rb +5 -1
- data/lib/plutonium/ui/page/new.rb +4 -1
- data/lib/plutonium/ui/page/show.rb +27 -10
- data/lib/plutonium/ui/page/wizard.rb +10 -1
- data/lib/plutonium/ui/page/wizard_chooser.rb +36 -11
- data/lib/plutonium/ui/page_width.rb +58 -0
- data/lib/plutonium/ui/table/base.rb +34 -1
- data/lib/plutonium/ui/table/components/attachment.rb +1 -1
- data/lib/plutonium/ui/table/components/bulk_actions_toolbar.rb +32 -8
- data/lib/plutonium/ui/table/components/drag_handle.rb +120 -0
- data/lib/plutonium/ui/table/components/filter_form.rb +1 -4
- data/lib/plutonium/ui/table/components/filter_pills.rb +1 -1
- data/lib/plutonium/ui/table/components/row_actions_dropdown.rb +1 -1
- data/lib/plutonium/ui/table/resource.rb +50 -5
- data/lib/plutonium/ui/table/theme.rb +59 -2
- data/lib/plutonium/ui/wizard/review.rb +4 -2
- data/lib/plutonium/ui/wizard/summary_display.rb +42 -14
- data/lib/plutonium/version.rb +1 -1
- data/lib/plutonium/wizard/attachments.rb +32 -197
- data/lib/plutonium/wizard/base.rb +6 -1
- data/lib/plutonium/wizard/configuration.rb +12 -0
- data/lib/plutonium/wizard/controller.rb +14 -0
- data/lib/plutonium/wizard/driving.rb +99 -15
- data/lib/plutonium/wizard/dsl.rb +23 -0
- data/lib/plutonium/wizard/resume.rb +127 -49
- data/lib/plutonium/wizard/runner.rb +46 -2
- data/lib/plutonium/wizard/step_adapter.rb +1 -1
- data/lib/plutonium/wizard/sweep_job.rb +16 -0
- data/lib/plutonium.rb +21 -0
- data/lib/rodauth/features/session_isolation.rb +92 -0
- data/lib/rodauth/plugins.rb +1 -0
- data/package.json +2 -1
- data/plutonium.gemspec +20 -20
- data/src/css/components.css +89 -1
- data/src/css/slim_select.css +20 -0
- data/src/js/controllers/breadcrumbs_controller.js +112 -0
- data/src/js/controllers/bulk_actions_controller.js +10 -2
- data/src/js/controllers/kanban_controller.js +30 -21
- data/src/js/controllers/positioned_controller.js +452 -0
- data/src/js/controllers/register_controllers.js +6 -0
- data/src/js/controllers/resource_drop_down_controller.js +5 -0
- data/src/js/controllers/run_progress_controller.js +73 -0
- data/src/js/drag/sortable.js +186 -0
- data/yarn.lock +108 -63
- metadata +101 -22
- data/docs/.vitepress/theme/components/HomeAudienceSplit.vue +0 -53
- data/docs/.vitepress/theme/components/HomePillars.vue +0 -42
|
@@ -34,6 +34,19 @@
|
|
|
34
34
|
<% end %>
|
|
35
35
|
</div>
|
|
36
36
|
<%= render "password_visibility" %>
|
|
37
|
+
<%# Only rendered when the config enables `remember`. The box's value is
|
|
38
|
+
`remember_remember_param_value`, which is what the config's after_login hook
|
|
39
|
+
compares against. `include_hidden: false` keeps Rails' default "0" off the
|
|
40
|
+
wire when unticked - belt-and-braces next to that value comparison, but it
|
|
41
|
+
also keeps this form correct for a config using a bare presence check. %>
|
|
42
|
+
<% if rodauth.features.include?(:remember) %>
|
|
43
|
+
<div class="flex items-center space-x-2">
|
|
44
|
+
<%= form.check_box rodauth.remember_param,
|
|
45
|
+
{id: "remember", class: "pu-checkbox", include_hidden: false},
|
|
46
|
+
rodauth.remember_remember_param_value %>
|
|
47
|
+
<%= form.label "remember", rodauth.remember_remember_label, class: "text-sm text-[var(--pu-text)]" %>
|
|
48
|
+
</div>
|
|
49
|
+
<% end %>
|
|
37
50
|
<% end %>
|
|
38
51
|
<%= form.submit rodauth.login_button, class: "w-full pu-btn pu-btn-md pu-btn-primary" %>
|
|
39
52
|
<% end %>
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class CreatePlutoniumAsyncRuns < ActiveRecord::Migration[7.2]
|
|
4
|
+
def change
|
|
5
|
+
create_table :plutonium_async_runs do |t|
|
|
6
|
+
# STI discriminator — the author's Run subclass.
|
|
7
|
+
t.string :type, null: false
|
|
8
|
+
t.string :state, null: false, default: "pending" # pending | running | completed | failed
|
|
9
|
+
|
|
10
|
+
# JSON payloads are jsonb, matching plutonium_wizard_sessions: Postgres
|
|
11
|
+
# hosts get equality and GIN indexing (plain json has neither), and SQLite
|
|
12
|
+
# hosts get the type via PLUTONIUM_SQLITE_TYPE_ALIASES, which aliases
|
|
13
|
+
# jsonb -> json. Changing a column type post-release costs every host app a
|
|
14
|
+
# migration, so pick the queryable one now.
|
|
15
|
+
#
|
|
16
|
+
# The dispatching interaction's validated inputs.
|
|
17
|
+
t.jsonb :options, null: false, default: {}
|
|
18
|
+
|
|
19
|
+
# Targets. target_type is a REAL column, not JSON: the index feature
|
|
20
|
+
# queries "runs for this resource", which cannot be indexed out of a
|
|
21
|
+
# JSON array. Ids are re-resolved through the policy scope at perform time.
|
|
22
|
+
t.string :target_type
|
|
23
|
+
t.jsonb :target_ids, null: false, default: []
|
|
24
|
+
|
|
25
|
+
# Who started it, and in which tenant — the two halves of the policy
|
|
26
|
+
# context, which Plutonium authorizes on as (user, entity_scope).
|
|
27
|
+
#
|
|
28
|
+
# The initiator is ALWAYS required. There is no such thing as a run with
|
|
29
|
+
# no user to authorize as, and a nullable initiator would inevitably come
|
|
30
|
+
# to mean "unauthenticated/unscoped" — the fail-OPEN case.
|
|
31
|
+
#
|
|
32
|
+
# The scoped entity is nullable BY DESIGN: it is present only when the
|
|
33
|
+
# run was launched in an entity-scoped portal, and nil for an un-scoped
|
|
34
|
+
# one (same as plutonium_wizard_sessions.scope_type/scope_id). nil here
|
|
35
|
+
# means "no tenant", NOT "tenant unknown". Code rebuilding the policy
|
|
36
|
+
# context must handle nil explicitly rather than assume a tenant is
|
|
37
|
+
# present — this schema offers no non-null guarantee to lean on.
|
|
38
|
+
#
|
|
39
|
+
# *_id is string-typed to accommodate bigint or uuid host primary keys,
|
|
40
|
+
# matching plutonium_wizard_sessions.
|
|
41
|
+
t.string :initiator_type, null: false
|
|
42
|
+
t.string :initiator_id, null: false
|
|
43
|
+
t.string :scoped_entity_type
|
|
44
|
+
t.string :scoped_entity_id
|
|
45
|
+
|
|
46
|
+
# The third half of the policy context: WHICH policy applied.
|
|
47
|
+
#
|
|
48
|
+
# Policy lookup in a controller passes the controller's own module nesting
|
|
49
|
+
# as the namespace (ActionPolicy::Behaviours::Namespaced), which is how a
|
|
50
|
+
# portal gets its own policy — StorefrontPortal::Blogging::PostPolicy
|
|
51
|
+
# rather than Blogging::PostPolicy. A job has no controller and therefore
|
|
52
|
+
# no namespace to derive, so without this the lookup falls back to the base
|
|
53
|
+
# policy and every narrowing the portal applied is silently lost. Nullable
|
|
54
|
+
# because a top-level dispatch legitimately has no namespace; nil here
|
|
55
|
+
# means "top level", NOT "namespace unknown".
|
|
56
|
+
#
|
|
57
|
+
# Stored as the module's NAME (e.g. "StorefrontPortal"), constantized at
|
|
58
|
+
# perform time — ActionPolicy.lookup requires a Module and raises on a
|
|
59
|
+
# String.
|
|
60
|
+
t.string :authorization_namespace
|
|
61
|
+
|
|
62
|
+
# The nested-route PARENT, and the association the child hangs off it —
|
|
63
|
+
# /orgs/1/posts/5/comments dispatches with (Post#5, :comments).
|
|
64
|
+
#
|
|
65
|
+
# Both halves or neither: Policy#default_relation_scope raises on one
|
|
66
|
+
# without the other, and it is the pair that names a scope.
|
|
67
|
+
#
|
|
68
|
+
# The third input the policy authorizes on, alongside the initiator and the
|
|
69
|
+
# tenant. Without it, Policy#default_relation_scope cannot take its parent
|
|
70
|
+
# branch and falls through to entity scoping — so perform time re-derives
|
|
71
|
+
# targets under the TENANT where dispatch used the parent, a wider answer
|
|
72
|
+
# than the one the initiator was shown. It also leaves any host predicate
|
|
73
|
+
# reading +parent+ looking at nil, which refuses every target with a reason
|
|
74
|
+
# that names the wrong thing.
|
|
75
|
+
#
|
|
76
|
+
# Nullable, like scoped_entity and for the same reason: most routes are not
|
|
77
|
+
# nested. nil means "not a nested dispatch", NOT "parent unknown" — see
|
|
78
|
+
# AsyncRuns::Context#verify_subjects!, which tells those apart by the _type
|
|
79
|
+
# column exactly as it does for the tenant.
|
|
80
|
+
t.string :parent_type
|
|
81
|
+
t.string :parent_id
|
|
82
|
+
t.string :parent_association
|
|
83
|
+
|
|
84
|
+
# The policy actually resolved AT DISPATCH, e.g.
|
|
85
|
+
# "StorefrontPortal::Blogging::PostPolicy". An ASSERTION, not an input: at
|
|
86
|
+
# perform time the policy is resolved from the namespace above and then
|
|
87
|
+
# checked against this. The namespace fixes today's lookup, but a policy
|
|
88
|
+
# renamed, deleted or re-parented between enqueue and perform would resolve
|
|
89
|
+
# to something else — the same silent widening, just moved later in time.
|
|
90
|
+
# Recording what we expected turns that into a loud failure.
|
|
91
|
+
#
|
|
92
|
+
# NOT named `policy_class`: ActionPolicy's lookup chain probes
|
|
93
|
+
# `record.policy_class` before inferring from the class name, so a column
|
|
94
|
+
# of that name would make ActionPolicy.lookup(run) return this String
|
|
95
|
+
# instead of a policy — breaking authorization of run records themselves.
|
|
96
|
+
t.string :policy_class_name
|
|
97
|
+
|
|
98
|
+
# The policy PREDICATE dispatch checked, e.g. "archive?".
|
|
99
|
+
#
|
|
100
|
+
# The class above says which policy; this says which question to ask it.
|
|
101
|
+
# Without it a run re-checks only VISIBILITY (the relation scope) and not
|
|
102
|
+
# PERMISSION: an initiator whose archive? flipped to false after enqueue
|
|
103
|
+
# would still resolve every target and the work would proceed. Dispatch
|
|
104
|
+
# derives this from the action name and checks it PER RECORD — see
|
|
105
|
+
# Plutonium::Resource::Controllers::InteractiveActions#authorize_interactive_bulk_action!
|
|
106
|
+
# — and perform must reproduce exactly that.
|
|
107
|
+
#
|
|
108
|
+
# Nullable ONLY because opaque (untargeted) work has no per-record
|
|
109
|
+
# predicate to check. nil means "no per-target predicate", and for a
|
|
110
|
+
# TARGETED run that is refused rather than read as "allow everything" —
|
|
111
|
+
# the fail-open shape again.
|
|
112
|
+
t.string :policy_action
|
|
113
|
+
|
|
114
|
+
# Counts. Both nil for opaque (untargeted) work — the progress UI reads
|
|
115
|
+
# nil as "indeterminate" rather than 0%.
|
|
116
|
+
t.integer :progress_total
|
|
117
|
+
t.integer :progress_done, null: false, default: 0
|
|
118
|
+
|
|
119
|
+
# Per-target execution failures, appended as the run proceeds — hence an
|
|
120
|
+
# array default. A run may fail on some targets and still complete.
|
|
121
|
+
t.jsonb :errors_log, null: false, default: []
|
|
122
|
+
|
|
123
|
+
t.datetime :started_at
|
|
124
|
+
t.datetime :finished_at
|
|
125
|
+
|
|
126
|
+
# Set only once a job actually picks the run up (AsyncRuns::Executor#claim!)
|
|
127
|
+
# and bumped on every write that represents real progress thereafter —
|
|
128
|
+
# NOT at create time, and not the same thing as updated_at, which bumps
|
|
129
|
+
# on any write to the row. nil means "never picked up"; AsyncRuns::ReapJob
|
|
130
|
+
# falls back to created_at for that case.
|
|
131
|
+
t.datetime :last_activity_at
|
|
132
|
+
|
|
133
|
+
# Target ids already dispositioned (succeeded or failed), so a run
|
|
134
|
+
# resumed after an interruption can skip work it already did instead of
|
|
135
|
+
# either replaying it or refusing to resume at all. See AsyncRuns::Executor.
|
|
136
|
+
t.jsonb :handled_target_ids, null: false, default: []
|
|
137
|
+
|
|
138
|
+
# Rails' standard optimistic-locking column (same convention as
|
|
139
|
+
# plutonium_wizard_sessions.lock_version) — bumped by AsyncRuns::Executor#claim!
|
|
140
|
+
# on every successful claim, on top of the ordinary automatic bump every
|
|
141
|
+
# +save!+/+update!+ already gets. ReapJob's resume is a TIME heuristic, not
|
|
142
|
+
# a true lease (see AsyncRuns::ReapJob): it can hand a run to a second executor
|
|
143
|
+
# while the first is still mid-batch. Without this column that race
|
|
144
|
+
# silently double-applies AND can lose one side's progress write; with it,
|
|
145
|
+
# the superseded executor's next write raises ActiveRecord::StaleObjectError
|
|
146
|
+
# (see AsyncRuns::Executor#call), which is treated as "no longer mine" rather
|
|
147
|
+
# than a target or run failure.
|
|
148
|
+
t.integer :lock_version, null: false, default: 0
|
|
149
|
+
|
|
150
|
+
t.timestamps
|
|
151
|
+
|
|
152
|
+
t.index [:target_type, :state], name: "idx_pu_runs_on_target_and_state"
|
|
153
|
+
t.index [:initiator_type, :initiator_id], name: "idx_pu_runs_on_initiator"
|
|
154
|
+
t.index [:scoped_entity_type, :scoped_entity_id], name: "idx_pu_runs_on_scoped_entity"
|
|
155
|
+
# AsyncRuns::ReapJob's stalled scan filters on state with no target_type
|
|
156
|
+
# predicate, so it cannot use idx_pu_runs_on_target_and_state above (wrong
|
|
157
|
+
# leading column) — without this, the scan degrades to a full-table scan as
|
|
158
|
+
# completed/failed history accumulates. Low-cardinality but selective for
|
|
159
|
+
# this query: the in-progress rows are a vanishing fraction of a mature
|
|
160
|
+
# table, which is exactly the case that needs the index.
|
|
161
|
+
#
|
|
162
|
+
# state ALONE, deliberately. The scan's other predicate is
|
|
163
|
+
# COALESCE(last_activity_at, created_at) < ? (see Run.stalled) — an
|
|
164
|
+
# expression, so a trailing last_activity_at column could not be used for
|
|
165
|
+
# the range at all, and created_at is not in the index either, so it buys
|
|
166
|
+
# no index-only scan. It would be write cost for nothing.
|
|
167
|
+
t.index :state, name: "idx_pu_runs_on_state"
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
data/docs/.vitepress/config.ts
CHANGED
|
@@ -1,8 +1,28 @@
|
|
|
1
|
-
import { defineConfig } from "vitepress"
|
|
1
|
+
import { defineConfig, createContentLoader, type SiteConfig, type HeadConfig } from "vitepress"
|
|
2
2
|
import { withMermaid } from "vitepress-plugin-mermaid";
|
|
3
3
|
import llmstxt from "vitepress-plugin-llms";
|
|
4
|
+
import { Feed } from "feed";
|
|
5
|
+
import fs from "node:fs";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
4
8
|
|
|
5
9
|
const base = "/plutonium-core/"
|
|
10
|
+
// Origin + base, with no trailing slash — content-loader URLs already start with one.
|
|
11
|
+
const hostname = "https://radioactive-labs.github.io/plutonium-core"
|
|
12
|
+
|
|
13
|
+
// Whether the blog has anything a reader could actually open: a post that is not
|
|
14
|
+
// a draft and whose date has arrived. Same rule the loader and the feed apply, so
|
|
15
|
+
// the nav entry and the RSS advert never point at an empty page.
|
|
16
|
+
const hasPublishedPost = fs
|
|
17
|
+
.readdirSync(fileURLToPath(new URL("../blog", import.meta.url)))
|
|
18
|
+
.filter((f) => f.endsWith(".md") && f !== "index.md")
|
|
19
|
+
.some((f) => {
|
|
20
|
+
const raw = fs.readFileSync(fileURLToPath(new URL(`../blog/${f}`, import.meta.url)), "utf8")
|
|
21
|
+
const frontmatter = raw.split("---")[1] ?? ""
|
|
22
|
+
if (/^draft:\s*true\s*$/m.test(frontmatter)) return false
|
|
23
|
+
const date = frontmatter.match(/^date:\s*(.+)$/m)?.[1]?.trim()
|
|
24
|
+
return !!date && +new Date(date) <= Date.now()
|
|
25
|
+
})
|
|
6
26
|
|
|
7
27
|
// https://vitepress.dev/reference/site-config
|
|
8
28
|
export default defineConfig(withMermaid({
|
|
@@ -20,6 +40,9 @@ export default defineConfig(withMermaid({
|
|
|
20
40
|
["meta", { name: "twitter:title", content: "Plutonium - Build Production-Ready Rails Apps in Minutes" }],
|
|
21
41
|
["meta", { name: "twitter:description", content: "Build production-ready Rails applications in minutes, not days. Convention-driven, fully customizable. Built for the AI era." }],
|
|
22
42
|
["meta", { name: "twitter:image", content: "https://radioactive-labs.github.io/plutonium-core/og-image.png" }],
|
|
43
|
+
...(hasPublishedPost
|
|
44
|
+
? ([["link", { rel: "alternate", type: "application/rss+xml", title: "Plutonium Blog", href: `${hostname}/blog/feed.rss` }]] as HeadConfig[])
|
|
45
|
+
: []),
|
|
23
46
|
],
|
|
24
47
|
ignoreDeadLinks: 'localhostLinks',
|
|
25
48
|
srcExclude: ['superpowers/**'],
|
|
@@ -37,6 +60,7 @@ export default defineConfig(withMermaid({
|
|
|
37
60
|
"getting-started/index.md",
|
|
38
61
|
"guides/index.md",
|
|
39
62
|
"reference/index.md",
|
|
63
|
+
"blog/index.md",
|
|
40
64
|
],
|
|
41
65
|
}),
|
|
42
66
|
],
|
|
@@ -52,7 +76,9 @@ export default defineConfig(withMermaid({
|
|
|
52
76
|
{ text: "Getting Started", link: "/getting-started/" },
|
|
53
77
|
{ text: "Guides", link: "/guides/" },
|
|
54
78
|
{ text: "Reference", link: "/reference/" },
|
|
55
|
-
{ text: "
|
|
79
|
+
...(hasPublishedPost ? [{ text: "Blog", link: "/blog/" }] : []),
|
|
80
|
+
{ text: "For AI Agents", link: "/ai" },
|
|
81
|
+
{ text: "Radioactive Labs", link: "https://radioactive-labs.github.io/" }
|
|
56
82
|
],
|
|
57
83
|
sidebar: {
|
|
58
84
|
'/getting-started/': [
|
|
@@ -122,6 +148,8 @@ export default defineConfig(withMermaid({
|
|
|
122
148
|
text: "Quality",
|
|
123
149
|
items: [
|
|
124
150
|
{ text: "Testing", link: "/guides/testing" },
|
|
151
|
+
{ text: "Performance", link: "/guides/performance" },
|
|
152
|
+
{ text: "Troubleshooting", link: "/guides/troubleshooting" },
|
|
125
153
|
]
|
|
126
154
|
}
|
|
127
155
|
],
|
|
@@ -153,6 +181,7 @@ export default defineConfig(withMermaid({
|
|
|
153
181
|
{ text: "Definition", link: "/reference/resource/definition" },
|
|
154
182
|
{ text: "Query", link: "/reference/resource/query" },
|
|
155
183
|
{ text: "Actions", link: "/reference/resource/actions" },
|
|
184
|
+
{ text: "Positioning & drag-to-reorder", link: "/reference/positioning" },
|
|
156
185
|
{ text: "CSV Export", link: "/reference/resource/export" },
|
|
157
186
|
]
|
|
158
187
|
},
|
|
@@ -164,6 +193,7 @@ export default defineConfig(withMermaid({
|
|
|
164
193
|
{ text: "Controllers", link: "/reference/behavior/controllers" },
|
|
165
194
|
{ text: "Policies", link: "/reference/behavior/policies" },
|
|
166
195
|
{ text: "Interactions", link: "/reference/behavior/interactions" },
|
|
196
|
+
{ text: "Async Interactions", link: "/reference/behavior/async-interactions" },
|
|
167
197
|
]
|
|
168
198
|
},
|
|
169
199
|
{
|
|
@@ -235,8 +265,56 @@ export default defineConfig(withMermaid({
|
|
|
235
265
|
],
|
|
236
266
|
footer: {
|
|
237
267
|
message: 'Released under the MIT License.',
|
|
238
|
-
copyright: 'Copyright © 2024-present Stefan Froelich'
|
|
268
|
+
copyright: 'Copyright © 2024-present Stefan Froelich · <a href="https://radioactive-labs.github.io/" target="_blank" rel="noopener">Radioactive Labs</a>'
|
|
239
269
|
}
|
|
240
270
|
},
|
|
241
271
|
cleanUrls: true,
|
|
272
|
+
async buildEnd(config: SiteConfig) {
|
|
273
|
+
const feed = new Feed({
|
|
274
|
+
title: "Plutonium",
|
|
275
|
+
description: "Release notes, design notes, and what's new in Plutonium.",
|
|
276
|
+
id: `${hostname}/blog/`,
|
|
277
|
+
link: `${hostname}/blog/`,
|
|
278
|
+
language: "en",
|
|
279
|
+
image: `${hostname}/og-image.png`,
|
|
280
|
+
favicon: `${hostname}/favicon.ico`,
|
|
281
|
+
copyright: "Copyright © 2024-present Stefan Froelich · Radioactive Labs",
|
|
282
|
+
})
|
|
283
|
+
|
|
284
|
+
const posts = await createContentLoader("blog/*.md", { render: true }).load()
|
|
285
|
+
|
|
286
|
+
posts
|
|
287
|
+
// Build-time cutoff: a feed cannot re-filter itself later, so a post dated
|
|
288
|
+
// ahead stays out until the next build after its date.
|
|
289
|
+
.filter(({ frontmatter }) => frontmatter.date && !frontmatter.draft)
|
|
290
|
+
.filter(({ frontmatter }) => +new Date(frontmatter.date) <= Date.now())
|
|
291
|
+
.sort((a, b) => +new Date(b.frontmatter.date) - +new Date(a.frontmatter.date))
|
|
292
|
+
.forEach(({ url, html, frontmatter }) => {
|
|
293
|
+
const link = `${hostname}${url}`
|
|
294
|
+
feed.addItem({
|
|
295
|
+
title: frontmatter.title,
|
|
296
|
+
id: link,
|
|
297
|
+
link,
|
|
298
|
+
description: frontmatter.description,
|
|
299
|
+
content: absolutize(html ?? "", link),
|
|
300
|
+
author: frontmatter.author ? [{ name: frontmatter.author }] : undefined,
|
|
301
|
+
category: frontmatter.tags?.map((name: string) => ({ name })),
|
|
302
|
+
date: new Date(frontmatter.date),
|
|
303
|
+
})
|
|
304
|
+
})
|
|
305
|
+
|
|
306
|
+
fs.mkdirSync(path.join(config.outDir, "blog"), { recursive: true })
|
|
307
|
+
fs.writeFileSync(path.join(config.outDir, "blog", "feed.rss"), feed.rss2())
|
|
308
|
+
},
|
|
242
309
|
}))
|
|
310
|
+
|
|
311
|
+
// A feed reader has no page to resolve links against, so everything the renderer
|
|
312
|
+
// left relative has to be made absolute before it ships.
|
|
313
|
+
function absolutize(html: string, link: string): string {
|
|
314
|
+
return html
|
|
315
|
+
// The renderer emits .html even though the site serves clean URLs.
|
|
316
|
+
.replace(new RegExp(`(="${base}[^"]*?)\\.html(?=["#])`, "g"), "$1")
|
|
317
|
+
.replaceAll(`="${base}`, `="${hostname}/`)
|
|
318
|
+
// Bare fragments would point at whatever page the reader happens to be showing.
|
|
319
|
+
.replaceAll('href="#', `href="${link}#`)
|
|
320
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { createContentLoader } from "vitepress"
|
|
2
|
+
|
|
3
|
+
export interface Post {
|
|
4
|
+
title: string
|
|
5
|
+
url: string
|
|
6
|
+
description: string
|
|
7
|
+
author?: string
|
|
8
|
+
tags: string[]
|
|
9
|
+
date: { time: number; string: string }
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
declare const data: Post[]
|
|
13
|
+
export { data }
|
|
14
|
+
|
|
15
|
+
export default createContentLoader("blog/*.md", {
|
|
16
|
+
transform(raw): Post[] {
|
|
17
|
+
return raw
|
|
18
|
+
// The section index has no date: that's what separates it from a post.
|
|
19
|
+
.filter(({ frontmatter }) => frontmatter.date && !frontmatter.draft)
|
|
20
|
+
// Dated ahead means unpublished. The cutoff is applied at BUILD time, so a
|
|
21
|
+
// post goes live on the first build after its date, not on the date itself.
|
|
22
|
+
.filter(({ frontmatter }) => +new Date(frontmatter.date) <= Date.now())
|
|
23
|
+
.map(({ url, frontmatter }) => ({
|
|
24
|
+
title: frontmatter.title,
|
|
25
|
+
url,
|
|
26
|
+
description: frontmatter.description ?? "",
|
|
27
|
+
author: frontmatter.author,
|
|
28
|
+
tags: frontmatter.tags ?? [],
|
|
29
|
+
date: formatDate(frontmatter.date),
|
|
30
|
+
}))
|
|
31
|
+
.sort((a, b) => b.date.time - a.date.time)
|
|
32
|
+
},
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
function formatDate(raw: string | Date): Post["date"] {
|
|
36
|
+
const date = new Date(raw)
|
|
37
|
+
// Frontmatter dates parse as UTC midnight; nudge to midday so the rendered
|
|
38
|
+
// day doesn't slip backwards for readers behind UTC.
|
|
39
|
+
date.setUTCHours(12)
|
|
40
|
+
return {
|
|
41
|
+
time: +date,
|
|
42
|
+
string: date.toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric" }),
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<section class="pu-section bi-section">
|
|
3
|
+
<div class="pu-section-inner">
|
|
4
|
+
<div class="pu-eyebrow">{{ eyebrow }}</div>
|
|
5
|
+
<h1 class="bi-h1">{{ title }}</h1>
|
|
6
|
+
<p class="bi-lede">{{ lede }}</p>
|
|
7
|
+
|
|
8
|
+
<a class="bi-rss" :href="withBase('/blog/feed.rss')">
|
|
9
|
+
<IconRss :size="15" :stroke-width="2" /> Subscribe via RSS
|
|
10
|
+
</a>
|
|
11
|
+
|
|
12
|
+
<div class="bi-list">
|
|
13
|
+
<a v-for="post in posts" :key="post.url" :href="withBase(post.url)" class="bi-post">
|
|
14
|
+
<time class="bi-date" :datetime="new Date(post.date.time).toISOString()">
|
|
15
|
+
{{ post.date.string }}
|
|
16
|
+
</time>
|
|
17
|
+
<span class="bi-body">
|
|
18
|
+
<span class="bi-title">{{ post.title }}</span>
|
|
19
|
+
<span v-if="post.description" class="bi-desc">{{ post.description }}</span>
|
|
20
|
+
<span v-if="post.tags.length" class="bi-tags">
|
|
21
|
+
<span v-for="tag in post.tags" :key="tag" class="bi-tag">{{ tag }}</span>
|
|
22
|
+
</span>
|
|
23
|
+
</span>
|
|
24
|
+
<IconArrowRight class="bi-arrow" :size="16" :stroke-width="2" />
|
|
25
|
+
</a>
|
|
26
|
+
</div>
|
|
27
|
+
|
|
28
|
+
<p v-if="!posts.length" class="bi-empty">No posts yet.</p>
|
|
29
|
+
</div>
|
|
30
|
+
</section>
|
|
31
|
+
</template>
|
|
32
|
+
|
|
33
|
+
<script setup>
|
|
34
|
+
import { withBase } from "vitepress"
|
|
35
|
+
import { IconArrowRight, IconRss } from "@tabler/icons-vue"
|
|
36
|
+
import { data as posts } from "../blog.data"
|
|
37
|
+
|
|
38
|
+
defineProps({
|
|
39
|
+
eyebrow: { type: String, default: "Blog" },
|
|
40
|
+
title: { type: String, required: true },
|
|
41
|
+
lede: { type: String, required: true },
|
|
42
|
+
})
|
|
43
|
+
</script>
|
|
44
|
+
|
|
45
|
+
<style scoped>
|
|
46
|
+
.bi-section { padding: 64px 24px 96px; }
|
|
47
|
+
.bi-h1 { font-size: 36px; letter-spacing: -0.025em; margin: 0 0 12px; color: var(--pu-text); }
|
|
48
|
+
.bi-lede { font-size: 16px; color: var(--pu-text-muted); max-width: 640px; margin: 0 0 20px; line-height: 1.55; }
|
|
49
|
+
|
|
50
|
+
.bi-rss {
|
|
51
|
+
display: inline-flex; align-items: center; gap: 6px; margin-bottom: 40px;
|
|
52
|
+
font-size: 13px; font-weight: 500; color: var(--pu-text-muted); text-decoration: none;
|
|
53
|
+
}
|
|
54
|
+
.bi-rss:hover { color: var(--pu-accent); }
|
|
55
|
+
|
|
56
|
+
.bi-list { border-left: 2px solid var(--pu-accent); padding-left: 24px; max-width: 760px; }
|
|
57
|
+
.bi-post {
|
|
58
|
+
display: flex; gap: 20px; align-items: flex-start;
|
|
59
|
+
padding: 18px 0; border-bottom: 1px solid var(--pu-border-soft);
|
|
60
|
+
color: var(--pu-text); text-decoration: none;
|
|
61
|
+
}
|
|
62
|
+
.bi-post:last-child { border-bottom: none; }
|
|
63
|
+
.bi-post:hover .bi-title { color: var(--pu-accent); }
|
|
64
|
+
.bi-post:hover .bi-arrow { transform: translateX(2px); color: var(--pu-accent); }
|
|
65
|
+
|
|
66
|
+
.bi-date {
|
|
67
|
+
flex-shrink: 0; width: 124px; padding-top: 2px;
|
|
68
|
+
font-size: 12.5px; color: var(--pu-text-faint); font-variant-numeric: tabular-nums;
|
|
69
|
+
}
|
|
70
|
+
.bi-body { display: flex; flex-direction: column; gap: 4px; flex: 1; }
|
|
71
|
+
.bi-title { font-weight: 600; font-size: 16px; letter-spacing: -0.01em; }
|
|
72
|
+
.bi-desc { font-size: 13.5px; color: var(--pu-text-muted); line-height: 1.5; }
|
|
73
|
+
.bi-tags { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 4px; }
|
|
74
|
+
.bi-tag {
|
|
75
|
+
font-size: 11px; text-transform: uppercase; letter-spacing: 0.06em; font-weight: 600;
|
|
76
|
+
color: var(--pu-text-faint); border: 1px solid var(--pu-border-soft);
|
|
77
|
+
border-radius: 3px; padding: 1px 6px;
|
|
78
|
+
}
|
|
79
|
+
.bi-arrow { color: var(--pu-text-faint); transition: transform 0.15s ease, color 0.15s ease; flex-shrink: 0; margin-top: 4px; }
|
|
80
|
+
.bi-empty { color: var(--pu-text-muted); font-size: 14px; }
|
|
81
|
+
|
|
82
|
+
@media (max-width: 640px) {
|
|
83
|
+
.bi-post { flex-direction: column; gap: 6px; }
|
|
84
|
+
.bi-date { width: auto; padding-top: 0; }
|
|
85
|
+
.bi-arrow { display: none; }
|
|
86
|
+
}
|
|
87
|
+
</style>
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="bm">
|
|
3
|
+
<div class="bm-line">
|
|
4
|
+
<time v-if="dateString" :datetime="isoDate">{{ dateString }}</time>
|
|
5
|
+
<span v-if="frontmatter.author" class="bm-sep">·</span>
|
|
6
|
+
<span v-if="frontmatter.author">{{ frontmatter.author }}</span>
|
|
7
|
+
</div>
|
|
8
|
+
<a class="bm-back" :href="withBase('/blog/')">
|
|
9
|
+
<IconArrowLeft :size="14" :stroke-width="2" /> All posts
|
|
10
|
+
</a>
|
|
11
|
+
</div>
|
|
12
|
+
</template>
|
|
13
|
+
|
|
14
|
+
<script setup>
|
|
15
|
+
import { computed } from "vue"
|
|
16
|
+
import { useData, withBase } from "vitepress"
|
|
17
|
+
import { IconArrowLeft } from "@tabler/icons-vue"
|
|
18
|
+
|
|
19
|
+
const { frontmatter } = useData()
|
|
20
|
+
|
|
21
|
+
// Frontmatter dates arrive as a Date (YAML-parsed) or a string, depending on quoting.
|
|
22
|
+
const parsed = computed(() => (frontmatter.value.date ? new Date(frontmatter.value.date) : null))
|
|
23
|
+
const isoDate = computed(() => parsed.value?.toISOString())
|
|
24
|
+
const dateString = computed(() =>
|
|
25
|
+
parsed.value?.toLocaleDateString("en-US", {
|
|
26
|
+
year: "numeric", month: "long", day: "numeric", timeZone: "UTC",
|
|
27
|
+
})
|
|
28
|
+
)
|
|
29
|
+
</script>
|
|
30
|
+
|
|
31
|
+
<style scoped>
|
|
32
|
+
.bm {
|
|
33
|
+
margin: 0 0 40px;
|
|
34
|
+
padding-bottom: 20px;
|
|
35
|
+
border-bottom: 1px solid var(--pu-border-soft);
|
|
36
|
+
}
|
|
37
|
+
.bm-line {
|
|
38
|
+
font-size: 14px; color: var(--pu-text-muted);
|
|
39
|
+
display: flex; align-items: center; gap: 7px;
|
|
40
|
+
}
|
|
41
|
+
.bm-sep { color: var(--pu-text-faint); }
|
|
42
|
+
.bm-back {
|
|
43
|
+
display: inline-flex; align-items: center; gap: 5px; margin-top: 14px;
|
|
44
|
+
font-size: 13px; font-weight: 500; color: var(--pu-accent); text-decoration: none;
|
|
45
|
+
}
|
|
46
|
+
.bm-back:hover { text-decoration: underline; }
|
|
47
|
+
</style>
|