Search by

tietge / silverstripe-markup

moritz-sauer-13

Anmerkungen direkt auf der Website für SilverStripe: eingeloggte Kunden markieren einen Punkt oder Bereich, beschreiben, was anders sein soll, und hängen Bilder an — die Agentur arbeitet die Anmerkungen im CMS ab.

Package info

git.innomedia.de/Tietge/silverstripe-markup

Issues

Type:silverstripe-vendormodule

pkg:composer/tietge/silverstripe-markup

Statistics

Installs: 23

Dependents: 0

Suggesters: 0

1.0.3 2026-10-06 10:57 UTC

This package is auto-updated.

Last update: 2026-10-06 11:01:39 UTC


README

English · Deutsch

tietge/silverstripe-markup

Version 1.0 · SilverStripe 6 · PHP ≥ 8.3

Client feedback directly on the website, in the style of markup.io. Logged-in clients place a point (on desktop also an area) on the live page, describe the change and attach images. Each comment is anchored to the page and the Elemental block and stores a protected screenshot of the client's view. The agency works through the comments in the CMS: Kanban board, tab on page and block, badge in the site tree and a jump into the CMS preview with the comment open. No guest access, no inline editing, no external services.

Screenshots

Comment mode on the website: highlighted paragraph, newly placed point and the open form “What should be different here?”
Placing a point on the website.
Kanban board in the CMS with the columns Open, In progress, Question and Resolved
Board in the CMS with four status columns.
Comment on a phone: point on a paragraph, bottom sheet with text field and the buttons Photo and Image
Bottom sheet on phones.
Open point on the website with status “In progress”, client text, image attachment, agency reply and client response
Thread with status, attachment and replies.
Site tree with count badges on Home and Services, block list with badges on the affected blocks
Badges in site tree and block list.
Board with open side panel: status switch, screenshot crop with ring, attachment tile, details and thread
Side panel with screenshot, attachment, details and thread.

Features

  • Overlay (Shadow DOM, loaded only for members with permission, never in the cached HTML): point or area on desktop, point on touch/below 768 px with bottom sheet and separate “Photo”/“Image” buttons; image attachments (default max. 3).
  • Screenshot of the visible viewport (snapdom, in the browser), captured at the moment of the click, with the marker drawn in; stored protected.
  • Anchoring to Elemental block + selector path + text snippet, with fallbacks (text search, approximate position in the block). Points that are hidden (slider, <details>, other breakpoint) are listed under “Out of view” with a “Show” button; nothing is lost silently (“No longer found”).
  • Statuses Open, In progress, Question, Resolved. Clients can edit/delete their own open comments and mark them “Resolved for me”.
  • CMS: board at admin/markup (drag & drop, filters, side panel with reply field, image viewer), read-only tab “Comments (n)” on pages and blocks, badges in site tree and Elemental block list, “Open in CMS” opens the preview with the comment.
  • Deep links {page URL}?markup={ID}, independent of device and host; invite link that logs the client in and lands on the website (?markup=welcome).
  • Digest mail to the agency, customer notifications (bundled or immediate), automatic deletion of images after resolution.
  • UI in German and English (overlay: member locale, otherwise <html lang>; board: CMS locale).
  • Optional with tractorcow/silverstripe-fluent and silverstripe/subsites: comments per site and language, see Multilingual sites and Subsites.

Installation

composer require tietge/silverstripe-markup:^1.0
vendor/bin/sake db:build --flush

Requires silverstripe/framework ^6, silverstripe/cms ^6, silverstripe/admin ^3, silverstripe/assets ^3. dnadesign/silverstripe-elemental is optional; without it comments are anchored to the page only and there is no block tab. Source: https://git.innomedia.de/Tietge/silverstripe-markup, changes in CHANGELOG.md (German).

Then set up the cron tasks. Enter the digest recipient under Settings › “Comments” (SiteConfig.MarkupAgencyEmail, visible with MARKUP_MANAGE only); without it no agency digest is sent.

The same tab has the checkbox “Hide the comment tool in the frontend” (SiteConfig.MarkupHideInFrontend, off by default): the overlay is no longer loaded on the website, while the board, tabs, mails, the API and the CMS preview (CMSPreview=1) keep working. The invite link and “On the website” links (board, tabs, mails) then just open the page. To switch the module off entirely, use enabled: false below.

Configuration

Defaults:

