diff --git a/README.md b/README.md index 131b478..e7c7984 100644 --- a/README.md +++ b/README.md @@ -18,18 +18,19 @@ WinterCMS theme components such as `journalPost` and `journalPosts` are a later ## Features -- **Posts.** `models.Post` maps `golem15_journal_posts`. Translatable fields are title, content, content_html, excerpt, metadata, and slug (indexed). Fillable does not include `redactor_id`, `content_html`, or `user_id`. Attachments `featured_images` and `content_images` use `system_files` under the same MorphName. YAML admin uses `mltext`, `mltextarea` and the plugin's own `mlblogeditor` field type for content (see [Admin: Blog Editor field types](#admin-blog-editor-field-types)); an admin save stores the sanitized HTML in both `content` and `content_html`. `classes.FormatHTML` regenerates `content_html` on the other write paths (`journal:import-posts`, Journal API writes). It renders markdown and passes raw HTML in `content` through the renderer (Winter parity), then sanitizes the result with a `bluemonday` allow-list: bluemonday's UGC policy plus `footer`, `class`, schema.org microdata, the `color`, `font-size`, `font-weight` and `text-align` styles, goldmark's footnote roles, `dir="auto"`, two pasted-transcript `data-message-*` attributes, and exactly four rules for the Blog Editor's own output (Phase 16.2): `data-embed` matching `^youtube$` and `data-id` matching `^[A-Za-z0-9_-]{11}$` on `figure` (the embed placeholder), `alt` with any text on `img`, and `start` matching `^[0-9]+$` on `ol`. No iframe, other data attribute, style property or URL scheme is allowed for the editor; `TestEditorOutputPassesPolicy` checks every entry of the editor corpus against the policy. Like Winter's `Html::clean`, it strips what the policy does not allow instead of refusing the save: script, style, iframe, object, embed, form, base and meta elements, comments, event handler attributes, and URLs other than http, https, mailto and relative ones. HTML the policy keeps whole is stored byte for byte, so imported bodies survive a save. Admin saves strip the same way and name what they removed (`classes.SanitizeHTMLReport`). A tag that repeats an attribute is never kept whole: the sanitized output, which keeps the first value, is stored. On the API and import paths `content` itself is stored as submitted. Translated `content_html` goes through the same policy on the API and `journal:import-posts` write paths (`classes.FormatTranslation`): when the translation carries a non-blank `content`, `content_html` is rendered from it and a supplied `content_html` is replaced, as the host column is always derived; a translation with only `content_html` is sanitized with `classes.SanitizeHTML`. Winter stored a supplied translated `content_html` verbatim. The API response shapes and status codes are unchanged. +- **Posts.** `models.Post` maps `golem15_journal_posts`. Translatable fields are title, content, content_html, excerpt, metadata, and slug (indexed). Fillable does not include `redactor_id`, `content_html`, or `user_id`. Attachments `featured_images` and `content_images` use `system_files` under the same MorphName. YAML admin uses `mltext`, `mltextarea` and the plugin's own `mlblogeditor` field type for content (see [Admin: Blog Editor field types](#admin-blog-editor-field-types)); an admin save stores the sanitized HTML in both `content` and `content_html`. `classes.FormatHTML` regenerates `content_html` on the other write paths (`journal:import-posts`, Journal API writes). It renders markdown and passes raw HTML in `content` through the renderer (Winter parity), then sanitizes the result with a `bluemonday` allow-list: bluemonday's UGC policy plus `footer`, `class`, schema.org microdata, the `color`, `font-size`, `font-weight` and `text-align` styles, goldmark's footnote roles, `dir="auto"`, two pasted-transcript `data-message-*` attributes, and exactly five rules for the Blog Editor's own output (Phase 16.2, Phase 33): `data-embed` matching `^youtube$` and `data-id` matching `^[A-Za-z0-9_-]{11}$` on `figure` (the embed placeholder), `alt` with any text on `img`, `start` matching `^[0-9]+$` on `ol`, and `rel` on `a` holding only the tokens `nofollow`, `sponsored` and `ugc` (`^(nofollow|sponsored|ugc)( (nofollow|sponsored|ugc))*$`; any other `rel` value is removed). No iframe, other data attribute, style property or URL scheme is allowed for the editor; `TestEditorOutputPassesPolicy` checks every entry of the editor corpus against the policy. Like Winter's `Html::clean`, it strips what the policy does not allow instead of refusing the save: script, style, iframe, object, embed, form, base and meta elements, comments, event handler attributes, and URLs other than http, https, mailto and relative ones. HTML the policy keeps whole is stored byte for byte, so imported bodies survive a save. Admin saves strip the same way and name what they removed (`classes.SanitizeHTMLReport`). A tag that repeats an attribute is never kept whole: the sanitized output, which keeps the first value, is stored. On the API and import paths `content` itself is stored as submitted. Translated `content_html` goes through the same policy on the API and `journal:import-posts` write paths (`classes.FormatTranslation`): when the translation carries a non-blank `content`, `content_html` is rendered from it and a supplied `content_html` is replaced, as the host column is always derived; a translation with only `content_html` is sanitized with `classes.SanitizeHTML`. Winter stored a supplied translated `content_html` verbatim. The API response shapes and status codes are unchanged. - **Categories and tags.** `models.Category` is translatable (name, description, slug). It also stores `content`, an HTML host column that is not translatable, edited in the admin with the `blogeditor` field type. Fillable is name, slug, code, description, content and parent_id; nest_* columns exist in DDL and are not fillable. `models.Tag` is not translatable. Fillable is name, slug, description. Pivots are `golem15_journal_posts_categories` and `golem15_journal_posts_tags`. -- **Menu and sitemap item types.** The plugin registers its five PHP menu item types (`journal-category`, `all-journal-categories`, `journal-post`, `all-journal-posts`, `category-journal-posts`) and `journal-tag` with the framework's menu item type registry, so menus and sitemaps can point at posts, categories and tags. Posts, categories and tags carry content descriptors, so plugins that work on every content type (sitemaps, SEO, search) describe, page and match Journal records without the Journal importing them. See [menu item types](#menu-item-types) and [content descriptors](#content-descriptors). -- **Settings.** Singleton row ID=1 on `golem15_journal_settings`, not PHP `system_settings`. `search_use_typesense` defaults to false. A fresh save never contacts Typesense. +- **Menu and sitemap item types.** The plugin registers its five PHP menu item types (`journal-category`, `all-journal-categories`, `journal-post`, `all-journal-posts`, `category-journal-posts`), `journal-tag` and `journal-author` with the framework's menu item type registry, so menus and sitemaps can point at posts, categories, tags and author pages. Posts, categories, tags and authors carry content descriptors, so plugins that work on every content type (sitemaps, SEO, search) describe, page and match Journal records without the Journal importing them. See [menu item types](#menu-item-types) and [content descriptors](#content-descriptors). +- **Settings.** Singleton row ID=1 on `golem15_journal_settings`, not PHP `system_settings`. `search_use_typesense` defaults to false. A fresh save never contacts Typesense. The Headings and links tab holds the H1 setting `content_h1_to_h2` (on by default) and the generic link texts `generic_anchors`; see [headings and links](#headings-and-links). +- **Headings and links.** While the H1 setting is on, every H1 saved in Journal rich text (post content and category pillar text) becomes an H2 on every write path, with a notice; `journal:fix-headings` and the MCP tool `journal_fix_headings` convert stored content on request. The Blog Editor follows the setting and adds a heading outline, link `rel` options for external links and image file naming. See [headings and links](#headings-and-links). - **Search.** With a configured search engine and `search_use_typesense` on, published posts are kept in the Typesense collection `golem15_journal_posts` after every committed save, and `GET /posts?search=` answers in relevance order. Otherwise search is a SQL `ILIKE`. `?fields=suggest` serves short rows for a search box, and `journal:reindex` fills the index. See [search](#search). - **Public HTTP.** Anonymous GET `/_journal/api/v1/posts`, `/posts/{slug}`, `/categories`, `/tags`, `/rss`, `/authors/{author}`, and `/authors/{author}/posts`. `/posts/{slug}` finds the post by its default-locale slug, then by its slug in the request locale (the router's locale, from `Accept-Language` or the translate plugin's resolver), then by id, so a translated slug resolves. Writes under the same prefix require a cabana backend JWT (`aud=backend`) or a personal admin API token (`g15_…`) whose scope covers the Journal. - **Record events.** Post writes through the admin fire the framework's record events (`pact.RecordSaved`, `pact.RecordDeleted`) after the commit, like every admin controller. The `/_journal/api/v1` post routes, which MCP tools call, fire the same events with `cabana.EmitRecordSaved` and `cabana.EmitRecordDeleted`: `POST /posts` a RecordSaved with `Created`, `PUT /posts/{id}` a RecordSaved whose changes hold the post's changed columns and each submitted translation that changed (a translated slug per locale), `DELETE /posts/{id}` a RecordDeleted. A tag `POST` or `PUT` creates from a name in `tags` fires its own RecordSaved with `Created`. `POST` and `PUT` write the row, its categories, tags and translations in one transaction and fire the event after it commits, so a write that fails part-way changes nothing and fires nothing. Their `Controller` is empty, as they do not go through an admin form. Another plugin (an SEO plugin creating redirects, a search indexer) therefore hears about a slug changed over MCP without importing the Journal. -- **MCP tools.** `journal.Plugin.MCP` declares seven tools and one prompt for the framework's MCP endpoint (pier, a binary built with the `summercms_mcp` tag): `journal_list_posts`, `journal_show_post`, `journal_list_categories`, `journal_list_tags`, `journal_create_post`, `journal_update_post`, `journal_delete_post` and the prompt `journal_draft_post`. Each runs the matching `/_journal/api/v1` route as the backend account linked to the MCP user (guard `backend`) and is listed only when that account has `golem15.journal.access_posts`; the route checks its own permissions on every call. See [MCP tools](#mcp-tools). +- **MCP tools.** `journal.Plugin.MCP` declares eleven tools and one prompt for the framework's MCP endpoint (pier, a binary built with the `summercms_mcp` tag): `journal_list_posts`, `journal_show_post`, `journal_list_categories`, `journal_list_tags`, `journal_create_post`, `journal_update_post`, `journal_delete_post`, `journal_get_settings`, `journal_update_settings`, `journal_fix_headings`, `journal_set_image_alt` and the prompt `journal_draft_post`. Each runs the matching `/_journal/api/v1` route as the backend account linked to the MCP user (guard `backend`) and is listed only when that account has `golem15.journal.access_posts` (`golem15.journal.manage_settings` for the settings and `fix_headings` tools); the route checks its own permissions on every call. See [MCP tools](#mcp-tools). - **Author slug.** Plugin migration adds `backend_users.golem15_bloghub_author_slug`. The column stores the public author slug that the author routes resolve. - **Import and export.** `journal:export-posts` and `journal:import-posts` write and read one JSON file per post. The Posts list toolbar exposes the same actions to administrators with `golem15.journal.access_import_export`. - **Phrasebooks.** Embedded `lang/en/lang.yaml` and `lang/pl/lang.yaml` hold plugin, journal, post, category, and tag UI strings. They are independent of model translation JSON. -- **Schema.** Eight gormigrate steps create the six journal tables, the author-slug column and the category `content` column. AutoMigrate is not used. Table names are `golem15_journal_*` only. +- **Schema.** Nine gormigrate steps create the six journal tables, the author-slug column, the category `content` column and the settings columns `content_h1_to_h2` and `generic_anchors`. AutoMigrate is not used. Table names are `golem15_journal_*` only. ## Usage @@ -171,6 +172,19 @@ GET /_journal/api/v1/posts?search=borowik&fields=suggest&per_page=5 The search document holds the default-locale title, excerpt and content; content is indexed as plain text, from `content_html` (or `content` when nothing was rendered) with tags removed and entities decoded. The collection is created from `models.Post.SearchIndexSchema` when it does not exist. A collection created before the schema existed keeps its auto-detected fields until `journal:reindex --fresh` recreates it. +### Headings and links + +Journal decides per site whether its rich text may hold an H1 (2026-10-28, D-50): a page template that shows the post title or the category name as the H1 needs the body to start at H2, and Journal cannot know which template a site uses. + +- **The setting.** Settings › Journal, tab Headings and links, switch `content_h1_to_h2` (Convert H1 to H2 in post and category content), on by default. The settings row decides; the config key `golem15.journal.content_h1_to_h2` is used only when no row exists (`classes.EffectiveH1ToH2`). Switching it never rewrites stored content. +- **On.** Every H1 saved in Journal rich text becomes an H2 (`classes.ConvertH1`, on the `golang.org/x/net/html` tokenizer: only the tag name changes, attributes and every other byte are kept), on every write path: an admin save of a post or a category (notice `editor.h1_converted` or `editor.h1_converted_category` on the editor field), `POST` and `PUT /_journal/api/v1/posts` and so MCP (default and translated `content` and `content_html`; the answer gains `notices: [{message, count, locale}]`, `locale` only on a translation's notice), markdown content (its rendered HTML) and `journal:import-posts` (it prints `Converted N H1 headings to H2.`). The editor offers H2 to H4. +- **Off.** H1 is stored as written, no notice appears, the editor offers H1 to H4 and `journal:fix-headings` refuses. +- **Stored content.** `journal:fix-headings` (a dry run unless `--apply`) and the MCP tool `journal_fix_headings` (`dry_run` default `true`) convert H1 already stored in post content (default and translated) and in category pillar text, in one transaction, firing a `RecordSaved` per changed post and category; they answer the post, category and heading counts and refuse while the setting is off (exit code 1; `409` from the API). +- **The API flag.** `GET /_journal/api/v1/posts/{slug}` and every category row of `GET /_journal/api/v1/categories` carry `content_h1_to_h2` (boolean, an added key), so a frontend reads the setting from the Journal and not from its own option. +- **Generic link texts.** The `generic_anchors` setting (one per line; the English and Polish seed lists when empty, `classes.DefaultGenericAnchors`) is the list the editor's outline and link form warn about. +- **Settings API.** `GET` and `PUT /_journal/api/v1/settings` read and write every settings column for `golem15.journal.manage_settings` (`403` without it); `PUT` checks the submitted columns against `models.Settings.Rules` and ignores other keys. +- **Image alt.** `POST /_journal/api/v1/images/alt` `{dry_run, changes: [{post_id, locale, src, alt}]}` sets the alt of the image whose `src` equals `src` in the post's `content_html` (and in `content` when that holds the same image), in the default locale or in `locale`; `alt: ""` marks it decorative. It needs `golem15.journal.access_posts` and edit rights on each post, refuses the whole batch with `422` when one image is not in its post, answers `{changes: [{post_id, locale, src, old_alt, alt, changed}], dry_run}` and fires a `RecordSaved` per changed post. Alt text is post content, so the bulk alt tool is Journal's (`journal_set_image_alt`), not the SEO plugin's. + ### Personal API tokens The plugin registers two scopes through `journal.Plugin.TokenScopes` (`pact.HasTokenScopes`): @@ -217,20 +231,26 @@ With the framework's MCP endpoint mounted (`summer.yaml` `build.tags: [summercms | `journal_create_post` | `POST /_journal/api/v1/posts` | `title`, `content`, optional `slug`, `excerpt`, `published`, `published_at`, `is_pinned`, `categories` (ids), `tags` (names) | | `journal_update_post` | `PUT /_journal/api/v1/posts/{id}` | `id` and any of the create fields; only the fields sent change | | `journal_delete_post` | `DELETE /_journal/api/v1/posts/{id}` | `id` (destructive) | +| `journal_get_settings` | `GET /_journal/api/v1/settings` | none (`golem15.journal.manage_settings`) | +| `journal_update_settings` | `PUT /_journal/api/v1/settings` | any settings column, for example `content_h1_to_h2` or `generic_anchors`; only the fields sent change (`golem15.journal.manage_settings`) | +| `journal_fix_headings` | `POST /_journal/api/v1/fix-headings` | `dry_run` (default `true`) (`golem15.journal.manage_settings`) | +| `journal_set_image_alt` | `POST /_journal/api/v1/images/alt` | `dry_run`, `changes` (`post_id`, `locale`, `src`, `alt`) (destructive) | The prompt `journal_draft_post` (arguments `topic`, required, and `tone`) asks the agent to draft a post, reuse existing categories and tags and save it with `journal_create_post` as a draft; it is marked `Writes`, so it is offered only to grants that may write. A grant without the `write` scope lists only the read tools and no prompt. Publishing still needs `golem15.journal.access_publish` and editing another author's post `golem15.journal.access_other_posts`; the route answers its usual error body, which the agent receives as the tool error. The media and featured-image uploads (multipart) are not tools. ### Blog Editor -`assets/src/blogeditor` is the Blog Editor admin element, the custom element `golem15-journal-blogeditor` (`BlogEditorElement`). It is a WYSIWYG HTML editor on TipTap (ProseMirror, MIT) with a Source tab that shows and edits the exact HTML string. The toolbar offers paragraphs and headings 2 to 4, bold, italic, bullet and ordered lists, links, blockquotes, tables, images, YouTube embeds, a raw HTML block, undo and redo, plus a Preview toggle. Labels follow the `ui-locale` attribute (`en`, `pl`). +`assets/src/blogeditor` is the Blog Editor admin element, the custom element `golem15-journal-blogeditor` (`BlogEditorElement`). It is a WYSIWYG HTML editor on TipTap (ProseMirror, MIT) with a Source tab that shows and edits the exact HTML string. The toolbar offers paragraphs and headings 2 to 4 (1 to 4 while the H1 setting is off), bold, italic, bullet and ordered lists, links, blockquotes, tables, images, YouTube embeds, a raw HTML block, undo and redo, plus a Preview toggle. Labels follow the `ui-locale` attribute (`en`, `pl`). The element follows the admin field type element contract: the `value` property holds the HTML string; the attributes are `field-name`, `record-id`, `locale`, `ui-locale`, `options`, `disabled` and `invalid`; a bubbling `summer-change` event with `detail.value` fires on user edits only. It styles itself only through the admin `--c-*` variables, in `blogeditor.css` under its tag (TipTap's injected stylesheet is off), and never receives or sends credentials. - **Untouched values.** The element returns the exact string it was given until the user edits; an untouched imported post is saved byte for byte. Setting `value` to the string it currently returns is a no-op. After an edit, top-level blocks that are still equal to the loaded ones keep their original bytes, so editing one paragraph changes only that paragraph. Markup the schema does not model (`footer`, `section`, `aside`, `header`, `nav`, `details`, `style`, `div` and similar blocks, and a `figure` that is not an image with an optional caption) becomes a raw HTML card that shows its source as text and is written back verbatim. On paragraphs, headings, lists, blockquotes, tables, cells, images and links, and on `span` elements, the view-safe unmodelled attributes survive an edit of their block: `class`, `title`, `alt`, `width`, `height`, `colspan`, `rowspan`, `start`, `rel`, `target` and `data-*` (`href` and `src` are kept by the link and image nodes, which check them). Every other attribute, such as `id`, `name`, `style`, `srcset`, `form`, `contenteditable`, `popover`, `itemprop` or an event handler (`on*`), is never rendered into the admin page and is dropped from a block once that block is edited; a `span` left with none of the kept attributes is dropped and its text kept. Untouched documents and untouched blocks keep every stored attribute. - **Embeds.** The embed button takes a YouTube URL (`youtube.com/watch?v=`, `youtu.be/`, `youtube.com/shorts/`, `youtube.com/embed/`) and stores exactly `
`, where `ID` matches `^[A-Za-z0-9_-]{11}$`. The admin shows a card and never an iframe; the site layer expands the placeholder. Another provider is one entry in `EMBED_PROVIDERS` (`embed.ts`) and one in the layer. - **Preview.** The Preview button (`Podgląd` for `ui-locale` `pl`) is off by default, belongs to each element on its own and shows its state through `aria-pressed`. It splits the editor 50/50; when the element is narrower than about 720px, the pane wraps below the editor. The pane renders the current value from the WYSIWYG or Source tab 300 ms (`PREVIEW_DEBOUNCE_MS` in `preview.ts`) after the last change, and each update reloads the frame. The frame is an `