
Gitlab Docs Publishing
- 20 installs
- 41 repo stars
- Updated July 6, 2026
- aws-samples/sample-agent-skills-for-builders
gitlab-docs-publishing is a Claude skill that publishes HTML design docs on GitLab Pages and injects a text-selection bubble that opens prefilled GitLab Issues for in-context review.
About
This skill publishes HTML or Markdown design documents on GitLab Pages and adds an in-context comment workflow. Reviewers select any text in the published doc, and a floating bubble opens a prefilled GitLab Issue with a title, anchor link, selection quote, and label. It relies only on free GitLab features and the reviewer's existing session, so there is no OAuth, PAT, or in-page API call.
- Publishes HTML/Markdown design docs on GitLab Pages
- Adds a selection-driven bubble that opens a prefilled GitLab Issue
- Uses free GitLab features with no OAuth, PAT, or API calls
Gitlab Docs Publishing by the numbers
- 20 all-time installs (skills.sh)
- Ranked #1,005 of 1,879 Documentation skills by installs in the Skillselion catalog
- Data as of Jul 30, 2026 (Skillselion catalog sync)
gitlab-docs-publishing capabilities & compatibility
Uses only free GitLab features (Pages, Issues); no external services.
- Capabilities
- quip to gitlab wiki · documentation
- Works with
- gitlab
- Use cases
- documentation
- Pricing
- Free
What gitlab-docs-publishing says it does
Publish HTML/Markdown design documents on GitLab Pages with selection-driven discussion.
GitLab handles auth via the user's existing session — no OAuth, no PAT, no API calls.
npx skills add https://github.com/aws-samples/sample-agent-skills-for-builders --skill gitlab-docs-publishingAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 20 |
|---|---|
| repo stars | ★ 41 |
| Last updated | July 6, 2026 |
| Repository | aws-samples/sample-agent-skills-for-builders ↗ |
What it does
Publish design docs on GitLab Pages with a selection-driven bubble that files prefilled GitLab Issues as in-context review comments.
Who is it for?
Teams reviewing design docs on GitLab who want in-context feedback without external services or OAuth.
Skip if: External or public audiences who do not have GitLab accounts.
When should I use this skill?
When publishing design docs on GitLab Pages and enabling selection-driven in-context review comments.
What you get
Design docs are served at a public GitLab Pages URL where selecting text opens a prefilled, labelled GitLab Issue with a working anchor link.
- public/<v>/index.html on GitLab Pages
- comment-widget.js and .css assets
- comments-issue.json config
By the numbers
- 4-step inject-comments pipeline
- deployment checklist with 6 checks
Files
GitLab Docs Publishing
Publish styled HTML design docs on GitLab Pages and let reviewers file in-context feedback by selecting any text and clicking a floating 💬 bubble — which opens a pre-filled GitLab Issue in a new tab. Zero external services, zero in-page API calls.
Standalone HTML design docs are great for reading but miss two things GitLab does not provide out of the box: a public URL (GitLab renders .html blobs as source code, not as a page) and an in-context comment thread tied to the exact piece of prose a reviewer wants to discuss. This skill bolts both on using only free GitLab features:
- GitLab Pages auto-publishes HTML on every merge to main.
- Selection-driven 💬 bubble — a small JS widget watches text selection.
On click, it navigates the browser to GitLab's "New issue" page with title + description + label prefilled. GitLab authenticates the user with their existing session cookie.
When to Apply
- Publishing a technical design doc (HTML or Markdown) for team review.
- Teammates complain they have to
git cloneto see the styled HTML version. - Meeting feedback gets lost because there's no place to write it against
specific sentences.
- An HTML visual doc has been generated from source Markdown and now needs
a distribution channel with feedback affordances.
Not for:
- Documents that belong in GitLab Wiki (Wiki renders Markdown natively and
has built-in comments).
- Single-reviewer review — use MR line comments instead.
- External/public audiences who do not have GitLab accounts.
How It Works
docs/<v>/foo.html
│
│ inject-comments.py:
│ 1. Strip any pre-existing in-page widgets
│ 2. Add ids to headings/figures/tables
│ 3. Inject <link>+<script> for the selection-bubble widget
│ 4. Copy comment-widget.{js,css} into <assets-dir>
↓
public/<v>/index.html ──▶ GitLab Pages CI publishes public/
↓
Reviewer opens published URL
Selects any text → 💬 bubble appears
Clicks → new tab on GitLab issues/new
with title + description + label prefilled
GitLab uses existing session cookie
Reviewer types → submits → labelled Issue createdUsage
Step 1 — Add the Pages CI job
Copy `templates/gitlab-ci-pages.yml` into your project's .gitlab-ci.yml and replace <VERSION> / <MAIN_HTML>. The template already runs the inject step (Step 2 explains its config). After merge to main, the doc is served at the project's Pages URL — check Settings → Pages for the exact host.
Step 2 — Configure the per-page Issue settings
Commit a small per-page config:
// docs/v1.0.0/comments-issue.json
{
"gitlabHost": "https://gitlab.example.com",
"projectPath": "group/subgroup/project",
"pageUrl": "https://<pages-url>/v1.0.0/",
"titlePrefix": "[v1.0.0-tech-design]",
"issueLabels": "doc-comments"
}The CI template invokes the injector with this config. The injector:
- Adds
idattributes to every<h1>–<h6>(slug from heading text),
<div class="diagram-frame"> (figure-N), and <div class="aws-table-wrap"> (table-N) so anchor URLs resolve.
- Injects
<link rel="stylesheet">and<script defer>referencing the
bundled widget assets.
- Copies
comment-widget.{js,css}into--assets-dir. - Is idempotent — re-running on the same HTML doesn't double up.
Step 3 — Validate before publishing
python3 scripts/validate-html.py docs/v1.0.0/tech-design.htmlConfirms tag balance and that anchor IDs are present.
What the reviewer sees
- The published doc renders normally — no extra buttons or chrome anywhere.
- Selecting any text shows a small orange "💬 添加评论" / "💬 Add comment"
bubble above the selection.
- Clicking opens a new tab on GitLab's New Issue page. Title is
[<prefix>] §<heading-text> · "<first 30 chars of selection>…". Description contains an anchor link (back to the section) plus the selection quote, plus a hidden anchor JSON block for forward-compat tooling.
- The reviewer types their comment under "Your comment:" and submits.
Deployment Checklist
- [ ] HTML generated; tag balance and anchors validated.
- [ ]
.gitlab-ci.ymlincludes thepagesjob and the inject step. - [ ]
comments-issue.jsonis committed and has the rightgitlabHost,
projectPath, and pageUrl.
- [ ] Selecting text on the published page shows the bubble.
- [ ] Clicking the bubble opens GitLab's New Issue page with prefilled
title, description, and label.
- [ ] After submitting, the resulting Issue has a working anchor link in
its description that scrolls back to the right section.
References
- Gotchas and common mistakes
- Discussion model: per-Issue vs. shared Issue
- GitLab Pages documentation
- GitLab Issues URL parameters
GitLab Docs Publishing Skill
Publish styled HTML design docs on GitLab Pages and let reviewers file pre-filled GitLab Issues for in-context discussion — by selecting any text in the doc and clicking a small floating 💬 bubble. No OAuth, no PAT, no in-browser API calls.
Installation
npx skills add https://github.com/aws-samples/sample-agent-skills-for-builders --skill gitlab-docs-publishingQuick Start
1. Add the Pages CI job
Copy `templates/gitlab-ci-pages.yml` into your project's .gitlab-ci.yml and replace the <VERSION> / <MAIN_HTML> placeholders. The template already runs inject-comments.py against the config from Step 2.
After merge to main, the doc is served at your project's Pages URL — check Settings → Pages for the exact host.
2. Configure the per-page Issue settings
Commit a small per-page config:
// docs/v1.0.0/comments-issue.json
{
"gitlabHost": "https://gitlab.example.com",
"projectPath": "group/subgroup/project",
"pageUrl": "https://<pages-url>/v1.0.0/",
"titlePrefix": "[v1.0.0-tech-design]",
"issueLabels": "doc-comments"
}The CI template's pages job already invokes the injector with this config. To run it locally for a smoke test:
python3 scripts/inject-comments.py \
--html docs/v1.0.0/tech-design.html \
--out public/v1.0.0/index.html \
--assets-dir public/v1.0.0/_assets \
--assets-url _assets \
--config-json docs/v1.0.0/comments-issue.json \
--page-key v1.0.0/tech-design.htmlThe injector:
- Adds
ids to every<h1>–<h6>(slug from heading text) and to
<div class="diagram-frame"> (figure-N) and <div class="aws-table-wrap"> (table-N).
- Injects a
<link>and a deferred<script>referencing the bundled widget. - Copies
comment-widget.{js,css}into--assets-dir. - Is idempotent — running twice doesn't double up.
3. Validate before publishing
python3 scripts/validate-html.py docs/v1.0.0/tech-design.htmlConfirms tag balance and anchor IDs.
Reviewer experience
- The published doc renders normally — no extra buttons anywhere.
- The reviewer selects any text → a small orange "💬 添加评论" bubble
appears above the selection.
- Click → new tab opens GitLab's New Issue page with:
- Title:
[<prefix>] §<heading-text> · "<first 30 chars of selection>…" - Description: anchor link back to the section + selection quote + a
hidden <!-- doc-comment-anchor-v1 ... --> JSON block for any future tooling that wants to re-locate the selection.
- Label:
doc-comments(configurable). - GitLab authenticates the reviewer with their existing browser session.
- Reviewer types under "Your comment:" and submits.
Prerequisites
- Python 3.8+ — standard library only.
- GitLab project with Pages enabled (default on
gitlab.com; verify on
self-hosted).
- Project Issues enabled so reviewers can file new issues.
File Structure
skills/gitlab-docs-publishing/
├── README.md
├── SKILL.md
├── scripts/
│ ├── inject-comments.py # Injects widget + ids into HTML
│ ├── comment-widget.js # Selection-bubble widget (~7 KB)
│ ├── comment-widget.css # Bubble styles (~1 KB)
│ └── validate-html.py # Tag balance + anchor coverage
├── templates/
│ └── gitlab-ci-pages.yml # Pages CI template
└── references/
├── gotchas.md # Hard-learned pitfalls
└── discussion-model.md # Per-Issue vs. shared-Issue tradeoffsCustomizing
Title format
Default: [<titlePrefix>] §<heading-text> · "<first 30 chars>…". The widget walks back from the selection to find the deepest enclosing heading and uses its text. Edit buildTitle() in scripts/comment-widget.js if you want a different shape.
Different figure / table classes
The injector looks for <div class="diagram-frame"> and <div class="aws-table-wrap"> to add figure-N / table-N ids. If your doc uses different classes, edit DIAGRAM_FRAME_RE / TABLE_WRAP_RE in scripts/inject-comments.py.
Shared-Issue mode
By default each click creates a new Issue. To route all clicks into one shared Issue instead, see `references/discussion-model.md`.
Troubleshooting
Pages URL 404 after merge
- Check Settings → Pages for the exact URL — self-hosted and
proxy-fronted GitLab often serve at <project>-<hash>.pages.<host> rather than at <group>.<gitlab-host>.
- Confirm the
pages:pipeline ran on the merge commit; if you used
rules: changes:, it may have skipped. See `references/gotchas.md`.
Bubble doesn't appear when I select text
- Open the browser console; look for `Doc comments not yet enabled —
missing config: … — means comments-issue.json` is incomplete.
- Confirm the widget loaded:
document.querySelector('script[src*="comment-widget"]') should return the tag. If null, the inject step didn't run.
- Check that
_assets/comment-widget.jsreturns200from the network tab.
Title shows section id like sec-1 instead of heading text
The widget walks back through siblings to find the deepest enclosing heading. If your DOM nests headings unusually (e.g. headings live inside a sibling of the content rather than as a preceding sibling), the fallback is the anchor id. Adjust findOwningHeading() in scripts/comment-widget.js to match your structure.
Clicking opens GitLab but the section doesn't scroll into view
- Confirm
ids are present onh3/h4(runscripts/validate-html.py). - The
pageUrlincomments-issue.jsonmust end with/and match the
actual Pages URL for this document.
Chinese / non-ASCII titles look mangled in the Issue form
URLSearchParams (used by the widget) and urllib.parse.quote (used by the validator) handle this correctly. If you see mangled text, you likely constructed the URL by hand somewhere — keep using the included builders.
License
MIT — see the repository LICENSE file.
References
- GitLab Pages documentation
- GitLab Issues URL parameters
- Gotchas and common mistakes
- Discussion model tradeoffs
Discussion Model: Per-Issue vs. Shared Issue
Pick one model upfront before publishing the doc — mixing both on the same doc confuses reviewers.
Per-discussion Issue (recommended, default)
Each click on the 💬 bubble creates a new GitLab Issue with the section reference in the title and an anchor link in the description.
- Title:
[<prefix>] §<heading-text> · "<selection quote>" - Description: anchor URL back to the section + selection quote + a
hidden anchor JSON metadata block.
- Searchable: Issues are assignable, labelable, and closable per topic.
- Downside: an active review can produce dozens of Issues.
This is what scripts/inject-comments.py and scripts/comment-widget.js ship with — comments-issue.json's issueLabels field tags every Issue (default: doc-comments) so they're easy to filter and triage.
Shared Issue (simpler)
All clicks jump to one central Issue (e.g. #1). The widget would copy the section reference to the clipboard; the reviewer pastes it as a new comment on the shared Issue.
- Pros: one Issue to watch, fewer notifications.
- Cons: threading breaks when many parallel discussions run at once;
you lose the ability to track/close topics individually. GitLab has no URL API to pre-fill a comment on an existing Issue, so the user must paste manually.
To switch to this mode, fork scripts/comment-widget.js and replace buildIssueUrl() with a function that:
1. Sets bubble.href = ${gitlabHost}/${projectPath}/-/issues/<shared-iid>#new_note 2. On click, calls navigator.clipboard.writeText(buildDescription(anchor))` so the reviewer can paste the anchor + quote into the comment box.
Choosing
| Situation | Pick |
|---|---|
| Formal design review, several reviewers, each topic may stay open for days | Per-Issue |
| Informal walkthrough, one short review cycle, minimal bookkeeping desired | Shared Issue |
| Reviewers are external or junior and are likely to forget to file Issues | Shared Issue |
| Each discussion needs an assignee, label, or closes-via-MR tracking | Per-Issue |
When in doubt: start with Per-Issue. Consolidating later is easy; splitting a shared Issue after the fact is not.
Gotchas and Common Mistakes
Hard-learned pitfalls from running this workflow on real GitLab projects.
GitLab sanitizes HTML in Wiki
Don't try to paste raw HTML into Wiki — <script>, <style>, and most class attributes get stripped. Mermaid diagrams won't render and CSS is lost. The only places a styled HTML doc renders are:
- Direct browser open from a cloned repo.
- GitLab Pages (this skill's approach).
- External hosting (S3/CloudFront/Netlify).
GitLab blob URLs don't render HTML
https://<gitlab>/<project>/-/blob/main/docs/foo.html shows the source code as plain text. Pages is the only built-in way to render HTML.
rules: changes: drops Pages pipeline on merge commits
rules:
- if: $CI_COMMIT_BRANCH == "main"
changes: [docs/**/*] # ← this can skip merge commits!When a feature branch merges to main, the merge commit's diff may not show "changes" and the pipeline is skipped. Simplest fix: drop the `changes:` constraint, and let Pages rebuild on every push to main. The job is cheap.
Issue URL prefill supports description + title, NOT comments on existing Issues
GitLab's /issues/new?issue[title]=X&issue[description]=Y works as advertised — but there is no URL API for pre-filling a comment on an existing Issue. If you want all discussion in one Issue, reviewers must copy/paste the section reference manually. Per-Issue-per-discussion is cleaner and is the default this skill ships with.
h3/h4 have no id by default
<h3> usually just contains the heading text. Anchor links like #sec-4-3 fail silently (the browser does nothing) if there's no matching id. The injector script adds ids based on heading text — always smoke-test a sample URL before telling reviewers.
Mermaid figures need their own id wrapper
The mermaid diagram caption lives inside <div class="diagram-caption">. For #figure-N to scroll correctly, the outer <div class="diagram-frame"> wrapper needs the id. The injector adds these sequentially.
URL encoding matters for non-ASCII text
Prefilled titles and descriptions must be URL-encoded. The widget uses URLSearchParams, which handles this correctly. If you build the URL yourself with raw Chinese (or other non-ASCII) text, it will either break the router or get partially encoded by the browser — unreliable either way. Use the included builders.
Title shows section id (§sec-1) instead of heading text (§1.1 背景)
The widget walks back from the selection through previous siblings to find the deepest enclosing heading and uses its text in the title. If your DOM nests headings unusually (e.g. headings live inside a section's later descendant rather than as a preceding sibling of the content), the fallback is the anchor id — which is rarely what you want in the title. Adjust findOwningHeading() in comment-widget.js to match your structure.
Common mistakes
| Mistake | Fix |
|---|---|
Anchor only to parent section (#sec-4) when reviewer wanted #sec-4-3 | Make sure the injector adds ids to h3/h4, not just h2. The included script does this. |
| Wrong doc URL (uses blob URL instead of Pages URL) | Re-check Settings → Pages and update the pageUrl value in comments-issue.json. |
| Pipeline doesn't rerun on merge commits | Remove changes: from the rules: block. |
| Reviewers expect comments to appear "in the page" | They don't — clicking opens GitLab in a new tab. Set expectations in your team announcement; "comment lives as a labelled GitLab Issue, not as inline marginalia". |
| Bubble overlaps something important when reviewer selects near the top of the viewport | The bubble positions itself 40 px above the selection's top edge. If your doc has a fixed top header, increase the offset in repositionBubble() or scope the widget to a content container. |
/* Game AI QA — Doc comments widget styles
*
* Minimal: just the floating "💬 添加评论" bubble that appears above a text
* selection. Click → opens GitLab Issue creation in a new tab.
*/
/* ── Bubble (selection floater) ────────────────────────────────────────── */
#dc-bubble {
position: absolute;
z-index: 9999;
display: inline-block;
background: #ff9900;
color: #fff;
font: 600 12px/1.2 -apple-system, system-ui, "Segoe UI", "PingFang SC", sans-serif;
border-radius: 16px;
padding: 6px 12px;
cursor: pointer;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.18);
user-select: none;
white-space: nowrap;
text-decoration: none;
transition: background 0.12s, transform 0.08s;
}
#dc-bubble:hover { background: #e88b00; }
#dc-bubble:active { transform: scale(0.96); }
#dc-bubble[hidden] { display: none; }
/* Game AI QA — In-page Doc Comments (selection → new GitLab Issue)
*
* Reviewer selects any text or block element. A floating "💬 添加评论" bubble
* appears above the selection. Clicking opens a new tab on GitLab's Issue
* creation page with title + description prefilled — reviewer's Midway/SSO
* session authenticates them automatically. No CORS/OAuth/PAT involved.
*
* Why this shape: AEA + GitLab CORS make any in-browser API call impossible
* from the Pages origin. Plain navigation works because the browser handles
* the auth handshake itself.
*
* Config injected as window.__DOC_COMMENTS_CONFIG__ before this script.
*/
(function () {
'use strict';
// ─── Config ──────────────────────────────────────────────────────────────
var cfg = window.__DOC_COMMENTS_CONFIG__ || {};
var required = ['gitlabHost', 'projectPath', 'pageKey', 'pageUrl'];
var missing = required.filter(function (k) { return !cfg[k]; });
if (missing.length) {
showSetupBanner('Doc comments not yet enabled — missing config: ' + missing.join(', '));
return;
}
var TITLE_PREFIX = cfg.titlePrefix || '[doc-comment]';
var ISSUE_LABELS = cfg.issueLabels || 'doc-comments';
// ─── Anchor model ────────────────────────────────────────────────────────
// Same shape as before so the JSON in the issue body is forward-compatible
// if we ever add an in-page reader that fetches & re-locates anchors.
function anchorFromRange(range) {
if (range.collapsed) return null;
var exact = range.toString().slice(0, 280);
if (!exact.trim()) return null;
var prefix = textBefore(range, 32);
var suffix = textAfter(range, 32);
var startEl = range.startContainer.nodeType === 1
? range.startContainer
: range.startContainer.parentElement;
var heading = findOwningHeading(startEl);
return {
type: 'text',
id: heading && heading.id ? heading.id : null,
sectionTitle: heading ? cleanHeadingText(heading) : null,
exact: exact,
prefix: prefix,
suffix: suffix,
};
}
// Walk back through previous siblings + their descendants, then up the tree,
// collecting the deepest (highest-level h-tag number) heading seen so far.
// Stops as soon as we find a heading that owns the current node.
function findOwningHeading(el) {
var node = el;
while (node && node !== document.body) {
// 1. Check this node itself if it's a heading
if (/^H[1-6]$/.test(node.tagName)) return node;
// 2. Walk back through preceding siblings looking for a heading
var sib = node.previousElementSibling;
while (sib) {
var h = lastHeadingIn(sib);
if (h) return h;
sib = sib.previousElementSibling;
}
node = node.parentElement;
}
return null;
}
// Return the LAST heading inside `el` (so a section's last subsection wins
// over its parent title when selecting text under that subsection).
function lastHeadingIn(el) {
if (!el || el.nodeType !== 1) return null;
if (/^H[1-6]$/.test(el.tagName)) return el;
var hs = el.querySelectorAll && el.querySelectorAll('h1, h2, h3, h4, h5, h6');
if (hs && hs.length) return hs[hs.length - 1];
return null;
}
function cleanHeadingText(h) {
return (h.textContent || '').trim().replace(/\s+/g, ' ').slice(0, 80);
}
function textBefore(range, n) {
var s = range.startContainer.textContent || '';
return s.slice(Math.max(0, range.startOffset - n), range.startOffset);
}
function textAfter(range, n) {
var s = range.endContainer.textContent || '';
return s.slice(range.endOffset, range.endOffset + n);
}
// ─── Title + description for the GitLab Issue ───────────────────────────
// Format: "[doc-comment] §<section-title> · <selection-quote>"
// Section title comes from the heading the selection sits under (when available).
// Falls back to the anchor id, then to the page key. Selection quote is short
// (first ~30 chars, ellipsised) so the title stays scannable.
function buildTitle(anchor) {
var section = anchor.sectionTitle || anchor.id || cfg.pageKey;
var quote = (anchor.exact || '').replace(/\s+/g, ' ').trim();
if (quote.length > 30) quote = quote.slice(0, 30) + '…';
var parts = [TITLE_PREFIX, '§' + section];
if (quote) parts.push('· "' + quote + '"');
return parts.join(' ').slice(0, 240);
}
function buildDescription(anchor) {
var anchorUrl = cfg.pageUrl + (anchor.id ? '#' + anchor.id : '');
var anchorLabel = anchor.sectionTitle || anchor.id || cfg.pageKey;
var lines = [];
lines.push('**Anchor**: [§' + anchorLabel + '](' + anchorUrl + ')');
lines.push('');
lines.push('**Selection**:');
lines.push('> ' + (anchor.exact || '').replace(/\n/g, '\n> '));
lines.push('');
lines.push('---');
lines.push('');
lines.push('<!-- doc-comment-anchor-v1');
lines.push(JSON.stringify({ anchor: anchor, page: cfg.pageKey }));
lines.push('-->');
lines.push('');
lines.push('Your comment:');
lines.push('');
return lines.join('\n');
}
function buildIssueUrl(anchor) {
var base = cfg.gitlabHost + '/' + cfg.projectPath + '/-/issues/new';
var p = new URLSearchParams();
p.set('issue[title]', buildTitle(anchor));
p.set('issue[description]', buildDescription(anchor));
if (ISSUE_LABELS) p.set('issue[label_names][]', ISSUE_LABELS);
return base + '?' + p.toString();
}
// ─── Bubble UI ───────────────────────────────────────────────────────────
var bubble;
function build() {
bubble = document.createElement('a');
bubble.id = 'dc-bubble';
bubble.target = '_blank';
bubble.rel = 'noopener noreferrer';
bubble.hidden = true;
bubble.textContent = '💬 添加评论';
document.body.appendChild(bubble);
// Keep selection while clicking the bubble.
bubble.addEventListener('mousedown', function (e) { e.preventDefault(); });
document.addEventListener('mouseup', maybeShowBubble);
document.addEventListener('selectionchange', maybeShowBubble);
document.addEventListener('scroll', repositionBubble, { passive: true });
window.addEventListener('resize', repositionBubble);
}
var lastRect = null;
function maybeShowBubble() {
var sel = window.getSelection();
if (!sel || sel.isCollapsed || sel.rangeCount === 0) {
bubble.hidden = true;
lastRect = null;
return;
}
var range = sel.getRangeAt(0);
var text = range.toString();
if (!text || !text.trim()) {
bubble.hidden = true;
lastRect = null;
return;
}
if (bubble.contains(range.startContainer)) return;
var anchor = anchorFromRange(range);
if (!anchor) {
bubble.hidden = true;
return;
}
bubble.href = buildIssueUrl(anchor);
lastRect = range.getBoundingClientRect();
repositionBubble();
bubble.hidden = false;
}
function repositionBubble() {
if (!lastRect || bubble.hidden) return;
var r = lastRect;
bubble.style.top = (window.scrollY + r.top - 40) + 'px';
bubble.style.left = (window.scrollX + r.left + r.width / 2 - 64) + 'px';
}
// ─── Setup-banner (only when config missing) ────────────────────────────
function showSetupBanner(msg) {
var b = document.createElement('div');
b.id = 'dc-setup-banner';
b.textContent = msg;
b.style.cssText = 'position:fixed;top:0;left:0;right:0;background:#ffe9b2;' +
'color:#7a4a00;padding:10px 16px;font:13px system-ui;' +
'z-index:99999;border-bottom:1px solid #d8a520;';
if (document.body) document.body.appendChild(b);
else document.addEventListener('DOMContentLoaded', function () { document.body.appendChild(b); });
}
// ─── Public API for testing ─────────────────────────────────────────────
window.docComments = {
buildIssueUrl: buildIssueUrl,
anchorFromRange: anchorFromRange,
};
// ─── Boot ───────────────────────────────────────────────────────────────
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', build);
} else {
build();
}
})();
#!/usr/bin/env python3
"""Inject the doc-comments widget into a published HTML file.
Idempotent: running twice does not duplicate <link>/<script> tags.
Zero deps (stdlib only).
Usage (typical CI):
python3 scripts/inject-comments.py \
--html docs/v1.0.0/tech-design.html \
--out public/v1.0.0/index.html \
--assets-dir public/v1.0.0/_assets \
--assets-url _assets \
--config-json docs/v1.0.0/comments-issue.json \
--page-key v1.0.0/tech-design.html
"""
from __future__ import annotations
import argparse
import json
import re
import shutil
import sys
from pathlib import Path
INJECTED_META = '<meta name="doc-comments-injected" content="v1">'
SCRIPT_NAME = 'comment-widget.js'
STYLE_NAME = 'comment-widget.css'
# ─── Heading id slugger ────────────────────────────────────────────────────
_slug_strip = re.compile(r'[^\w一-鿿\- ]+', re.UNICODE)
_ws = re.compile(r'\s+')
def slugify(text: str) -> str:
text = (text or '').strip().lower()
text = _slug_strip.sub('', text)
text = _ws.sub('-', text).strip('-')
return text or 'sec'
# ─── Heading id ensure ─────────────────────────────────────────────────────
HEADING_RE = re.compile(r'<(h[1-6])(\s[^>]*)?>(.*?)</\1>', re.DOTALL | re.IGNORECASE)
ID_ATTR_RE = re.compile(r'\bid\s*=\s*"([^"]*)"', re.IGNORECASE)
TAG_TEXT_RE = re.compile(r'<[^>]+>')
def ensure_heading_ids(html: str) -> str:
seen: set[str] = set()
# First pass: collect existing ids so we don't collide.
for m in re.finditer(r'\bid\s*=\s*"([^"]*)"', html, re.IGNORECASE):
seen.add(m.group(1))
def repl(m: re.Match) -> str:
tag = m.group(1)
attrs = m.group(2) or ''
inner = m.group(3)
if ID_ATTR_RE.search(attrs):
return m.group(0)
text = TAG_TEXT_RE.sub('', inner).strip()
slug = slugify(text)
cand = slug
i = 2
while cand in seen:
cand = f'{slug}-{i}'
i += 1
seen.add(cand)
new_attrs = (attrs or '') + f' id="{cand}"'
return f'<{tag}{new_attrs}>{inner}</{tag}>'
return HEADING_RE.sub(repl, html)
# ─── Figure / table id helpers (best effort, mirrors existing skill style) ─
DIAGRAM_FRAME_RE = re.compile(
r'<div([^>]*\bclass="[^"]*diagram-frame[^"]*"[^>]*)>',
re.IGNORECASE,
)
TABLE_WRAP_RE = re.compile(
r'<div([^>]*\bclass="[^"]*aws-table-wrap[^"]*"[^>]*)>',
re.IGNORECASE,
)
# ─── Strip stale `<a class="discuss-btn">` markup if present ────────────
# Source HTML produced by older versions of this skill (≤ v1.x) carried
# per-heading 💬 buttons. The v2 selection bubble covers that use case,
# so we strip those legacy anchors from the published artifact (the
# source HTML is left untouched). If your source HTML doesn't have them,
# this is a no-op and safe to keep.
LEGACY_BTN_RE = re.compile(
r'<a\s+class="discuss-btn"[^>]*>.*?</a>',
re.DOTALL | re.IGNORECASE,
)
LEGACY_CSS_RULE_RE = re.compile(
r'\.discuss-btn[^{}]*\{[^{}]*\}',
re.IGNORECASE,
)
def strip_legacy_discuss_buttons(html: str) -> tuple[str, int]:
new_html, btn_count = LEGACY_BTN_RE.subn('', html)
new_html = LEGACY_CSS_RULE_RE.sub('', new_html)
return new_html, btn_count
def add_sequential_ids(html: str, regex: re.Pattern, prefix: str) -> str:
counter = {'n': 0}
def repl(m: re.Match) -> str:
attrs = m.group(1)
if ID_ATTR_RE.search(attrs):
return m.group(0)
counter['n'] += 1
return f'<div{attrs} id="{prefix}-{counter["n"]}">'
return regex.sub(repl, html)
# ─── Injection ─────────────────────────────────────────────────────────────
def already_injected(html: str) -> bool:
return 'name="doc-comments-injected"' in html
def build_config_snippet(
*, gitlab_host: str, project_path: str,
page_key: str, page_url: str,
title_prefix: str, issue_labels: str,
) -> str:
payload = {
'gitlabHost': gitlab_host,
'projectPath': project_path,
'pageKey': page_key,
'pageUrl': page_url,
'titlePrefix': title_prefix,
'issueLabels': issue_labels,
}
js = json.dumps(payload, ensure_ascii=False)
return f'<script>window.__DOC_COMMENTS_CONFIG__ = {js};</script>'
def inject_assets(html: str, assets_rel: str, config_snippet: str) -> str:
if already_injected(html):
return html
head_inject = (
f' {INJECTED_META}\n'
f' <link rel="stylesheet" href="{assets_rel}/{STYLE_NAME}">\n'
)
body_inject = (
f' {config_snippet}\n'
f' <script src="{assets_rel}/{SCRIPT_NAME}" defer></script>\n'
)
if '</head>' in html:
html = html.replace('</head>', head_inject + '</head>', 1)
else:
html = head_inject + html # no <head>; degrade
if '</body>' in html:
html = html.replace('</body>', body_inject + '</body>', 1)
else:
html = html + body_inject
return html
# ─── Driver ────────────────────────────────────────────────────────────────
def main() -> int:
p = argparse.ArgumentParser()
p.add_argument('--html', required=True, help='Input HTML file')
p.add_argument('--out', required=True, help='Output HTML file (e.g. public/v1.0.0/index.html)')
p.add_argument('--assets-dir', required=True, help='Where comment-widget.{js,css} get copied')
p.add_argument('--assets-url', default='_assets',
help='URL path (relative to the HTML) where the browser fetches assets')
p.add_argument('--config-json', required=True,
help='Path to comments-issue.json (gitlabHost + projectPath + pageUrl)')
p.add_argument('--page-key', required=True, help='Logical key stored with each comment')
args = p.parse_args()
src_path = Path(args.html)
out_path = Path(args.out)
assets_dir = Path(args.assets_dir)
cfg_path = Path(args.config_json)
if not src_path.is_file():
print(f'ERROR: html not found: {src_path}', file=sys.stderr)
return 2
if not cfg_path.is_file():
print(f'ERROR: config json not found: {cfg_path}', file=sys.stderr)
return 2
cfg = json.loads(cfg_path.read_text(encoding='utf-8'))
project_path = cfg.get('projectPath')
page_url = cfg.get('pageUrl')
if not project_path or not page_url:
print('ERROR: comments-issue.json missing projectPath or pageUrl', file=sys.stderr)
return 3
# 1. Read source
html = src_path.read_text(encoding='utf-8')
# 2. Strip stale `<a class="discuss-btn">` markup from earlier tooling.
html, stripped = strip_legacy_discuss_buttons(html)
if stripped:
print(f'OK: stripped {stripped} stale discuss-btn link(s) from source')
# 3. Augment with ids (so anchors like #sec-4-3 resolve to the right place)
html = ensure_heading_ids(html)
html = add_sequential_ids(html, DIAGRAM_FRAME_RE, 'figure')
html = add_sequential_ids(html, TABLE_WRAP_RE, 'table')
# 4. Inject (idempotent)
snippet = build_config_snippet(
gitlab_host=cfg.get('gitlabHost', 'https://gitlab.aws.dev'),
project_path=project_path,
page_key=args.page_key,
page_url=page_url,
title_prefix=cfg.get('titlePrefix', '[doc-comment]'),
issue_labels=cfg.get('issueLabels', 'doc-comments'),
)
html = inject_assets(html, args.assets_url, snippet)
# 5. Write output + copy widget assets
out_path.parent.mkdir(parents=True, exist_ok=True)
out_path.write_text(html, encoding='utf-8')
assets_dir.mkdir(parents=True, exist_ok=True)
here = Path(__file__).parent
for name in (SCRIPT_NAME, STYLE_NAME):
shutil.copy2(here / name, assets_dir / name)
print(f'OK: wrote {out_path} ({out_path.stat().st_size} bytes)')
print(f'OK: copied assets to {assets_dir}/')
return 0
if __name__ == '__main__':
raise SystemExit(main())
#!/usr/bin/env python3
"""Validate an HTML doc before publishing.
Checks:
1. Every tag opens and closes cleanly (simple stack-based parser).
2. Headings have id attributes (so widget-generated anchor URLs scroll
to the right place after submission).
3. Counts: sections, headings, mermaid figures, tables.
Run on the source HTML before the inject step:
python3 validate-html.py docs/v1.0.0/tech-design.html
Or on the published HTML to confirm the inject step ran:
python3 validate-html.py public/v1.0.0/index.html
"""
import re
import sys
from html.parser import HTMLParser
from pathlib import Path
class TagBalance(HTMLParser):
VOID = {
"br", "hr", "meta", "link", "img", "input", "area",
"base", "col", "embed", "source", "track", "wbr",
}
def __init__(self):
super().__init__()
self.stack = []
self.errors = []
def handle_starttag(self, tag, attrs):
if tag in self.VOID:
return
self.stack.append(tag)
def handle_endtag(self, tag):
if tag in self.VOID:
return
if self.stack and self.stack[-1] == tag:
self.stack.pop()
else:
self.errors.append(
f"expected </{self.stack[-1] if self.stack else '?'}> got </{tag}>"
)
HEADING_RE = re.compile(r'<(h[1-6])([^>]*)>', re.IGNORECASE)
ID_ATTR_RE = re.compile(r'\bid\s*=\s*"([^"]+)"', re.IGNORECASE)
def main():
if len(sys.argv) < 2:
print("Usage: validate-html.py <file.html>", file=sys.stderr)
sys.exit(1)
html = Path(sys.argv[1]).read_text(encoding="utf-8")
print(f"=== {sys.argv[1]} — {len(html):,} bytes ===\n")
# 1. Tag balance
bal = TagBalance()
bal.feed(html)
if bal.errors:
print(f"❌ Tag balance: {len(bal.errors)} errors")
for e in bal.errors[:5]:
print(f" - {e}")
else:
print("✅ Tag balance: OK")
if bal.stack:
print(f" Unclosed tags at EOF: {bal.stack[-5:]}")
# 2. Heading id coverage
headings = HEADING_RE.findall(html)
headings_without_id = [
tag for tag, attrs in headings if not ID_ATTR_RE.search(attrs)
]
if headings_without_id:
print(
f"⚠ Heading ids: {len(headings_without_id)} of {len(headings)} "
f"h1..h6 have no id (run inject-comments.py to add them)"
)
else:
print(f"✅ Heading ids: all {len(headings)} h1..h6 have id attributes")
# 3. Counts
print()
print(f"Headings (h1..h6): {len(headings)}")
print(f"Mermaid figures: {html.count('<div class=\"diagram-frame\"')}")
print(f"Tables (aws-table): {html.count('<div class=\"aws-table-wrap\"')}")
print(
"Widget injected: "
+ ("yes" if 'doc-comments-injected' in html else "no")
)
if bal.errors:
sys.exit(1)
if __name__ == "__main__":
main()
# GitLab Pages deployment template for technical design docs.
#
# What it does:
# - Runs on every push to main
# - Copies docs/<VERSION>/*.html → public/<VERSION>/index.html
# - Copies companion .md files for reference
# - Generates a simple landing index.html at project root
#
# After merge, docs are served at the project Pages URL. Check
# Settings → Pages in your GitLab project for the exact hostname.
#
# Placeholders to replace before committing:
# <VERSION> → e.g. v1.0.0 or latest
# <MAIN_HTML> → e.g. tech-design.html (the main HTML doc)
pages:
stage: deploy
image: python:3.11-alpine
script:
- mkdir -p public/<VERSION>
# Inject the selection-bubble widget + heading/figure/table ids into the
# published HTML. Outputs index.html and copies comment-widget.{js,css}
# into public/<VERSION>/_assets. See SKILL.md for config-json schema.
- python3 scripts/inject-comments.py
--html docs/<VERSION>/<MAIN_HTML>
--out public/<VERSION>/index.html
--assets-dir public/<VERSION>/_assets
--assets-url _assets
--config-json docs/<VERSION>/comments-issue.json
--page-key <VERSION>/<MAIN_HTML>
- cp docs/<VERSION>/*.md public/<VERSION>/ 2>/dev/null || true
- |
cat > public/index.html <<'HTML'
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>Project Documentation</title>
<style>
body { font-family: -apple-system, system-ui, 'Segoe UI', sans-serif;
max-width: 720px; margin: 80px auto; padding: 0 24px; color: #16191f; }
h1 { color: #232f3e; }
ul { line-height: 2; }
a { color: #0972d3; text-decoration: none; }
a:hover { text-decoration: underline; }
.meta { color: #879596; font-size: 13px; margin-top: 24px; }
</style>
</head>
<body>
<h1>Project Documentation</h1>
<ul>
<li><a href="<VERSION>/"><VERSION> Technical Design</a></li>
</ul>
<div class="meta">Hosted on GitLab Pages · Auto-updated on push to main.</div>
</body>
</html>
HTML
artifacts:
paths:
- public
expire_in: never
# NOTE: We intentionally don't use `changes:` here. Merge-commit diffs can
# bypass `changes:` rules, causing the Pages pipeline to skip. Trigger on
# every main push instead — the job is cheap.
rules:
- if: $CI_COMMIT_BRANCH == "main"
Related skills
FAQ
Does this need OAuth or a personal access token?
No. GitLab handles auth via the user's existing session, so there is no OAuth, no PAT, and no in-page API calls.
How do reviewers leave feedback?
They select any text in the published doc; a floating bubble opens a prefilled GitLab Issue in a new tab with title, anchor link, selection quote, and label.