Tietge\Markup\Config:
  enabled: true                  # false: no overlay, /markup/api/… returns 404
  max_body_length: 4000          # characters per comment or reply
  max_attachments: 3             # image attachments per comment
  max_upload_bytes: 8388608      # per file (screenshot or attachment), 8 MB
  max_pixels: 40000000           # width × height, checked before decoding
  attachment_max_edge: 2000      # longest edge of an attachment after re-encoding
  screenshot_max_edge: 2000      # longest edge of a screenshot
  screenshot_timeout_ms: 8000    # capture time limit in the browser
  rate_limit_per_hour: 60        # write requests per member and hour, 0 = no limit
  media_retention_days: 90       # delete images this many days after “Resolved”, 0 = never
  resolved_visible_days: 30      # show resolved comments on the board this long, 0 = all
  customer_notifications: digest # digest (bundled, via cron) or immediate
  target_classes:                # allowed classes for block anchoring (incl. subclasses)
    - DNADesign\Elemental\Models\BaseElement

Tietge\Markup\Service\UrlNormalizer:
  strip_parameters: [CMSPreview, stage, ElementalPreview, markup, fbclid, gclid]
  strip_parameter_prefixes: [utm_]

Tietge\Markup\Service\MediaStore:
  folder: 'markup'               # folder in the asset store
  quality: 85                    # JPEG/WebP when re-encoding

Tietge\Markup\Admin\ThumbnailRenderer:
  width: 640                     # card image on the board
  height: 400
  min_crop_width: 800            # minimum crop width within the screenshot
  attachment_edge: 320           # longest edge of attachment tiles in the panel
  quality: 82

Disable per environment:

---
Only:
  environment: live
---
Tietge\Markup\Config:
  enabled: false

Custom slider libraries can register a revealer so that “Show” can bring a hidden point into view (Swiper and <details> are built in):

window.tietgeMarkup?.registerRevealer((el) => {
  const slide = el.closest('.my-slide');
  if (!slide) return false;
  mySlider.goTo([...slide.parentElement.children].indexOf(slide));
  return true; // may also return a Promise (wait for the animation)
});

window.tietgeMarkup exists as soon as overlay.js runs; the app fires tietge-markup:ready on window when it is ready.

Permissions and groups

PermissionForAllows
MARKUP_COMMENT (“Comments: create”)ClientSee, create and reply to comments; edit own (while open), delete own (open, no replies), resolve own
MARKUP_MANAGE (“Comments: manage”)AgencySee everything, set status, reply, delete; board, tabs, site tree badges, Settings tab “Comments”

ADMIN includes both; CMS_ACCESS_LeftAndMain alone does not grant board access. db:build creates the group “Website comments” (code markup-feedback) with MARKUP_COMMENT once; it is identified by its code and may be renamed. Clients need an account in this group, otherwise the invite link logs them in without an overlay.

All checks go through canView(), canEdit(), canDelete(), canSetStatus() and canReply() on Tietge\Markup\Model\MarkupComment. Single tenant: every client sees all comments; restrict via an extension implementing canView().

The invite link is available under Settings › “Comments” (home page), in the “Comments (n)” tab of each page (that page) and via “Copy invite link” on the board; in code Tietge\Markup\Service\CommentLinks::inviteLink(?SiteTree $page = null).

Cron

Both tasks are CLI-only and support --dry-run. Run them as the web server user. (The third task, tasks:markup-site-context, is a one-off migration, see Multilingual sites and Subsites.)

# Digest mails (agency and, with digest, clients), weekday mornings
0 7 * * 1-5 www-data cd /path/to/project && php8.4 vendor/bin/sake tasks:markup-digest >> /var/log/markup-digest.log 2>&1

# Delete images of resolved comments, nightly
30 2 * * * www-data cd /path/to/project && php8.4 vendor/bin/sake tasks:markup-cleanup >> /var/log/markup-cleanup.log 2>&1
  • tasks:markup-digest: one mail to the agency with all unreported comments and client replies, grouped by page; with customer_notifications: digest one mail per client with agency replies and resolved comments. --only=agency|customers limits the run.
  • tasks:markup-cleanup: deletes screenshot and attachments media_retention_days after “Resolved”; text and anchoring remain.
  • customer_notifications: immediate sends client mails on save instead (new reply, resolved). Own replies and self-resolved comments never trigger a mail. Sender is Email.admin_email; failed sends are logged and never break saving.

Without cron: no agency digest, no client mails (with digest), no image cleanup.

Multilingual sites and Subsites

Both are optional (composer suggest): tractorcow/silverstripe-fluent ^8 and silverstripe/subsites ^4. Without them everything behaves as before. When installed, _config/context.yml binds the matching adapters automatically; there is nothing to configure.

