Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/).

Nothing yet.

## [0.6.6] - 2026-06-14

### Changed

- The built-in docs page got a light polish while staying tiny and
dependency-free. Operations now have coloured method badges (custom
methods included), a padlock marks the ones that require auth,
deprecated operations are dimmed and flagged, and the header carries
the API version, description, and links to `openapi.json` /
`openapi.yaml`. It is still a flat, no-build, no-CDN route list under
the same strict CSP — the padlock is an inline `<svg>`, not an emoji,
and the inline script/style hashes are re-pinned.

## [0.6.5] - 2026-06-14

### Added
Expand Down Expand Up @@ -598,7 +611,8 @@ Initial release.
Dependabot for gomod/actions/npm with per-package version-parity
tests, and a runnable demo (`cmd/demo`).

[Unreleased]: https://github.com/FumingPower3925/stdocs/compare/v0.6.5...HEAD
[Unreleased]: https://github.com/FumingPower3925/stdocs/compare/v0.6.6...HEAD
[0.6.6]: https://github.com/FumingPower3925/stdocs/compare/v0.6.5...v0.6.6
[0.6.5]: https://github.com/FumingPower3925/stdocs/compare/v0.6.4...v0.6.5
[0.6.4]: https://github.com/FumingPower3925/stdocs/compare/v0.6.3...v0.6.4
[0.6.3]: https://github.com/FumingPower3925/stdocs/compare/v0.6.2...v0.6.3
Expand Down
8 changes: 5 additions & 3 deletions doc.go
Original file line number Diff line number Diff line change
Expand Up @@ -243,9 +243,11 @@
//
// # Docs UIs
//
// The default docs page is a dependency-free route list. It shows a
// dismissable notice that it is the minimal built-in UI and points at
// the richer ones; the dismissal is remembered in localStorage.
// The default docs page is a dependency-free route list: operations
// grouped by path, with method badges, a padlock on secured operations,
// and a marker on deprecated ones. It shows a dismissable notice that it
// is the minimal built-in UI and points at the richer ones; the
// dismissal is remembered in localStorage.
// Four rich UIs ship as sub-packages, each in two flavors: a CDN
// variant pinning exact versions with sha384 subresource-integrity
// hashes, and an embedded variant vendoring the npm bundle bytes for
Expand Down
4 changes: 2 additions & 2 deletions docssecurity.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ import "net/http"
// drift from the HTML without the test failing.
const defaultDocsCSP = "default-src 'none'; base-uri 'none'; form-action 'none'; " +
"frame-ancestors 'self'; connect-src 'self'; " +
"script-src 'sha256-hutH2bs5tHNPsMgpvbDEs4Hb49FijGJIzX4Cvuv0vxs='; " +
"style-src 'sha256-6TwjPVSd/wwIKoYJ4X2RZ+wzoi4gQUC80z79Tc52u/o='"
"script-src 'sha256-xEqNIg+VaDOhbJhVXRy3/0N4r7HncaW8QRmjmJXbUjo='; " +
"style-src 'sha256-xv3fOYGM+zpvtRpVkr1LCW8Zhk6AZpVZ5NfVev/Sv+I='"

