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.
- silverstripe/subsites: ^4: Anmerkungen je Auftritt (Zuordnung, Links auf die Domain des Auftritts, Filter im Board).
- tractorcow/silverstripe-fluent: ^8: Anmerkungen auf mehrsprachigen Seiten (Sprachpräfix und Sprach-Domains).
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). - Optional with
tractorcow/silverstripe-fluentandsilverstripe/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
| 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. (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; 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.
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_MANAGEonly there, a group with “access to all subsites” everywhere; on the main site only groups with access to all subsites count.ADMINmay do everything everywhere. Without a permission on a site the overlay does not appear there, and the API answers 403, whatever URL orSubsiteIDparameter 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 withMARKUP_COMMENTper site and assign it to that site only. - The board shows the comments of all sites the signed-in user manages (
MARKUP_MANAGEon 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 (SubsiteID0), 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
Extensionon 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\SubsiteAdapterandTietge\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.IsPublicoff) 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, becausePermission::checkMember()does not distinguish sites. CustomcanView()extensions onMarkupCommentshould therefore usePermissions::canComment($member, $subsiteId)orcanManage(), notPermission::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 > 0are 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).
| 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 and belongs to the site of the request |
GET admin/markup/board | Board 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 viamarkup/api/media/{id}and the board’s image routes.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).