Test status: the logic is tested with substitute adapters (without the two modules). The real adapters SubsitesAdapter and FluentAdapter have so far only been written and checked against the modules’ source code; their tests (tests/php/Context/*AdapterTest.php) only run in a project that has the modules installed. Before relying on this in production, run the test suite there once and walk through the points below in a browser.

What behaves how

  • Every comment stores the site (MarkupComment.SubsiteID, 0 = main site) and the language (MarkupComment.Locale, e.g. en_US) of the URL it was placed on: language from the prefix (/en/…), query parameter or locale domain, site from the domain or, in the CMS preview, from ?SubsiteID=.
  • The overlay separates strictly: on a site only that site’s comments exist, the API answers 404 for all others. With locale domains (same path in two languages) the comment’s language decides as well.
  • Permissions apply per site, following the group assignment of Subsites (Security › group › “Subsites”): a group limited to “only these subsites” grants MARKUP_COMMENT/MARKUP_MANAGE only there, a group with “access to all subsites” everywhere; on the main site only groups with access to all subsites count. ADMIN may do everything everywhere. Without a permission on a site the overlay does not appear there, and the API answers 403, whatever URL or SubsiteID parameter a request sends along. Note: Subsites creates every group with “access to all subsites”. The group “Website comments” created by the module therefore applies to all sites until you restrict it. To separate customers per site, create one group with MARKUP_COMMENT per site and assign it to that site only.
  • The board shows the comments of all sites the signed-in user manages (MARKUP_MANAGE on the respective site), whichever site is selected in the CMS, with a site filter and a site and language marker on each card. Comments of other sites do not exist there, not even by ID. The “Comments (n)” tab gets a “Language” column when there is more than one language.
  • Links (mails, board, tabs) point to the domain of the comment’s site or language; CMS links switch site and language along.
  • Agency digest per site: the recipient is the address from that site’s settings; if empty, it falls back to the main site’s address. Sites sharing an address share one mail (then with a heading per site); a mail covering exactly one site other than the main site names it in the subject.
  • One image folder for all: markup/ is global (SubsiteID 0), whichever site the comment is created on. Consequence: administrators see the screenshots and attachments of all sites in the file manager of every site (the folder stays “Only these users” without groups, other editors do not see it).

Migration after the update (only needed if comments already exist and one of the modules is installed):

# 1. Back up the database
vendor/bin/sake db:build --flush                       # new columns SubsiteID, Locale
vendor/bin/sake tasks:markup-site-context --dry-run    # shows what would be backfilled
vendor/bin/sake tasks:markup-site-context

The task backfills existing comments: the site (from the page, otherwise from the host of the stored URL if it is registered with exactly one site), the language (from the URL) and missing pages (the lookup used to fail on the language prefix). Numbers are kept, block assignments are not guessed. It then merges the per-site image folders into the one global folder. Whatever cannot be assigned safely (host registered with several sites, tunnel or old domain without a page, deleted page) stays on the main site and is listed at the end with ID, number, URL and reason. The task is idempotent, CLI-only and reports “nothing to do” without both modules; its output is in German. Run it as the web server user.

Configuration and extension

Tietge\Markup\Context\SiteContextResolver:
  # URL of the main site for links to its comments. Only needed if the CMS is (also) used under
  # another site's domain and SS_BASE_URL does not point to the main site. Empty = current host.
  main_base_url: 'https://www.example.com'

Everything the module needs to know about site and language it asks Tietge\Markup\Context\SiteContextResolver. Projects can step in at two points:

  • Hooks via an Extension on the resolver, e.g. when one domain determines both site and language: updateContextForUrl(SiteContext &$context, string $url), updateBaseUrl(string &$baseUrl, SiteContext $context, ?string $seenUrl), updateCmsParams(array &$params, MarkupComment $comment).
  • Replace the adapters: swap the Injector services Tietge\Markup\Context\SubsiteAdapter and Tietge\Markup\Context\LocaleAdapter (interfaces) for your own class, e.g. for a different translation module.

Known limitations

  • A site without a registered domain is only reachable via the current host; links to its comments point there, and the site of a URL can only be recognised via ?SubsiteID= (CMS preview).
  • Non-public sites (Subsite.IsPublic off) are not recognised by their domain in Subsites: the main site (or the default site) is served there. Comments on such a site only work through the CMS preview (?SubsiteID=).
  • If the agency group is assigned to individual sites only in Subsites, it sees only those in the board. Whether it may open the “Comments” section in the CMS at all is additionally decided by Subsites itself (LeftAndMainSubsites::canAccess()): without access to the page tree only while one of its sites is selected in the CMS.
  • Permissions per site are decided by SubsitesAdapter::memberHas() with its own queries on the group assignment, because Permission::checkMember() does not distinguish sites. Custom canView() extensions on MarkupComment should therefore use Permissions::canComment($member, $subsiteId) or canManage(), not Permission::check().
  • The invite link carries the language that was selected in the CMS when it was copied.
  • If Subsites is uninstalled again, comments with SubsiteID > 0 are invisible in the overlay (the board still shows them).
  • When the migration task assigns a page afterwards, the comment keeps its number; a number can then occur twice on that page.

API

JSON only, Cache-Control: no-store. Session auth; 401 without login, 403 without permission, 404 for comments the member may not see (also when the page is not viewable). Every POST needs the X-Securityid header (token from GET session) and a matching Origin if set; no CORS. Errors: {"error": {"code": "…", "message": "…"}} (400 invalid_json, 404, 405 with Allow, 409 screenshot_exists/screenshot_window_closed, 422 validation, 429 rate_limited with Retry-After, 500 internal_error).

RoutePurpose
GET markup/api/bootstrap?url=session and comments in one response (overlay start)
GET markup/api/session?url=Member, permissions, locale, CSRF token, limits, page and block anchors
GET markup/api/comments?url=Comments for the normalised URL (matched by path and query, not host)
GET markup/api/comments/{id}Single comment, if visible
POST markup/api/commentsCreate: JSON or multipart (data as JSON, attachments[])
POST markup/api/comments/{id}Edit own text, {body}
POST markup/api/comments/{id}/screenshotUpload screenshot (screenshot, optional data.marker) or report failure (data.error); author only, once, within 5 minutes
POST markup/api/comments/{id}/statusSet status, {status}
POST markup/api/comments/{id}/deleteDelete
POST markup/api/comments/{id}/repliesReply, {body}
GET markup/api/media/{id}Screenshot or attachment, if the comment is visible and belongs to the site of the request
GET admin/markup/boardBoard cards, filters site, page, author, q, all=1
POST admin/markup/status/{id}Status from the board, {status}
POST admin/markup/reply/{id}Agency reply, {body}
GET admin/markup/thumbnail/{id}JPEG 640×400 around the point, else 404
GET admin/markup/attachment/{commentId}/{imageId}Attachment scaled down (JPEG, 320 px), else 404
GET admin/markup/media/{commentId}/{imageId}Original screenshot or attachment for the board’s image viewer, regardless of site

Board routes require MARKUP_MANAGE.

Security and privacy

  • Stored: text, page URL (without tracking/preview parameters), anchoring data, viewport, pixel ratio, scroll position, user agent, author, timestamps, screenshot and attachments.
  • Images live under markup/YYYY/MM/ in the asset store, protected immediately (folder “only these users”, no groups), delivered only after a permission check via markup/api/media/{id} and the board’s image routes. Upload is not used, so project extensions that publish uploads do not apply. Board thumbnails are generated in TEMP_PATH/markup-thumbs/, never in assets/.
  • Uploads: real HTTP uploads only, MIME via finfo (JPEG, PNG, WebP, GIF), size and pixel limits before decoding, re-encoded server-side (EXIF/GPS removed, GIF becomes PNG).
  • Plain text only, escaped everywhere. URLs same-origin only, max. 2083 characters. Block anchoring only for target_classes the member may view.
  • Rate limit per member and hour across create, edit, status, delete and reply (screenshot upload excluded).
  • Deleting a comment removes replies and images immediately.
  • Screenshots show a logged-in member's view (in a shop possibly cart or account data); mention this in the privacy notice for clients.

Limitations

  • Screenshots are best effort: no video/iframes, no cross-origin images without CORS, no backdrop-filter, animations captured mid-state. Metadata is reliable; on failure or timeout the comment is kept with ScreenshotError.
  • Points and areas cannot be placed by keyboard (reading, replying and all thread actions can).
  • Anchored to the element, not the word; a point may shift by a word when text reflows.
  • “Show” supports Swiper and <details>; other libraries need registerRevealer. Pure CSS animations do not trigger re-evaluation.
  • “Open at phone size” uses a popup window of the original width (no touch/DPR emulation). Changed page URLs are not tracked.
  • Single tenant; restrict visibility via extension.

Development

npm install
npm run build        # rebuild client/dist (committed)
npm run dev          # watch mode
npm run typecheck
npm test             # vitest (jsdom)
npm run mock         # overlay without SilverStripe: http://localhost:4455/ (?as=agency, &CMSPreview=1)

SS_PHPUNIT_FLUSH=1 vendor/bin/phpunit vendor/tietge/silverstripe-markup/tests   # in a project

Bundles (esbuild): overlay.js, snapdom.js (prefetched when idle, executed on entering comment mode), admin.js/admin.css (uses the silverstripe/admin globals, no own React). Overlay and board texts are in client/src/overlay/i18n.ts and client/src/admin/i18n.ts, PHP texts in lang/*.yml. phpcs.xml.dist (PSR-12) and phpstan.neon.dist (level 5) are included.

Internals (anchoring, visibility, deep-link flow, screenshot, performance, hosting, path repository): docs/ARCHITECTURE.md (German).