tietge / silverstripe-markup
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
Type:silverstripe-vendormodule
pkg:composer/tietge/silverstripe-markup
Requires
- php: ^8.3
- silverstripe/admin: ^3
- silverstripe/assets: ^3
- silverstripe/cms: ^6
- silverstripe/framework: ^6
Requires (Dev)
- phpstan/phpstan: ^2
- phpunit/phpunit: ^11.3
- squizlabs/php_codesniffer: ^3
Suggests
- dnadesign/silverstripe-elemental: Verankerung der Anmerkungen am getroffenen Block und Reiter „Anmerkungen“ am Element.
Provides
None
Conflicts
None
Replaces
None
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
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).
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.
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
| Permission | For | Allows |
|---|---|---|
MARKUP_COMMENT (“Comments: create”) | Client | See, create and reply to comments; edit own (while open), delete own (open, no replies), resolve own |
MARKUP_MANAGE (“Comments: manage”) | Agency | See 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.
# 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; withcustomer_notifications: digestone mail per client with agency replies and resolved comments.--only=agency|customerslimits the run.tasks:markup-cleanup: deletes screenshot and attachmentsmedia_retention_daysafter “Resolved”; text and anchoring remain.customer_notifications: immediatesends client mails on save instead (new reply, resolved). Own replies and self-resolved comments never trigger a mail. Sender isEmail.admin_email; failed sends are logged and never break saving.
Without cron: no agency digest, no client mails (with digest), no image cleanup.
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).
| Route | Purpose |
|---|---|
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/comments | Create: JSON or multipart (data as JSON, attachments[]) |
POST markup/api/comments/{id} | Edit own text, {body} |
POST markup/api/comments/{id}/screenshot | Upload screenshot (screenshot, optional data.marker) or report failure (data.error); author only, once, within 5 minutes |
POST markup/api/comments/{id}/status | Set status, {status} |
POST markup/api/comments/{id}/delete | Delete |
POST markup/api/comments/{id}/replies | Reply, {body} |
GET markup/api/media/{id} | Screenshot or attachment, if the comment is visible |
GET admin/markup/board | Board cards, filters 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 |
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 viamarkup/api/media/{id}after a permission check.Uploadis not used, so project extensions that publish uploads do not apply. Board thumbnails are generated inTEMP_PATH/markup-thumbs/, never inassets/. - 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_classesthe 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 withScreenshotError. - 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 needregisterRevealer. 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).