// docsPermissionsPolicy denies every powerful browser feature on the
// docs page; an API reference needs none of them. Unrecognised feature
Expand Down
4 changes: 4 additions & 0 deletions docssecurity_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,10 @@ func TestDefaultUINotice(t *testing.T) {
"WithUI()",
"Scalar",
`href="https://pkg.go.dev/github.com/FumingPower3925/stdocs#hdr-Docs_UIs"`,
`id="lock-tmpl"`, // the auth padlock template (no emoji)
`<svg class="lock"`, // a real inline SVG, cloned per secured op
`href="openapi.json"`,
`href="openapi.yaml"`,
} {
if !strings.Contains(body, want) {
t.Errorf("default docs page missing %q", want)
Expand Down
107 changes: 57 additions & 50 deletions rawui.go
Original file line number Diff line number Diff line change
@@ -1,72 +1,79 @@
package stdocs

// defaultUIDoc is the default docs page HTML, served at the docs
// prefix when no UI sub-package is imported. It is a small (~2 KB)
// defaultUIDoc is the default docs page HTML, served at the docs prefix
// when no UI sub-package is imported. It is a small (~4 KB)
// dependency-free page — no CDN, no external assets — whose inline
// script fetches the JSON spec and renders the route list. It also
// shows a dismissable notice pointing at the richer UIs; the dismissal
// is remembered in localStorage. Operation keys are whitelisted (a path
// item's "parameters" array must not render as a method) and all
// spec-derived strings are inserted with textContent, never innerHTML.
// The page carries a strict Content-Security-Policy (see defaultDocsCSP)
// with the inline script and style pinned by hash, so it uses no inline
// style attributes — the notice is shown and hidden via the "hidden"
// attribute, not an inline style.
// script fetches the JSON spec and renders the operations grouped by
// path, with method badges, an auth padlock on secured operations, and
// a marker on deprecated ones. It also shows a dismissable notice that
// it is the minimal built-in UI and points at the richer ones; the
// dismissal is remembered in localStorage. Operation keys are
// whitelisted (a path item's "parameters" array must not render as a
// method) and all spec-derived strings are inserted with textContent,
// never innerHTML. The page carries a strict Content-Security-Policy
// (see defaultDocsCSP) with the inline script and style pinned by hash,
// so it uses no inline style attributes: the notice is shown and hidden
// via the "hidden" attribute, and the padlock is a static inline <svg>
// cloned from a <template>, not an emoji or an injected string.
const defaultUIDoc = `<!doctype html>
<html><head><meta charset="utf-8"><title>{{.Title}}</title>
<style>body{font-family:system-ui,sans-serif;max-width:900px;margin:2em auto;padding:0 1em}
h1{font-size:1.4em}code{background:#eee;padding:0 0.2em;border-radius:3px}
.method{font-weight:600;display:inline-block;min-width:4em}
.GET,.QUERY{color:#0a7}.POST{color:#08c}.PUT{color:#960}.DELETE{color:#c33}.PATCH{color:#960}
<style>
body{font-family:system-ui,-apple-system,sans-serif;max-width:920px;margin:2em auto;padding:0 1em;color:#1c1c1c;line-height:1.5}
h1{font-size:1.5em;margin:0 0 .15em}
.meta{color:#777;font-size:.88em}.meta a{color:#0a58ca;text-decoration:none}.meta a:hover{text-decoration:underline}
.desc{color:#444;margin:.7em 0 0;font-size:.96em}
h2{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:1.02em;margin:1.6em 0 .25em;color:#111;border-bottom:1px solid #eee;padding-bottom:.25em}
.op{display:flex;align-items:center;gap:.6em;padding:.4em .3em;border-radius:5px}
.op:hover{background:#f7f8fa}
.op.dep{opacity:.65}
.m{font-weight:700;font-size:.72em;min-width:3.4em;text-align:center;padding:.22em .3em;border-radius:4px;color:#fff;letter-spacing:.02em}
.GET,.QUERY{background:#16a34a}.POST{background:#0284c7}.PUT{background:#b45309}.PATCH{background:#ca8a04}.DELETE{background:#dc2626}.OTHER{background:#6b7280}
.sum{color:#555;font-size:.93em}
.tag{margin-left:auto;display:flex;gap:.5em;align-items:center}
.pill{font-size:.66em;text-transform:uppercase;letter-spacing:.04em;padding:.12em .45em;border-radius:10px;background:#fde8ec;color:#b3324f;white-space:nowrap}
.lock{width:14px;height:14px;color:#9098a8;flex:none}
#stdocs-note{background:#eef4ff;border:1px solid #cdddff;border-radius:6px;padding:0.6em 0.8em;margin-bottom:1.4em;font-size:0.9em;color:#1c3050}
#stdocs-note:not([hidden]){display:flex;gap:0.6em;align-items:flex-start;justify-content:space-between}
#stdocs-note a{color:#0a58ca}
#stdocs-note button{flex:none;border:0;background:none;font-size:1.3em;line-height:1;cursor:pointer;color:#789;padding:0 0.1em}</style>
</head><body>
#stdocs-note a{color:#0a58ca}#stdocs-note code{background:#dde7fb;padding:0 .25em;border-radius:3px}
#stdocs-note button{flex:none;border:0;background:none;font-size:1.3em;line-height:1;cursor:pointer;color:#789;padding:0 0.1em}
</style></head><body>
<template id="lock-tmpl"><svg class="lock" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" role="img"><title>Requires authentication</title><rect x="4.5" y="10.5" width="15" height="9.5" rx="2"></rect><path d="M8 10.5V7a4 4 0 0 1 8 0v3.5"></path></svg></template>
<div id="stdocs-note" hidden>
<span>You are viewing the minimal built-in docs UI. For a richer reference — Scalar, Swagger UI, Redoc, or Stoplight — import a UI sub-package and pass its <code>WithUI()</code> option, e.g. <code>stdocs.New(scalar.WithUI())</code>. See <a href="https://pkg.go.dev/github.com/FumingPower3925/stdocs#hdr-Docs_UIs" target="_blank" rel="noopener noreferrer">the docs</a>.</span>
<button type="button" id="stdocs-note-x" aria-label="Dismiss this notice">&times;</button>
</div>
<h1>{{.Title}}</h1>
<div id="out">Loading...</div>
<div class="meta"><span id="ver"></span><span id="sep" hidden> · </span><a href="openapi.json">openapi.json</a> · <a href="openapi.yaml">openapi.yaml</a></div>
<p class="desc" id="desc" hidden></p>
<div id="out">Loading…</div>
<script>
(function(){
var KEY='stdocs-docs-notice-dismissed';
var note=document.getElementById('stdocs-note');
var dismissed=false;
try{dismissed=!!localStorage.getItem(KEY);}catch(e){}
if(note&&!dismissed)note.hidden=false;
var x=document.getElementById('stdocs-note-x');
if(x)x.addEventListener('click',function(){
if(note)note.hidden=true;
try{localStorage.setItem(KEY,'1');}catch(e){}
});
})();
(function(){var K='stdocs-docs-notice-dismissed',n=document.getElementById('stdocs-note'),d=false;try{d=!!localStorage.getItem(K);}catch(e){}if(n&&!d)n.hidden=false;var x=document.getElementById('stdocs-note-x');if(x)x.addEventListener('click',function(){if(n)n.hidden=true;try{localStorage.setItem(K,'1');}catch(e){}});})();
const METHODS=['get','put','post','delete','options','head','patch','trace','query'];
function row(out,method,path,op){
const div=document.createElement('div');
const m=document.createElement('span');
m.className='method '+method.toUpperCase();
m.textContent=method.toUpperCase();
const c=document.createElement('code');
c.textContent=path;
div.appendChild(m);
div.appendChild(document.createTextNode(' '));
div.appendChild(c);
if(op&&op.summary){div.appendChild(document.createTextNode(' — '+op.summary));}
out.appendChild(div);
const KNOWN=['GET','PUT','POST','DELETE','PATCH','QUERY'];
function badge(method){var s=document.createElement('span');var M=method.toUpperCase();s.className='m '+(KNOWN.indexOf(M)>=0?M:'OTHER');s.textContent=M;return s;}
function lock(){return document.getElementById('lock-tmpl').content.firstElementChild.cloneNode(true);}
function opRow(method,op){
var dep=op&&op.deprecated;
var row=document.createElement('div');row.className='op'+(dep?' dep':'');
row.appendChild(badge(method));
if(op&&op.summary){var s=document.createElement('span');s.className='sum';s.textContent=op.summary;row.appendChild(s);}
var t=document.createElement('span');t.className='tag';
if(op&&op.security)t.appendChild(lock());
if(dep){var dp=document.createElement('span');dp.className='pill';dp.textContent='deprecated';t.appendChild(dp);}
row.appendChild(t);
return row;
}
fetch('{{.SpecURL}}').then(r=>r.json()).then(spec=>{
const out=document.getElementById('out');
out.textContent='';
var info=spec.info||{};
if(info.version){document.getElementById('ver').textContent='v'+info.version;document.getElementById('sep').hidden=false;}
if(info.description){var dd=document.getElementById('desc');dd.textContent=info.description;dd.hidden=false;}
var out=document.getElementById('out');out.textContent='';
for(const [path,item] of Object.entries(spec.paths||{})){
const h=document.createElement('h2');h.textContent=path;out.appendChild(h);
for(const m of METHODS){
if(item[m])row(out,m,path,item[m]);
}
var h=document.createElement('h2');h.textContent=path;out.appendChild(h);
for(const m of METHODS){if(item[m])out.appendChild(opRow(m,item[m]));}
for(const extra of [item.additionalOperations,item['x-stdocs-additionalOperations']]){
if(!extra)continue;
for(const [m,op] of Object.entries(extra))row(out,m,path,op);
for(const [m,op] of Object.entries(extra))out.appendChild(opRow(m,op));
}
}
});
Expand Down