postnhost 0.1.0 → 0.2.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/CHANGELOG.md +36 -0
- data/README.md +22 -9
- data/Rakefile +28 -0
- data/app/assets/builds/postnhost/application.css +1 -1
- data/app/assets/stylesheets/application.tailwind.css +5 -1
- data/app/controllers/postnhost/api/base_controller.rb +29 -0
- data/app/controllers/postnhost/api/v1/commands_controller.rb +27 -0
- data/app/controllers/postnhost/api/v1/mcp_controller.rb +49 -0
- data/app/controllers/postnhost/api_tokens_controller.rb +34 -0
- data/app/controllers/postnhost/articles/translations_controller.rb +2 -9
- data/app/controllers/postnhost/categories/translations_controller.rb +2 -9
- data/app/controllers/postnhost/management_preview_controller.rb +17 -0
- data/app/controllers/postnhost/pages/translations_controller.rb +2 -9
- data/app/helpers/postnhost/application_helper.rb +0 -11
- data/app/models/postnhost/api_token.rb +26 -0
- data/app/models/postnhost/user.rb +2 -0
- data/app/services/postnhost/api/v1/openapi.rb +27 -0
- data/app/services/postnhost/management/catalog.rb +151 -0
- data/app/services/postnhost/management/command.rb +174 -0
- data/app/services/postnhost/management/content_actions.rb +75 -0
- data/app/services/postnhost/management/input.rb +49 -0
- data/app/services/postnhost/management/mcp.rb +54 -0
- data/app/services/postnhost/management/preview.rb +25 -0
- data/app/services/postnhost/management/resources.rb +30 -0
- data/app/services/postnhost/management/schema_fields.rb +25 -0
- data/app/services/postnhost/management/serializer.rb +40 -0
- data/app/services/postnhost/management/settings_actions.rb +64 -0
- data/app/services/postnhost/management/upload.rb +18 -0
- data/app/services/postnhost/translation/enqueue.rb +59 -0
- data/app/views/layouts/postnhost/_sidebar.html.erb +7 -0
- data/app/views/layouts/postnhost/application.html.erb +2 -2
- data/app/views/layouts/postnhost/articles.html.erb +3 -3
- data/app/views/layouts/postnhost/product.html.erb +3 -3
- data/app/views/layouts/postnhost/public/templates/default.html.erb +2 -2
- data/app/views/layouts/postnhost/public/templates/swiss-editorial.html.erb +2 -2
- data/app/views/layouts/postnhost/public/templates/workspace-journal.html.erb +2 -2
- data/app/views/postnhost/api_tokens/index.html.erb +92 -0
- data/app/views/postnhost/onboarding/_post_install_checklist.html.erb +8 -8
- data/app/views/postnhost/pages/index.html.erb +1 -1
- data/app/views/postnhost/templates/edit.html.erb +1 -1
- data/config/routes.rb +12 -0
- data/db/migrate/20261008120000_create_api_tokens.rb +12 -0
- data/lib/generators/postnhost/install/install_generator.rb +11 -12
- data/lib/generators/postnhost/locale/locale_generator.rb +1 -1
- data/lib/generators/postnhost/static_pages/static_pages_generator.rb +1 -1
- data/lib/generators/postnhost/tailwindcss/install_generator.rb +28 -25
- data/lib/generators/postnhost/tailwindcss/templates/host.tailwind.css +0 -3
- data/lib/generators/postnhost/user/user_generator.rb +1 -1
- data/lib/generators/postnhost/views/views_generator.rb +5 -7
- data/lib/postnhost/css_build.rb +68 -0
- data/lib/postnhost/css_scope.rb +77 -0
- data/lib/postnhost/sample_data.rb +1 -1
- data/lib/postnhost/tailwind_compiler.rb +75 -0
- data/lib/postnhost/tailwind_input.rb +32 -0
- data/lib/postnhost/version.rb +1 -1
- data/lib/tasks/postnhost_tasks.rake +40 -65
- metadata +40 -2
- data/lib/generators/postnhost/tailwindcss/templates/tailwind.config.js +0 -8
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
module Postnhost
|
|
2
|
+
module Management
|
|
3
|
+
module Input
|
|
4
|
+
TYPES = {
|
|
5
|
+
"object" => [Hash],
|
|
6
|
+
"array" => [Array],
|
|
7
|
+
"integer" => [Integer],
|
|
8
|
+
"string" => [String],
|
|
9
|
+
"boolean" => [TrueClass, FalseClass],
|
|
10
|
+
"null" => [NilClass]
|
|
11
|
+
}.freeze
|
|
12
|
+
|
|
13
|
+
def self.validate!(value, schema, path = "arguments")
|
|
14
|
+
valid = Array(schema[:type]).any? do |type|
|
|
15
|
+
TYPES.fetch(type).any? { |klass| value.is_a?(klass) }
|
|
16
|
+
end
|
|
17
|
+
raise ArgumentError, "#{path} has an invalid type" unless valid
|
|
18
|
+
|
|
19
|
+
Time.iso8601(value) if schema[:format] == "date-time" && value
|
|
20
|
+
|
|
21
|
+
case value
|
|
22
|
+
when Hash
|
|
23
|
+
validate_object!(value, schema, path)
|
|
24
|
+
when Array
|
|
25
|
+
raise ArgumentError, "#{path} has an invalid length" if value.size < schema.fetch(:minItems, 0) || value.size > schema.fetch(:maxItems, 1000)
|
|
26
|
+
raise ArgumentError, "#{path} must contain unique values" if schema[:uniqueItems] && value.uniq != value
|
|
27
|
+
|
|
28
|
+
value.each { |item| validate!(item, schema[:items], path) } if schema[:items]
|
|
29
|
+
when Integer
|
|
30
|
+
raise ArgumentError, "#{path} is out of range" if value < schema.fetch(:minimum, value) || value > schema.fetch(:maximum, value)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def self.validate_object!(value, schema, path)
|
|
35
|
+
properties = schema.fetch(:properties, {}).stringify_keys
|
|
36
|
+
missing = schema.fetch(:required, []) - value.keys
|
|
37
|
+
raise ArgumentError, "#{path} requires #{missing.join(', ')}" if missing.any?
|
|
38
|
+
|
|
39
|
+
unknown = value.keys - properties.keys
|
|
40
|
+
raise ArgumentError, "#{path} contains unknown fields: #{unknown.join(', ')}" if schema[:additionalProperties] == false && unknown.any?
|
|
41
|
+
|
|
42
|
+
value.each do |key, item|
|
|
43
|
+
child = properties[key] || schema[:additionalProperties]
|
|
44
|
+
validate!(item, child, "#{path}.#{key}") if child.is_a?(Hash)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
module Postnhost
|
|
2
|
+
module Management
|
|
3
|
+
class Mcp
|
|
4
|
+
VERSIONS = %w[2025-11-25 2025-06-18 2025-03-26].freeze
|
|
5
|
+
|
|
6
|
+
def initialize(user:, script_name: "")
|
|
7
|
+
@user = user
|
|
8
|
+
@script_name = script_name
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def call(message)
|
|
12
|
+
return error(nil, -32_600, "Invalid Request") unless message.is_a?(Hash)
|
|
13
|
+
|
|
14
|
+
id = message["id"]
|
|
15
|
+
return error(nil, -32_600, "Invalid Request") unless message["jsonrpc"] == "2.0" && message["method"].is_a?(String) && (!message.key?("id") || id.is_a?(String) || id.is_a?(Integer))
|
|
16
|
+
return nil unless message.key?("id")
|
|
17
|
+
|
|
18
|
+
params = message.fetch("params", {})
|
|
19
|
+
return error(id, -32_602, "params must be an object") unless params.is_a?(Hash)
|
|
20
|
+
|
|
21
|
+
result = case message["method"]
|
|
22
|
+
when "initialize"
|
|
23
|
+
{ protocolVersion: VERSIONS.include?(params["protocolVersion"]) ? params["protocolVersion"] : VERSIONS.first,
|
|
24
|
+
capabilities: { tools: { listChanged: false } }, serverInfo: { name: "PostnHost", version: Postnhost::VERSION },
|
|
25
|
+
instructions: "Manage CMS drafts, translations and settings. Publishing is explicit. Generate translations from published sources. Files use base64 and follow the same upload limits as the CMS." }
|
|
26
|
+
when "ping" then {}
|
|
27
|
+
when "tools/list" then { tools: Catalog.tools }
|
|
28
|
+
when "tools/call" then call_tool(params)
|
|
29
|
+
else return error(id, -32_601, "Method not found")
|
|
30
|
+
end
|
|
31
|
+
{ jsonrpc: "2.0", id:, result: }
|
|
32
|
+
rescue ArgumentError => e
|
|
33
|
+
error(id, -32_602, e.message)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def call_tool(params)
|
|
39
|
+
name = params.fetch("name", "")
|
|
40
|
+
Catalog.fetch(name)
|
|
41
|
+
arguments = params.fetch("arguments", {})
|
|
42
|
+
raise ArgumentError, "arguments must be an object" unless arguments.is_a?(Hash)
|
|
43
|
+
|
|
44
|
+
result = Command.call(name:, arguments:, user: @user, script_name: @script_name)
|
|
45
|
+
data = { data: result.value, errors: result.errors, status: Rack::Utils.status_code(result.status) }
|
|
46
|
+
{ content: [{ type: "text", text: data.to_json }], structuredContent: data, isError: !result.success? || data[:status] >= 400 }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def error(id, code, message)
|
|
50
|
+
{ jsonrpc: "2.0", id:, error: { code:, message: } }
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
module Postnhost
|
|
2
|
+
module Management
|
|
3
|
+
module Preview
|
|
4
|
+
def self.call(user:, record: nil, template: nil, script_name: "")
|
|
5
|
+
action = record ? "preview" : "index"
|
|
6
|
+
path = record ? "/preview/#{record.id}" : "/?preview_template=#{template}"
|
|
7
|
+
origin = Setting.current.effective_site_url || "http://example.com"
|
|
8
|
+
env = Rack::MockRequest.env_for("#{origin}#{script_name}#{path}")
|
|
9
|
+
env["SCRIPT_NAME"] = script_name
|
|
10
|
+
env["PATH_INFO"] = path.split("?").first
|
|
11
|
+
env["postnhost.management_user"] = user
|
|
12
|
+
env["action_dispatch.request.path_parameters"] = { controller: "postnhost/management_preview", action:, id: record&.id }
|
|
13
|
+
env["action_dispatch.routes"] = Engine.routes
|
|
14
|
+
status, _headers, body = I18n.with_locale(I18n.default_locale) { ManagementPreviewController.action(action).call(env) }
|
|
15
|
+
html = +""
|
|
16
|
+
body.each { |chunk| html << chunk }
|
|
17
|
+
raise ArgumentError, "Preview could not be rendered" unless status == 200
|
|
18
|
+
|
|
19
|
+
{ html:, template: template || Template.active_name }
|
|
20
|
+
ensure
|
|
21
|
+
body&.close if body.respond_to?(:close)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
module Postnhost
|
|
2
|
+
module Management
|
|
3
|
+
module Resources
|
|
4
|
+
TEXT = %w[title title_tag og_title meta_description content language_id].freeze
|
|
5
|
+
EXCERPT = %w[custom_excerpt use_excerpt_as_meta_description].freeze
|
|
6
|
+
ARTICLE = (TEXT + EXCERPT + %w[schema_headline schema_article_type slug cover_image_alt scheduled_at top_pick author_ids category_ids suggested_article_ids]).freeze
|
|
7
|
+
PAGE = (TEXT + %w[slug]).freeze
|
|
8
|
+
USER = %w[email name slug position bio website_url x_url linkedin_url threads_url tiktok_url youtube_url facebook_url instagram_url bluesky_url mastodon_url password password_confirmation].freeze
|
|
9
|
+
SETTING = %w[site_url site_indexing public_page_size timezone author_pages_enabled search_enabled show_powered_by use_auto_header_navigation use_auto_footer_navigation schema_settings schema_locale_overrides].freeze
|
|
10
|
+
DEFINITIONS = {
|
|
11
|
+
"api_tokens" => { model: "ApiToken", fields: %w[name] },
|
|
12
|
+
"articles" => { model: "Article", fields: ARTICLE, snapshot: :article_snapshot },
|
|
13
|
+
"pages" => { model: "Page", fields: PAGE, snapshot: :page_snapshot },
|
|
14
|
+
"categories" => { model: "Category", fields: %w[name slug meta_description] },
|
|
15
|
+
"languages" => { model: "Language", fields: %w[name html_lang default] },
|
|
16
|
+
"users" => { model: "User", fields: USER + %w[schema_profile schema_locale_overrides] },
|
|
17
|
+
"article_variants" => { model: "ArticleVariant", fields: TEXT + EXCERPT + %w[schema_headline], parent: "articles", snapshot: :article_variant_snapshot },
|
|
18
|
+
"page_variants" => { model: "PageVariant", fields: TEXT, parent: "pages", snapshot: :page_variant_snapshot },
|
|
19
|
+
"category_variants" => { model: "CategoryVariant", fields: %w[name meta_description language_id], parent: "categories" },
|
|
20
|
+
"settings" => { model: "Setting", fields: SETTING, singleton: true },
|
|
21
|
+
"template" => { model: "Template", fields: %w[name], singleton: true },
|
|
22
|
+
"site_scripts" => { model: "SiteScript", fields: %w[placement script] }
|
|
23
|
+
}.freeze
|
|
24
|
+
|
|
25
|
+
def self.model(resource)
|
|
26
|
+
Postnhost.const_get(DEFINITIONS.fetch(resource).fetch(:model), false)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
module Postnhost
|
|
2
|
+
module Management
|
|
3
|
+
module SchemaFields
|
|
4
|
+
TEXT = { type: %w[string null] }.freeze
|
|
5
|
+
STRINGS = { type: "array", items: { type: "string" }, maxItems: 100 }.freeze
|
|
6
|
+
|
|
7
|
+
def self.settings
|
|
8
|
+
fields = Postnhost::Settings::SchemaManageable::SCHEMA_SETTING_FIELDS.index_with { TEXT }
|
|
9
|
+
fields["same_as"] = STRINGS
|
|
10
|
+
fields["policies"] = { type: "object", properties: Postnhost::SchemaHelper::POLICY_FIELDS.keys.index_with { TEXT }, additionalProperties: false }
|
|
11
|
+
{ type: "object", properties: fields, additionalProperties: false }
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def self.profile
|
|
15
|
+
fields = Postnhost::Users::SchemaManageable::USER_SCHEMA_PROFILE_FIELDS.index_with { |field| %w[same_as knows_language].include?(field) ? STRINGS : TEXT }
|
|
16
|
+
{ type: "object", properties: fields, additionalProperties: false }
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def self.overrides(resource)
|
|
20
|
+
fields = resource == "users" ? Postnhost::Users::SchemaManageable::USER_SCHEMA_TEXT_FIELDS : Postnhost::SchemaHelper::SETTINGS_TEXT_OVERRIDE_FIELDS
|
|
21
|
+
{ type: "object", additionalProperties: { type: "object", properties: fields.index_with { TEXT }, additionalProperties: false } }
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
module Postnhost
|
|
2
|
+
module Management
|
|
3
|
+
module Serializer
|
|
4
|
+
SUMMARY_FIELDS = %w[
|
|
5
|
+
id user_id article_id page_id category_id language_id name title slug email position
|
|
6
|
+
html_lang default created_at updated_at scheduled_at publication_error top_pick generating
|
|
7
|
+
articles_count article_variants_count page_variants_count placement revoked_at
|
|
8
|
+
cover_image cover_image_alt avatar_file
|
|
9
|
+
].freeze
|
|
10
|
+
|
|
11
|
+
def self.summary(record)
|
|
12
|
+
data = record.attributes.slice(*SUMMARY_FIELDS)
|
|
13
|
+
data["published"] = record["management_snapshot_id"].present? if record.has_attribute?("management_snapshot_id")
|
|
14
|
+
%w[cover_image avatar_file].each do |field|
|
|
15
|
+
data["#{field}_url"] = record.public_send(field).url if record.has_attribute?(field)
|
|
16
|
+
end
|
|
17
|
+
data
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def self.call(value)
|
|
21
|
+
case value
|
|
22
|
+
when ActiveRecord::Base
|
|
23
|
+
data = value.attributes.except("password_digest", "token_digest", "scheduled_job_id")
|
|
24
|
+
if value.respond_to?(:published?)
|
|
25
|
+
data["published"] = value.published?
|
|
26
|
+
data["unpublished_changes"] = value.unpublished_changes?
|
|
27
|
+
end
|
|
28
|
+
data.merge!("author_ids" => value.author_ids, "category_ids" => value.category_ids, "suggested_article_ids" => value.suggested_article_ids) if value.is_a?(Article)
|
|
29
|
+
%w[cover_image avatar_file site_logo og_image].each do |field|
|
|
30
|
+
data["#{field}_url"] = value.public_send(field).url if value.respond_to?(field)
|
|
31
|
+
end
|
|
32
|
+
data
|
|
33
|
+
when Hash then value.transform_values { |item| call(item) }
|
|
34
|
+
when Array then value.map { |item| call(item) }
|
|
35
|
+
else value
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
module Postnhost
|
|
2
|
+
module Management
|
|
3
|
+
class SettingsActions < BaseService
|
|
4
|
+
include Postnhost::Settings::NavigationManageable
|
|
5
|
+
|
|
6
|
+
def initialize(action:, record:, arguments:)
|
|
7
|
+
@action = action
|
|
8
|
+
@record = record
|
|
9
|
+
@arguments = arguments
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def call
|
|
13
|
+
return upload if action.start_with?("upload_")
|
|
14
|
+
|
|
15
|
+
if %w[remove_logo remove_og_image].include?(action)
|
|
16
|
+
field = action == "remove_logo" ? :site_logo : :og_image
|
|
17
|
+
record.update!(field => nil)
|
|
18
|
+
return record
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
validate_locale!
|
|
22
|
+
case action
|
|
23
|
+
when "get_navigation"
|
|
24
|
+
@setting = record
|
|
25
|
+
@selected_navigation_locale_key = arguments.fetch("locale")
|
|
26
|
+
@default_navigation_locale_key = Language.blog_default&.html_lang || I18n.default_locale.to_s
|
|
27
|
+
@navigation = record.current_navigation
|
|
28
|
+
{ tree: navigation_tree_for_editor, targets: navigation_target_options }
|
|
29
|
+
when "replace_navigation"
|
|
30
|
+
tree = arguments.fetch("tree")
|
|
31
|
+
record.current_navigation.replace_tree!(tree:, locale_key: arguments.fetch("locale"))
|
|
32
|
+
{ navigation: record.current_navigation.navigation_items.to_a }
|
|
33
|
+
when "get_translations"
|
|
34
|
+
Settings::I18nOverrides.editable_translations_for_locale(arguments.fetch("locale"), record.locale_overrides)
|
|
35
|
+
when "update_translations"
|
|
36
|
+
overrides = Settings::I18nOverrides.merge_locale_overrides(record.locale_overrides, arguments.fetch("locale"), arguments.fetch("translations"))
|
|
37
|
+
record.update!(locale_overrides: overrides)
|
|
38
|
+
record
|
|
39
|
+
else
|
|
40
|
+
raise ArgumentError, "Unsupported action"
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
attr_reader :action, :record, :arguments
|
|
47
|
+
|
|
48
|
+
def validate_locale!
|
|
49
|
+
raise ArgumentError, "Locale must belong to a configured language" unless Language.exists?(html_lang: arguments["locale"])
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def upload
|
|
53
|
+
file = Upload.open(arguments.fetch("file"))
|
|
54
|
+
if action == "upload_image"
|
|
55
|
+
result = record.upload_inline_image(file)
|
|
56
|
+
return result[:success] ? success({ url: result[:url] }) : failure(result[:error])
|
|
57
|
+
end
|
|
58
|
+
field = { "upload_cover" => :cover_image, "upload_avatar" => :avatar_file, "upload_logo" => :site_logo, "upload_og_image" => :og_image }.fetch(action)
|
|
59
|
+
record.update!(field => file)
|
|
60
|
+
{ url: record.public_send(field).url }
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
require "base64"
|
|
2
|
+
require "stringio"
|
|
3
|
+
|
|
4
|
+
module Postnhost
|
|
5
|
+
module Management
|
|
6
|
+
module Upload
|
|
7
|
+
def self.open(payload)
|
|
8
|
+
filename = payload.fetch("filename")
|
|
9
|
+
raise ArgumentError, "Invalid filename" if filename.blank? || filename != File.basename(filename) || filename.include?("\\")
|
|
10
|
+
|
|
11
|
+
file = StringIO.new(Base64.strict_decode64(payload.fetch("base64")))
|
|
12
|
+
file.define_singleton_method(:original_filename) { filename }
|
|
13
|
+
file.define_singleton_method(:content_type) { payload.fetch("content_type") }
|
|
14
|
+
file
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
module Postnhost
|
|
2
|
+
module Translation
|
|
3
|
+
class Enqueue < BaseService
|
|
4
|
+
def initialize(record:, language_ids:)
|
|
5
|
+
@record = record
|
|
6
|
+
@language_ids = language_ids
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def call
|
|
10
|
+
return success({ variants: [], job_ids: [] }) if language_ids.empty?
|
|
11
|
+
|
|
12
|
+
kind = record.class.model_name.element
|
|
13
|
+
return failure("Publish the source before generating translations") if record.respond_to?(:published?) && !record.published?
|
|
14
|
+
|
|
15
|
+
languages = language_ids.map { |id| Language.find(id) }
|
|
16
|
+
primary_id = record.respond_to?(:language_id) ? record.language_id : Language.blog_default&.id
|
|
17
|
+
return failure("Cannot translate into the source language") if languages.any? { |language| language.id == primary_id }
|
|
18
|
+
|
|
19
|
+
variants = record.class.transaction do
|
|
20
|
+
languages.map { |language| record.public_send("#{kind}_variants").create!(language:, generating: true) }
|
|
21
|
+
end
|
|
22
|
+
enqueue_variants(variants, Translation.const_get("#{kind.camelize}VariantJob", false))
|
|
23
|
+
rescue ActiveRecord::RecordInvalid => e
|
|
24
|
+
failure(e.record.errors.full_messages)
|
|
25
|
+
rescue ActiveRecord::RecordNotFound
|
|
26
|
+
failure("Language not found", status: :not_found)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
attr_reader :record, :language_ids
|
|
32
|
+
|
|
33
|
+
def enqueue_variants(variants, job_class)
|
|
34
|
+
jobs = []
|
|
35
|
+
variants.each do |variant|
|
|
36
|
+
job = enqueue(job_class, variant)
|
|
37
|
+
unless job
|
|
38
|
+
unqueued = variants.drop(jobs.size)
|
|
39
|
+
unqueued.each(&:destroy!)
|
|
40
|
+
return ServiceResult.new(
|
|
41
|
+
value: { variants: variants.take(jobs.size), job_ids: jobs.map(&:job_id), failed_language_ids: unqueued.map(&:language_id) },
|
|
42
|
+
errors: ["Could not queue all translations. Retry the failed languages."], status: :service_unavailable
|
|
43
|
+
)
|
|
44
|
+
end
|
|
45
|
+
jobs << job
|
|
46
|
+
end
|
|
47
|
+
success({ variants:, job_ids: jobs.map(&:job_id) }, status: :accepted)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def enqueue(job_class, variant)
|
|
51
|
+
job = job_class.new(record.id, variant.language_id)
|
|
52
|
+
job_class.perform_later(job)
|
|
53
|
+
rescue StandardError => e
|
|
54
|
+
Rails.logger.error("Postnhost translation enqueue failed (#{e.class})")
|
|
55
|
+
job if job&.successfully_enqueued?
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -58,6 +58,13 @@
|
|
|
58
58
|
<span class="text-sm font-medium">Settings</span>
|
|
59
59
|
<% end %>
|
|
60
60
|
|
|
61
|
+
<%= link_to api_tokens_path, class: "group flex items-center space-x-3 text-gray-700 hover:text-gray-900 hover:bg-white font-medium px-3 py-2.5 rounded-xl transition-all duration-200 #{controller_name == 'api_tokens' ? 'bg-white text-gray-900' : ''}" do %>
|
|
62
|
+
<div class="flex-shrink-0 <%= controller_name == 'api_tokens' ? 'text-gray-900' : 'text-gray-500 group-hover:text-gray-700' %>">
|
|
63
|
+
<%= icon("code-bracket", css_class: "h-5 w-5") %>
|
|
64
|
+
</div>
|
|
65
|
+
<span class="text-sm font-medium">API & MCP</span>
|
|
66
|
+
<% end %>
|
|
67
|
+
|
|
61
68
|
<%= link_to edit_template_path, class: "group flex items-center space-x-3 text-gray-700 hover:text-gray-900 hover:bg-white font-medium px-3 py-2.5 rounded-xl transition-all duration-200 #{current_page?(edit_template_path) || controller_name == 'templates' ? 'bg-white text-gray-900' : ''}" do %>
|
|
62
69
|
<div class="flex-shrink-0 #{current_page?(edit_template_path) || controller_name == 'templates' ? 'text-gray-900' : 'text-gray-500 group-hover:text-gray-700'}">
|
|
63
70
|
<%= icon("eye", css_class: "h-5 w-5") %>
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<!DOCTYPE html>
|
|
2
|
-
<html lang="<%= content_for?(:lang) ? yield(:lang) : 'en' %>">
|
|
2
|
+
<html lang="<%= content_for?(:lang) ? yield(:lang) : 'en' %>" data-postnhost>
|
|
3
3
|
<head>
|
|
4
4
|
<title><%= content_for?(:title) ? yield(:title) : "postnhost" %></title>
|
|
5
5
|
<meta name="description" content="<%= content_for?(:meta_description) ? yield(:meta_description) : "postnhost" %>">
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
<%= csrf_meta_tags %>
|
|
11
11
|
<%= csp_meta_tag %>
|
|
12
12
|
|
|
13
|
-
<%= stylesheet_link_tag
|
|
13
|
+
<%= stylesheet_link_tag "postnhost/application", "data-turbo-track": "reload", media: "all" %>
|
|
14
14
|
<%= javascript_include_tag "postnhost/application", "data-turbo-track": "reload", type: "module" %>
|
|
15
15
|
|
|
16
16
|
<%= render "layouts/postnhost/favicon" %>
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<!DOCTYPE html>
|
|
2
|
-
<html lang="<%= content_for?(:lang) ? yield(:lang) : 'en' %>">
|
|
2
|
+
<html lang="<%= content_for?(:lang) ? yield(:lang) : 'en' %>" data-postnhost>
|
|
3
3
|
<head>
|
|
4
4
|
<title><%= content_for?(:title) ? yield(:title) : "postnhost" %></title>
|
|
5
5
|
<meta name="description" content="<%= content_for?(:meta_description) ? yield(:meta_description) : "postnhost dashboard - Manage your account and projects" %>">
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
<%= csrf_meta_tags %>
|
|
8
8
|
<%= csp_meta_tag %>
|
|
9
9
|
|
|
10
|
-
<%= stylesheet_link_tag
|
|
10
|
+
<%= stylesheet_link_tag "postnhost/application", "data-turbo-track": "reload" %>
|
|
11
11
|
<%= javascript_include_tag 'postnhost/application', 'data-turbo-track': 'reload', type: "module" %>
|
|
12
12
|
<%= turbo_refreshes_with method: :morph, scroll: :preserve %>
|
|
13
13
|
|
|
@@ -31,4 +31,4 @@
|
|
|
31
31
|
</div>
|
|
32
32
|
</main>
|
|
33
33
|
</body>
|
|
34
|
-
</html>
|
|
34
|
+
</html>
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<!DOCTYPE html>
|
|
2
|
-
<html lang="<%= content_for?(:lang) ? yield(:lang) : 'en' %>">
|
|
2
|
+
<html lang="<%= content_for?(:lang) ? yield(:lang) : 'en' %>" data-postnhost>
|
|
3
3
|
<head>
|
|
4
4
|
<title><%= content_for?(:title) ? yield(:title) : "postnhost" %></title>
|
|
5
5
|
<meta name="description" content="<%= content_for?(:meta_description) ? yield(:meta_description) : "postnhost dashboard - Manage your account and projects" %>">
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
<%= csrf_meta_tags %>
|
|
8
8
|
<%= csp_meta_tag %>
|
|
9
9
|
|
|
10
|
-
<%= stylesheet_link_tag
|
|
10
|
+
<%= stylesheet_link_tag "postnhost/application", "data-turbo-track": "reload" %>
|
|
11
11
|
<%= javascript_include_tag 'postnhost/application', 'data-turbo-track': 'reload', type: "module" %>
|
|
12
12
|
|
|
13
13
|
<%= turbo_refreshes_with method: :morph, scroll: :preserve %>
|
|
@@ -33,4 +33,4 @@
|
|
|
33
33
|
</div>
|
|
34
34
|
</div>
|
|
35
35
|
</body>
|
|
36
|
-
</html>
|
|
36
|
+
</html>
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<!DOCTYPE html>
|
|
2
|
-
<html lang="<%= document_lang %>"
|
|
2
|
+
<html lang="<%= document_lang %>" data-postnhost>
|
|
3
3
|
<head>
|
|
4
4
|
<meta charset="UTF-8">
|
|
5
5
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
<%= csrf_meta_tags %>
|
|
11
11
|
<%= csp_meta_tag %>
|
|
12
12
|
|
|
13
|
-
<%= stylesheet_link_tag
|
|
13
|
+
<%= stylesheet_link_tag "postnhost/application", "data-turbo-track": "reload" %>
|
|
14
14
|
<%= javascript_include_tag "postnhost/application", "data-turbo-track": "reload", type: "module" %>
|
|
15
15
|
|
|
16
16
|
<%= render 'layouts/postnhost/favicon' %>
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<!DOCTYPE html>
|
|
2
|
-
<html lang="<%= document_lang %>"
|
|
2
|
+
<html lang="<%= document_lang %>" data-postnhost>
|
|
3
3
|
<head>
|
|
4
4
|
<meta charset="UTF-8">
|
|
5
5
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
<%= csrf_meta_tags %>
|
|
11
11
|
<%= csp_meta_tag %>
|
|
12
12
|
|
|
13
|
-
<%= stylesheet_link_tag
|
|
13
|
+
<%= stylesheet_link_tag "postnhost/application", "data-turbo-track": "reload" %>
|
|
14
14
|
<%= javascript_include_tag "postnhost/application", "data-turbo-track": "reload", type: "module" %>
|
|
15
15
|
|
|
16
16
|
<%= render "layouts/postnhost/favicon" %>
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<!DOCTYPE html>
|
|
2
|
-
<html lang="<%= document_lang %>"
|
|
2
|
+
<html lang="<%= document_lang %>" data-postnhost>
|
|
3
3
|
<head>
|
|
4
4
|
<meta charset="UTF-8">
|
|
5
5
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
<%= csrf_meta_tags %>
|
|
11
11
|
<%= csp_meta_tag %>
|
|
12
12
|
|
|
13
|
-
<%= stylesheet_link_tag
|
|
13
|
+
<%= stylesheet_link_tag "postnhost/application", "data-turbo-track": "reload" %>
|
|
14
14
|
<%= javascript_include_tag "postnhost/application", "data-turbo-track": "reload", type: "module" %>
|
|
15
15
|
|
|
16
16
|
<%= render "layouts/postnhost/favicon" %>
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
<% content_for :title, "API & MCP" %>
|
|
2
|
+
<% content_for :head do %>
|
|
3
|
+
<meta name="turbo-cache-control" content="no-cache">
|
|
4
|
+
<% end %>
|
|
5
|
+
|
|
6
|
+
<div class="space-y-8">
|
|
7
|
+
<%= render "postnhost/shared/elements/page_header", title: "API & MCP access", description: "Connect agents and integrations to your CMS." %>
|
|
8
|
+
|
|
9
|
+
<% if @secret.present? %>
|
|
10
|
+
<section class="rounded-2xl border border-green-200 bg-green-50 p-6 space-y-3" aria-label="New token">
|
|
11
|
+
<div class="flex items-center gap-2 text-green-800">
|
|
12
|
+
<%= icon("check-circle", css_class: "h-5 w-5") %>
|
|
13
|
+
<h2 class="font-medium">Token created</h2>
|
|
14
|
+
</div>
|
|
15
|
+
<p class="text-sm text-green-800">Copy this token now. It is shown only once.</p>
|
|
16
|
+
<code class="block rounded-xl border border-green-200 bg-white p-4 text-sm text-gray-900 break-all select-all"><%= @secret %></code>
|
|
17
|
+
</section>
|
|
18
|
+
<% end %>
|
|
19
|
+
|
|
20
|
+
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
|
21
|
+
<section class="lg:col-span-2 <%= form_card_classes %>" aria-labelledby="create-token-title">
|
|
22
|
+
<div>
|
|
23
|
+
<h2 id="create-token-title" class="text-lg font-medium text-gray-900">Create an access token</h2>
|
|
24
|
+
<p class="mt-2 text-sm text-gray-500">Tokens grant your CMS permissions. Give each integration its own token so you can revoke access separately.</p>
|
|
25
|
+
</div>
|
|
26
|
+
<%= form_with scope: :api_token, url: api_tokens_path, data: { turbo: false }, class: "space-y-6" do |form| %>
|
|
27
|
+
<div>
|
|
28
|
+
<%= form.label :name, class: form_label_classes %>
|
|
29
|
+
<%= form.text_field :name, required: true, placeholder: "e.g. Writing agent", class: form_input_classes %>
|
|
30
|
+
</div>
|
|
31
|
+
<%= form.submit "Create token", class: button_classes %>
|
|
32
|
+
<% end %>
|
|
33
|
+
</section>
|
|
34
|
+
|
|
35
|
+
<section class="<%= form_card_classes %>" aria-labelledby="connection-title">
|
|
36
|
+
<div>
|
|
37
|
+
<h2 id="connection-title" class="text-lg font-medium text-gray-900">Connection details</h2>
|
|
38
|
+
<p class="mt-2 text-sm text-gray-500">Use your token to authenticate either connection.</p>
|
|
39
|
+
</div>
|
|
40
|
+
<dl class="space-y-5">
|
|
41
|
+
<div>
|
|
42
|
+
<dt class="text-sm font-medium text-gray-700">Management API · v1</dt>
|
|
43
|
+
<dd class="mt-2 rounded-xl bg-gray-50 p-3 text-xs text-gray-900 break-all"><code><%= api_v1_commands_url %></code></dd>
|
|
44
|
+
</div>
|
|
45
|
+
<div>
|
|
46
|
+
<dt class="text-sm font-medium text-gray-700">MCP · v1</dt>
|
|
47
|
+
<dd class="mt-2 rounded-xl bg-gray-50 p-3 text-xs text-gray-900 break-all"><code><%= api_v1_mcp_url %></code></dd>
|
|
48
|
+
<dd class="mt-2 text-xs text-gray-500">Connect using Streamable HTTP.</dd>
|
|
49
|
+
</div>
|
|
50
|
+
<div>
|
|
51
|
+
<dt class="text-sm font-medium text-gray-700">Authorization header</dt>
|
|
52
|
+
<dd class="mt-2 rounded-xl bg-gray-50 p-3 text-xs text-gray-900 break-all"><code>Bearer <your-token></code></dd>
|
|
53
|
+
</div>
|
|
54
|
+
</dl>
|
|
55
|
+
</section>
|
|
56
|
+
</div>
|
|
57
|
+
|
|
58
|
+
<section class="space-y-4" aria-labelledby="access-tokens-title">
|
|
59
|
+
<div>
|
|
60
|
+
<h2 id="access-tokens-title" class="text-lg font-medium text-gray-900">Access tokens</h2>
|
|
61
|
+
<p class="mt-2 text-sm text-gray-500">Manage the tokens you have created for your account.</p>
|
|
62
|
+
</div>
|
|
63
|
+
<% if @api_tokens.any? %>
|
|
64
|
+
<%= render "postnhost/shared/elements/table_container", headers: [
|
|
65
|
+
{ text: "Token", class: "col-span-8" },
|
|
66
|
+
{ text: "Actions", class: "col-span-4 text-right" }
|
|
67
|
+
] do %>
|
|
68
|
+
<% @api_tokens.each do |token| %>
|
|
69
|
+
<div class="<%= table_row_classes %>">
|
|
70
|
+
<div class="grid grid-cols-12 gap-4 items-center">
|
|
71
|
+
<div class="col-span-8 min-w-0 space-y-2">
|
|
72
|
+
<p class="font-medium text-gray-900 break-words"><%= token.name %></p>
|
|
73
|
+
<% if token.revoked_at? %>
|
|
74
|
+
<span class="inline-flex <%= stats_badge_classes(variant: :gray) %>">Revoked</span>
|
|
75
|
+
<% else %>
|
|
76
|
+
<span class="inline-flex <%= stats_badge_classes(variant: :green) %>">Active</span>
|
|
77
|
+
<% end %>
|
|
78
|
+
</div>
|
|
79
|
+
<div class="col-span-4 flex justify-end">
|
|
80
|
+
<% unless token.revoked_at? %>
|
|
81
|
+
<%= button_to "Revoke", api_token_path(token), method: :delete, class: button_classes(variant: :danger) %>
|
|
82
|
+
<% end %>
|
|
83
|
+
</div>
|
|
84
|
+
</div>
|
|
85
|
+
</div>
|
|
86
|
+
<% end %>
|
|
87
|
+
<% end %>
|
|
88
|
+
<% else %>
|
|
89
|
+
<%= render "postnhost/shared/elements/empty_state", icon: "code-bracket", title: "No access tokens", description: "Create a token above to connect your first agent or integration." %>
|
|
90
|
+
<% end %>
|
|
91
|
+
</section>
|
|
92
|
+
</div>
|
|
@@ -1,29 +1,29 @@
|
|
|
1
1
|
<div class="space-y-5">
|
|
2
2
|
<p class="text-sm text-gray-600">
|
|
3
|
-
|
|
3
|
+
Review these host-app settings before deployment. Details are available in the Production checklist section of the README.
|
|
4
4
|
</p>
|
|
5
5
|
|
|
6
6
|
<div class="rounded-xl border border-gray-200 bg-gray-50 p-4">
|
|
7
7
|
<ul class="space-y-3 text-sm text-gray-700">
|
|
8
8
|
<li>
|
|
9
9
|
<span class="font-medium text-gray-900">1. Favicon</span><br>
|
|
10
|
-
|
|
10
|
+
Replace the favicon and related icon files in the host app if needed.
|
|
11
11
|
</li>
|
|
12
12
|
<li>
|
|
13
|
-
<span class="font-medium text-gray-900">2. 404
|
|
14
|
-
|
|
13
|
+
<span class="font-medium text-gray-900">2. 404 and 500 pages</span><br>
|
|
14
|
+
Review the host app’s static error pages at <code>public/404.html</code> and <code>public/500.html</code>.
|
|
15
15
|
</li>
|
|
16
16
|
<li>
|
|
17
17
|
<span class="font-medium text-gray-900">3. robots.txt</span><br>
|
|
18
|
-
|
|
18
|
+
Update the host app’s <code>public/robots.txt</code> and confirm that the sitemap URL matches your mount path.
|
|
19
19
|
</li>
|
|
20
20
|
<li>
|
|
21
21
|
<span class="font-medium text-gray-900">4. Public template styling</span><br>
|
|
22
|
-
|
|
22
|
+
To customize the public site, run <code>bin/rails g postnhost:views --views-scope=minimal</code> or use <code>--views-scope=full</code>, then edit the copied templates.
|
|
23
23
|
</li>
|
|
24
24
|
<li>
|
|
25
|
-
<span class="font-medium text-gray-900">5.
|
|
26
|
-
|
|
25
|
+
<span class="font-medium text-gray-900">5. Production uploads</span><br>
|
|
26
|
+
The generated CarrierWave initializer uses S3-compatible storage in production. Configure it through Rails credentials or <code>config/initializers/postnhost.rb</code>, or replace that storage configuration.
|
|
27
27
|
</li>
|
|
28
28
|
</ul>
|
|
29
29
|
</div>
|
|
@@ -104,7 +104,7 @@
|
|
|
104
104
|
</div>
|
|
105
105
|
<% else %>
|
|
106
106
|
<div class="rounded-xl border border-gray-200 bg-gray-50 px-4 py-3 text-sm text-gray-600">
|
|
107
|
-
No code-defined static pages found. Add templates in <code>app/views/postnhost/static_pages</code> (for example, <code>about.html.erb</code> maps to <code>/about</code>), or run <code>rails g postnhost:static_pages about</code> to scaffold a starter template.
|
|
107
|
+
No code-defined static pages found. Add templates in <code>app/views/postnhost/static_pages</code> (for example, <code>about.html.erb</code> maps to <code>/about</code>), or run <code>bin/rails g postnhost:static_pages about</code> to scaffold a starter template.
|
|
108
108
|
</div>
|
|
109
109
|
<% end %>
|
|
110
110
|
</div>
|